snapshot of https://github.com/dream-num/univer.git (dev, 2026-09-16; blobs > 131072B omitted)
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled

This commit is contained in:
upstream-mirror
2026-09-16 05:09:45 +05:00
commit bcf6ef63ad
7118 changed files with 1236854 additions and 0 deletions

View File

@@ -0,0 +1,59 @@
# @univerjs/docs-thread-comment-ui
[![npm version](https://img.shields.io/npm/v/@univerjs/docs-thread-comment-ui?style=flat-square)](https://npmjs.com/package/@univerjs/docs-thread-comment-ui)
[![license](https://img.shields.io/npm/l/@univerjs/docs-thread-comment-ui?style=flat-square)](https://npmjs.com/package/@univerjs/docs-thread-comment-ui)
[![downloads](https://img.shields.io/npm/dm/@univerjs/docs-thread-comment-ui?style=flat-square)](https://npmjs.com/package/@univerjs/docs-thread-comment-ui)
`@univerjs/docs-thread-comment-ui` adds thread comment UI integration for Univer Docs.
## Package Overview
| Package | UMD global | CSS | Locales | Facade entry |
| --- | --- | :---: | :---: | :---: |
| `@univerjs/docs-thread-comment-ui` | `UniverDocsThreadCommentUi` | Yes | Yes | No |
## Installation
```sh
pnpm add @univerjs/docs-thread-comment @univerjs/docs-thread-comment-ui @univerjs/thread-comment-ui
# or
npm install @univerjs/docs-thread-comment @univerjs/docs-thread-comment-ui @univerjs/thread-comment-ui
```
Keep all `@univerjs/*` packages on the same version.
## Usage
```ts
import { LocaleType, mergeLocales, Univer } from '@univerjs/core';
import '@univerjs/docs-thread-comment/facade';
import { UniverDocsThreadCommentUIPlugin } from '@univerjs/docs-thread-comment-ui';
import DocsThreadCommentUIEnUS from '@univerjs/docs-thread-comment-ui/locale/en-US';
import { UniverThreadCommentUIPlugin } from '@univerjs/thread-comment-ui';
import ThreadCommentUIEnUS from '@univerjs/thread-comment-ui/locale/en-US';
import '@univerjs/docs-thread-comment-ui/lib/index.css';
import '@univerjs/thread-comment-ui/lib/index.css';
const univer = new Univer({
locale: LocaleType.EN_US,
locales: {
[LocaleType.EN_US]: mergeLocales(DocsThreadCommentUIEnUS, ThreadCommentUIEnUS),
},
});
univer.registerPlugin(UniverThreadCommentUIPlugin);
univer.registerPlugin(UniverDocsThreadCommentUIPlugin);
const range = univerAPI.getActiveDocument()?.getTextRange(0, 12);
await range?.createCommentAsync('Verify this introduction.', { id: 'review-intro' });
```
## Integration Notes
Register the shared thread-comment UI plugin before the Docs adapter. Use `@univerjs/docs-thread-comment` for headless model commands and Facade APIs; this UI package adds menus, rendering, and the shared side panel. The underlying `@univerjs/thread-comment` plugin is registered automatically as a dependency.
## Resources
- [Documentation](https://docs.univer.ai)
- [NPM package](https://npmjs.com/package/@univerjs/docs-thread-comment-ui)
- [GitHub repository](https://github.com/dream-num/univer)

View File

@@ -0,0 +1,98 @@
{
"name": "@univerjs/docs-thread-comment-ui",
"version": "1.0.0-rc.0",
"private": false,
"description": "Thread comment UI integration for Univer Docs.",
"author": "DreamNum Co., Ltd. <developer@univer.ai>",
"license": "Apache-2.0",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/univer"
},
"homepage": "https://univer.ai",
"repository": {
"type": "git",
"url": "https://github.com/dream-num/univer"
},
"bugs": {
"url": "https://github.com/dream-num/univer/issues"
},
"keywords": [
"univer",
"docs",
"comment",
"thread-comment",
"ui"
],
"exports": {
".": "./src/index.ts",
"./*": "./src/*",
"./locale/*": "./src/locale/*.ts"
},
"main": "./src/index.ts",
"types": "./lib/types/index.d.ts",
"publishConfig": {
"access": "public",
"main": "./lib/es/index.js",
"module": "./lib/es/index.js",
"exports": {
".": {
"import": "./lib/es/index.js",
"require": "./lib/cjs/index.js",
"types": "./lib/types/index.d.ts"
},
"./*": {
"import": "./lib/es/*",
"require": "./lib/cjs/*",
"types": "./lib/types/index.d.ts"
},
"./locale/*": {
"import": "./lib/es/locale/*.js",
"require": "./lib/cjs/locale/*.js",
"types": "./lib/types/locale/*.d.ts"
},
"./lib/*": "./lib/*"
}
},
"directories": {
"lib": "lib"
},
"files": [
"lib"
],
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"build:bundle": "univer-cli build",
"build:types": "tsc -p tsconfig.node.json",
"build": "pnpm run build:bundle && pnpm run build:types"
},
"peerDependencies": {
"react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
"rxjs": ">=7.0.0"
},
"dependencies": {
"@univerjs/core": "workspace:*",
"@univerjs/docs": "workspace:*",
"@univerjs/docs-thread-comment": "workspace:*",
"@univerjs/docs-ui": "workspace:*",
"@univerjs/drawing": "workspace:*",
"@univerjs/engine-render": "workspace:*",
"@univerjs/icons": "1.38.0",
"@univerjs/protocol": "workspace:*",
"@univerjs/thread-comment": "workspace:*",
"@univerjs/thread-comment-ui": "workspace:*",
"@univerjs/ui": "workspace:*"
},
"devDependencies": {
"@univerjs-infra/shared": "workspace:*",
"postcss": "^8.5.28",
"react": "18.3.1",
"rxjs": "^7.8.2",
"tailwindcss": "3.4.18",
"typescript": "^6.0.3",
"vitest": "^5.0.0"
}
}

View File

@@ -0,0 +1 @@
export { default } from '@univerjs-infra/shared/postcss';

View File

@@ -0,0 +1,310 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IDocumentData, Injector } from '@univerjs/core';
import type { IThreadComment } from '@univerjs/thread-comment';
import { ICommandService, IUniverInstanceService, Univer, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService, DocStateEmitService, RichTextEditingMutation } from '@univerjs/docs';
import {
AddDocCommentDecorationMutation,
CreateDocTextRangeCommentCommand,
DEFAULT_DOC_SUBUNIT_ID,
} from '@univerjs/docs-thread-comment';
import { FDocument } from '@univerjs/docs/facade';
import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import {
AddCommentMutation,
IThreadCommentDataSourceService,
ThreadCommentDataSourceService,
ThreadCommentFacadeService,
ThreadCommentModel,
} from '@univerjs/thread-comment';
import { SetActiveCommentOperation, ThreadCommentPanelService } from '@univerjs/thread-comment-ui';
import { DesktopSidebarService, ISidebarService } from '@univerjs/ui';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AddDocCommentComment } from '../add-doc-comment.command';
import '@univerjs/docs-thread-comment/facade';
const DOC_ID = 'doc-add-comment-test';
function createDocData(): IDocumentData {
return {
id: DOC_ID,
body: {
dataStream: 'Hello world\r\n',
textRuns: [],
paragraphs: [],
sectionBreaks: [],
customBlocks: [],
customDecorations: [],
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
function createComment(id = 'comment-1'): IThreadComment {
return {
id,
threadId: '',
ref: '',
unitId: DOC_ID,
subUnitId: DEFAULT_DOC_SUBUNIT_ID,
dT: '2026-06-17T00:00:00.000Z',
personId: 'user-1',
text: {
dataStream: 'Please revise this wording.\r\n',
},
};
}
describe('AddDocCommentComment', () => {
let univer: Univer;
let injector: Injector;
let get: Injector['get'];
let commandService: ICommandService;
function getDocBody() {
const univerInstanceService = get(IUniverInstanceService);
return univerInstanceService.getUnit<DocumentDataModel>(DOC_ID, UniverInstanceType.UNIVER_DOC)?.getBody();
}
beforeEach(() => {
univer = new Univer();
injector = univer.__getInjector();
get = injector.get.bind(injector);
injector.add([DocSelectionManagerService]);
injector.add([DocStateEmitService]);
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([IThreadCommentDataSourceService, { useClass: ThreadCommentDataSourceService }]);
injector.add([ThreadCommentModel]);
injector.add([ThreadCommentFacadeService]);
injector.add([ISidebarService, { useClass: DesktopSidebarService }]);
injector.add([ThreadCommentPanelService]);
univer.createUnit(UniverInstanceType.UNIVER_DOC, createDocData());
get(IUniverInstanceService).focusUnit(DOC_ID);
commandService = get(ICommandService);
commandService.registerCommand(AddDocCommentComment);
commandService.registerCommand(CreateDocTextRangeCommentCommand);
commandService.registerCommand(AddDocCommentDecorationMutation);
commandService.registerCommand(AddCommentMutation);
commandService.registerCommand(SetActiveCommentOperation);
commandService.registerCommand(RichTextEditingMutation);
});
afterEach(() => {
univer.dispose();
});
it('adds a thread comment to an explicit document range and makes it active', async () => {
const comment = createComment();
const result = await commandService.executeCommand(AddDocCommentComment.id, {
unitId: DOC_ID,
comment,
range: {
startOffset: 0,
endOffset: 5,
collapsed: false,
},
});
expect(result).toBe(true);
const thread = get(ThreadCommentModel).getThread(DOC_ID, DEFAULT_DOC_SUBUNIT_ID, comment.id);
expect(thread?.root).toMatchObject({
id: comment.id,
threadId: comment.id,
unitId: DOC_ID,
subUnitId: DEFAULT_DOC_SUBUNIT_ID,
personId: 'user-1',
text: {
dataStream: 'Please revise this wording.\r\n',
},
});
expect(getDocBody()?.customDecorations).toEqual([
expect.objectContaining({
id: comment.id,
startIndex: 0,
endIndex: 4,
}),
]);
expect(get(ThreadCommentPanelService).activeCommentId).toEqual({
unitId: DOC_ID,
subUnitId: DEFAULT_DOC_SUBUNIT_ID,
commentId: comment.id,
});
});
it('uses the server-assigned id for the document anchor', async () => {
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({
unitId: DOC_ID,
subUnitId: DOC_ID,
});
selectionManager.__TEST_ONLY_add([{
startOffset: 0,
endOffset: 5,
collapsed: false,
isActive: true,
segmentId: '',
style: null as never,
}]);
get(IThreadCommentDataSourceService).dataSource = {
addComment: vi.fn(async (comment: IThreadComment) => ({
...comment,
id: 'server-comment-id',
threadId: 'server-comment-id',
})),
updateComment: vi.fn(),
resolveComment: vi.fn(),
deleteComment: vi.fn(),
listComments: vi.fn(),
saveCommentToSnapshot: (value) => value,
};
const result = await commandService.executeCommand(AddDocCommentComment.id, {
unitId: DOC_ID,
comment: createComment('client-comment-id'),
range: { startOffset: 0, endOffset: 5, collapsed: false },
});
expect(result).toBe(true);
expect(getDocBody()?.customDecorations).toEqual([
expect.objectContaining({ id: 'server-comment-id' }),
]);
expect(get(ThreadCommentModel).getThread(
DOC_ID,
DEFAULT_DOC_SUBUNIT_ID,
'server-comment-id'
)?.root.id).toBe('server-comment-id');
expect(get(ThreadCommentPanelService).activeCommentId?.commentId).toBe('server-comment-id');
});
it('rejects a collapsed explicit range before writing to the data source', async () => {
const comment = createComment('comment-without-range');
const addComment = vi.fn(async (value: IThreadComment) => value);
get(IThreadCommentDataSourceService).dataSource = {
addComment,
updateComment: vi.fn(),
resolveComment: vi.fn(),
deleteComment: vi.fn(),
listComments: vi.fn(),
saveCommentToSnapshot: (value) => value,
};
const result = await commandService.executeCommand(AddDocCommentComment.id, {
unitId: DOC_ID,
comment,
range: {
startOffset: 5,
endOffset: 5,
collapsed: true,
},
});
const outOfBounds = await commandService.executeCommand(AddDocCommentComment.id, {
unitId: DOC_ID,
comment,
range: {
startOffset: 0,
endOffset: 100,
collapsed: false,
},
});
expect(result).toBe(false);
expect(outOfBounds).toBe(false);
expect(get(ThreadCommentModel).getThread(DOC_ID, DEFAULT_DOC_SUBUNIT_ID, comment.id)).toBeUndefined();
expect(getDocBody()?.customDecorations).toEqual([]);
expect(get(ThreadCommentPanelService).activeCommentId).toBeUndefined();
expect(addComment).not.toHaveBeenCalled();
});
it('creates an agent-supplied text-range comment with queryable offsets', async () => {
const result = await commandService.executeCommand(CreateDocTextRangeCommentCommand.id, {
unitId: DOC_ID,
range: { startOffset: 0, endOffset: 5, collapsed: false },
content: 'Check the greeting.',
id: 'agent-comment-1',
personId: 'agent-user-1',
});
expect(result).toBe(true);
expect(get(ThreadCommentModel).getThread(
DOC_ID,
DEFAULT_DOC_SUBUNIT_ID,
'agent-comment-1'
)?.root).toMatchObject({
id: 'agent-comment-1',
threadId: 'agent-comment-1',
personId: 'agent-user-1',
ref: 'Hello',
startOffset: 0,
endOffset: 5,
collapsed: false,
text: { dataStream: 'Check the greeting.\r\n' },
});
expect(getDocBody()?.customDecorations).toEqual([
expect.objectContaining({ id: 'agent-comment-1', startIndex: 0, endIndex: 4 }),
]);
});
it('creates and queries a comment through FDocumentTextRange', async () => {
const documentModel = get(IUniverInstanceService).getUnit<DocumentDataModel>(DOC_ID, UniverInstanceType.UNIVER_DOC);
if (!documentModel) {
throw new Error('Test document was not created.');
}
const document = injector.createInstance(FDocument, documentModel);
const range = document.getTextRange(6, 11);
await expect(range.createCommentAsync('Review the noun.', {
id: 'facade-comment-1',
personId: 'agent-user-2',
})).resolves.toBe(true);
expect(range.getComments()).toHaveLength(1);
expect(range.getComments()[0]).toMatchObject({
threadId: 'facade-comment-1',
root: {
ref: 'world',
startOffset: 6,
endOffset: 11,
personId: 'agent-user-2',
},
});
expect(document.getTextRange(0, 5).getComments()).toEqual([]);
});
it('rejects an incomplete add-comment request without changing the document', async () => {
const result = await commandService.executeCommand(AddDocCommentComment.id);
expect(result).toBe(false);
expect(get(ThreadCommentModel).getAll()).toEqual([]);
expect(getDocBody()?.customDecorations).toEqual([]);
expect(get(ThreadCommentPanelService).activeCommentId).toBeUndefined();
});
});

View File

@@ -0,0 +1,80 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IDocumentData } from '@univerjs/core';
import { CustomDecorationType, ICommandService, IUniverInstanceService, Univer, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService, DocStateEmitService, RichTextEditingMutation } from '@univerjs/docs';
import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DeleteDocCommentComment } from '../delete-doc-comment.command';
function createDocData(id: string): IDocumentData {
return {
id,
body: {
dataStream: 'Hello world\r\n',
customDecorations: [{ id: 'c1', startIndex: 0, endIndex: 5, type: CustomDecorationType.COMMENT }],
},
documentStyle: {
pageSize: { width: 100, height: 100 },
marginTop: 0,
marginBottom: 0,
marginLeft: 0,
marginRight: 0,
},
};
}
describe('DeleteDocCommentComment', () => {
let univer: Univer;
let injector: ReturnType<Univer['__getInjector']>;
let commandService: ICommandService;
beforeEach(() => {
univer = new Univer();
injector = univer.__getInjector();
injector.add([DocSelectionManagerService]);
injector.add([DocStateEmitService]);
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
commandService = injector.get(ICommandService);
commandService.registerCommand(DeleteDocCommentComment);
commandService.registerCommand(RichTextEditingMutation);
});
afterEach(() => {
univer.dispose();
});
it('removes the comment decoration from the document', async () => {
const doc = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, createDocData('doc-1'));
injector.get(IUniverInstanceService).focusUnit(doc.getUnitId());
const ok = await commandService.executeCommand(DeleteDocCommentComment.id, { unitId: 'doc-1', commentId: 'c1' });
expect(ok).toBe(true);
expect(doc.getBody()?.customDecorations).toEqual([]);
});
it('rejects an incomplete delete request without changing the document', async () => {
const doc = univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, createDocData('doc-1'));
injector.get(IUniverInstanceService).focusUnit(doc.getUnitId());
const ok = await commandService.executeCommand(DeleteDocCommentComment.id);
expect(ok).toBe(false);
expect(doc.getBody()?.customDecorations).toEqual([{ id: 'c1', startIndex: 0, endIndex: 5, type: CustomDecorationType.COMMENT }]);
});
});

View File

@@ -0,0 +1,64 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICommand } from '@univerjs/core';
import type { IAddDocTextRangeCommentParams } from '@univerjs/docs-thread-comment';
import { CommandType, ICommandService, sequenceExecute } from '@univerjs/core';
import { RichTextEditingMutation } from '@univerjs/docs';
import {
prepareDocTextRangeComment,
} from '@univerjs/docs-thread-comment';
import { SetActiveCommentOperation } from '@univerjs/thread-comment-ui';
export type IAddDocCommentComment = IAddDocTextRangeCommentParams;
/** Adds a document comment and activates it in the UI comment panel. */
export const AddDocCommentComment: ICommand<IAddDocCommentComment> = {
id: 'docs.command.add-comment',
type: CommandType.COMMAND,
async handler(accessor, params) {
if (!params) {
return false;
}
const prepared = await prepareDocTextRangeComment(accessor, params);
if (!prepared) {
return false;
}
const activeOperation = {
id: SetActiveCommentOperation.id,
params: {
unitId: prepared.comment.unitId,
subUnitId: prepared.comment.subUnitId,
commentId: prepared.comment.id,
},
};
const decorationMutation = {
id: RichTextEditingMutation.id,
params: {
...prepared.decorationMutationParams,
textRanges: null,
noNeedSetTextRange: true,
},
};
return (await sequenceExecute([
prepared.commentMutation,
decorationMutation,
activeOperation,
], accessor.get(ICommandService))).result;
},
};

View File

@@ -0,0 +1,47 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICommand } from '@univerjs/core';
import { CommandType, ICommandService, sequenceExecute } from '@univerjs/core';
import { deleteCustomDecorationFactory } from '@univerjs/docs-ui';
export interface IDeleteDocCommentComment {
unitId: string;
commentId: string;
}
export const DeleteDocCommentComment: ICommand<IDeleteDocCommentComment> = {
id: 'docs.command.delete-comment',
type: CommandType.COMMAND,
async handler(accessor, params) {
if (!params) {
return false;
}
const { commentId, unitId } = params;
const commandService = accessor.get(ICommandService);
const doMutation = deleteCustomDecorationFactory(accessor, {
id: commentId,
unitId,
});
if (doMutation) {
return (await sequenceExecute([doMutation], commandService)).result;
}
return false;
},
};

View File

@@ -0,0 +1,246 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IDisposable, IDocumentData } from '@univerjs/core';
import type { IRender } from '@univerjs/engine-render';
import type { ISidebarMethodOptions } from '@univerjs/ui';
import {
Disposable,
DrawingTypeEnum,
ICommandService,
IUniverInstanceService,
toDisposable,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { DEFAULT_DOC_SUBUNIT_ID } from '@univerjs/docs-thread-comment';
import { DrawingManagerService, IDrawingManagerService } from '@univerjs/drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import { ThreadCommentPanelService } from '@univerjs/thread-comment-ui';
import { ISidebarService } from '@univerjs/ui';
import { Subject } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DOCS_THREAD_COMMENT_PANEL } from '../../../common/const';
import { DocThreadCommentService } from '../../../services/doc-thread-comment.service';
import {
AddDocDrawingCommentOperation,
ShowCommentPanelOperation,
StartAddCommentOperation,
ToggleCommentPanelOperation,
} from '../show-comment-panel.operation';
const unitId = 'doc-thread-comment-operation-doc';
function createDocData(): IDocumentData {
return {
id: unitId,
body: {
dataStream: 'Hello world\r\n',
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
class TestSidebarService extends Disposable implements ISidebarService {
readonly sidebarOptions$ = new Subject<ISidebarMethodOptions>();
readonly scrollEvent$ = new Subject<Event>();
private _options: ISidebarMethodOptions = {};
private _container?: HTMLElement;
private _width?: number;
get visible(): boolean {
return this._options.visible ?? false;
}
get options(): ISidebarMethodOptions {
return this._options;
}
get width(): number | undefined {
return this._width;
}
open(params: ISidebarMethodOptions): IDisposable {
this._options = { ...params, visible: true };
this.sidebarOptions$.next(this._options);
return toDisposable(() => this.close());
}
close(): void {
this._options = { ...this._options, visible: false };
this.sidebarOptions$.next(this._options);
this._options.onClose?.();
}
getContainer(): HTMLElement | undefined {
return this._container;
}
setContainer(element?: HTMLElement): void {
this._container = element;
}
setWidth(value: number): void {
this._width = value;
}
override dispose(): void {
super.dispose();
this.sidebarOptions$.complete();
this.scrollEvent$.complete();
}
}
class TestRenderManagerService implements Pick<IRenderManagerService, 'getRenderUnitById'> {
getRenderUnitById(): IRender | null {
return null;
}
}
describe('doc thread comment panel operations', () => {
let univer: Univer;
let commandService: ICommandService;
let sidebarService: ISidebarService;
let panelService: ThreadCommentPanelService;
let selectionManager: DocSelectionManagerService;
let docThreadCommentService: DocThreadCommentService;
beforeEach(() => {
univer = new Univer();
const injector = univer.__getInjector();
injector.add([ISidebarService, { useClass: TestSidebarService }]);
injector.add([ThreadCommentPanelService]);
injector.add([DocSelectionManagerService]);
injector.add([DocThreadCommentService]);
injector.add([IRenderManagerService, { useClass: TestRenderManagerService as never }]);
univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, createDocData());
injector.get(IUniverInstanceService).focusUnit(unitId);
commandService = injector.get(ICommandService);
commandService.registerCommand(ShowCommentPanelOperation);
commandService.registerCommand(ToggleCommentPanelOperation);
commandService.registerCommand(StartAddCommentOperation);
sidebarService = injector.get(ISidebarService);
panelService = injector.get(ThreadCommentPanelService);
selectionManager = injector.get(DocSelectionManagerService);
docThreadCommentService = injector.get(DocThreadCommentService);
});
afterEach(() => {
univer.dispose();
});
it('opens the docs comment panel and activates the requested comment', async () => {
const activeComment = { unitId, subUnitId: 'doc', commentId: 'comment-1' };
const result = await commandService.executeCommand(ShowCommentPanelOperation.id, {
activeComment,
});
expect(result).toBe(true);
expect(panelService.panelVisible).toBe(true);
expect(panelService.activeCommentId).toEqual(activeComment);
expect(sidebarService.visible).toBe(true);
expect(sidebarService.options.children?.label).toBe(DOCS_THREAD_COMMENT_PANEL);
});
it('toggles an open comment panel closed and clears the active comment', async () => {
const activeComment = { unitId, subUnitId: 'doc', commentId: 'comment-2' };
await commandService.executeCommand(ShowCommentPanelOperation.id, {
activeComment,
});
const result = await commandService.executeCommand(ToggleCommentPanelOperation.id);
expect(result).toBe(true);
expect(panelService.panelVisible).toBe(false);
expect(panelService.activeCommentId).toBeNull();
expect(sidebarService.visible).toBe(false);
});
it('starts a new comment draft from the selected document text', async () => {
selectionManager.__TEST_ONLY_setCurrentSelection({
unitId,
subUnitId: unitId,
});
selectionManager.__TEST_ONLY_add([{
startOffset: 0,
endOffset: 5,
collapsed: false,
isActive: true,
segmentId: '',
style: null as never,
}]);
const result = await commandService.executeCommand(StartAddCommentOperation.id);
expect(result).toBe(true);
expect(panelService.panelVisible).toBe(true);
expect(docThreadCommentService.addingComment).toMatchObject({
unitId,
ref: 'Hello',
startOffset: 0,
endOffset: 5,
collapsed: true,
});
expect(panelService.activeCommentId).toEqual({
unitId,
subUnitId: DEFAULT_DOC_SUBUNIT_ID,
commentId: '',
});
});
it('does not start a new comment draft without selected document text', async () => {
const result = await commandService.executeCommand(StartAddCommentOperation.id);
expect(result).toBe(false);
expect(docThreadCommentService.addingComment).toBeUndefined();
expect(panelService.panelVisible).toBe(false);
});
it('rejects a focused drawing left over from another document', () => {
const injector = univer.__getInjector();
const drawing = {
unitId: 'other-doc',
subUnitId: 'other-doc',
drawingId: 'drawing-1',
drawingType: DrawingTypeEnum.DRAWING_SHAPE,
};
const drawingManager = new DrawingManagerService();
drawingManager.registerDrawingData(drawing.unitId, {
[drawing.subUnitId]: { data: { [drawing.drawingId]: drawing }, order: [drawing.drawingId] },
});
drawingManager.focusDrawing([drawing]);
injector.add([IDrawingManagerService, {
useValue: drawingManager,
}]);
expect(AddDocDrawingCommentOperation.handler(injector)).toBe(false);
drawingManager.dispose();
});
});

View File

@@ -0,0 +1,177 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICommand } from '@univerjs/core';
import type { ActiveCommentInfo } from '@univerjs/thread-comment-ui';
import { BuildTextUtils, CommandType, ICommandService, IUniverInstanceService, UniverInstanceType, UserManagerService } from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { DEFAULT_DOC_SUBUNIT_ID } from '@univerjs/docs-thread-comment';
import { DocSelectionRenderService } from '@univerjs/docs-ui';
import { IDrawingManagerService } from '@univerjs/drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import { getDT, ThreadCommentAnchorKind } from '@univerjs/thread-comment';
import { ThreadCommentDraftService, ThreadCommentPanelService } from '@univerjs/thread-comment-ui';
import { ISidebarService } from '@univerjs/ui';
import { DOCS_THREAD_COMMENT_PANEL } from '../../common/const';
import { DocThreadCommentService } from '../../services/doc-thread-comment.service';
export interface IShowCommentPanelOperationParams {
activeComment: ActiveCommentInfo;
}
export const ShowCommentPanelOperation: ICommand<IShowCommentPanelOperationParams> = {
id: 'docs.operation.show-comment-panel',
type: CommandType.OPERATION,
handler(accessor, params) {
const panelService = accessor.get(ThreadCommentPanelService);
const sidebarService = accessor.get(ISidebarService);
if (!panelService.panelVisible || sidebarService.options.children?.label !== DOCS_THREAD_COMMENT_PANEL) {
sidebarService.open({
header: { title: 'docs-thread-comment-ui.panel.title' },
children: { label: DOCS_THREAD_COMMENT_PANEL },
width: 320,
onClose: () => panelService.setPanelVisible(false),
});
panelService.setPanelVisible(true);
}
if (params) {
panelService.setActiveComment(params?.activeComment);
}
return true;
},
};
export const ToggleCommentPanelOperation: ICommand = {
id: 'docs.operation.toggle-comment-panel',
type: CommandType.OPERATION,
handler(accessor) {
const panelService = accessor.get(ThreadCommentPanelService);
const sidebarService = accessor.get(ISidebarService);
if (!panelService.panelVisible || sidebarService.options.children?.label !== DOCS_THREAD_COMMENT_PANEL) {
sidebarService.open({
header: { title: 'docs-thread-comment-ui.panel.title' },
children: { label: DOCS_THREAD_COMMENT_PANEL },
width: 320,
onClose: () => panelService.setPanelVisible(false),
});
panelService.setPanelVisible(true);
} else {
sidebarService.close();
panelService.setPanelVisible(false);
panelService.setActiveComment(null);
}
return true;
},
};
export const StartAddCommentOperation: ICommand = {
id: 'docs.operation.start-add-comment',
type: CommandType.OPERATION,
handler(accessor) {
const panelService = accessor.get(ThreadCommentPanelService);
const univerInstanceService = accessor.get(IUniverInstanceService);
const doc = univerInstanceService.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const renderManagerService = accessor.get(IRenderManagerService);
const userManagerService = accessor.get(UserManagerService);
const docCommentService = accessor.get(DocThreadCommentService);
const commandService = accessor.get(ICommandService);
const sidebarService = accessor.get(ISidebarService);
const textRange = docSelectionManagerService.getActiveTextRange();
if (!doc || !textRange) {
return false;
}
const docSelectionRenderManager = renderManagerService.getRenderUnitById(doc.getUnitId())?.with(DocSelectionRenderService);
docSelectionRenderManager?.setReserveRangesStatus(true);
if (textRange.collapsed) {
if (panelService.panelVisible) {
panelService.setPanelVisible(false);
sidebarService.close();
} else {
commandService.executeCommand(ShowCommentPanelOperation.id);
}
return true;
}
commandService.executeCommand(ShowCommentPanelOperation.id);
const unitId = doc.getUnitId();
const dataStream = (doc.getBody()?.dataStream ?? '').slice(textRange.startOffset, textRange.endOffset);
const text = BuildTextUtils.transform.getPlainText(dataStream);
const subUnitId = DEFAULT_DOC_SUBUNIT_ID;
const commentId = '';
const comment = {
unitId,
subUnitId,
id: commentId,
ref: text,
dT: getDT(),
personId: userManagerService.getCurrentUser().userID,
text: {
dataStream: '\r\n',
},
startOffset: textRange.startOffset!,
endOffset: textRange.endOffset!,
collapsed: true,
threadId: commentId,
};
docSelectionRenderManager?.blur();
docCommentService.startAdd(comment);
panelService.setActiveComment({
unitId,
subUnitId,
commentId,
});
return true;
},
};
export const AddDocDrawingCommentOperation: ICommand = {
id: 'docs.operation.add-drawing-comment',
type: CommandType.OPERATION,
handler(accessor) {
const drawing = accessor.get(IDrawingManagerService).getFocusDrawings()[0];
const doc = accessor.get(IUniverInstanceService)
.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (!drawing || !doc || drawing.unitId !== doc.getUnitId()) {
return false;
}
accessor.get(ThreadCommentDraftService).place({
unitId: drawing.unitId,
subUnitId: drawing.subUnitId,
anchor: {
kind: ThreadCommentAnchorKind.DOC_DRAWING,
pageId: drawing.subUnitId,
elementId: drawing.drawingId,
},
});
const panelService = accessor.get(ThreadCommentPanelService);
accessor.get(ISidebarService).open({
header: { title: 'docs-thread-comment-ui.panel.title' },
children: { label: DOCS_THREAD_COMMENT_PANEL },
width: 320,
onClose: () => panelService.setPanelVisible(false),
});
panelService.setPanelVisible(true);
return true;
},
};

View File

@@ -0,0 +1,21 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const DOCS_THREAD_COMMENT_PANEL = 'univer.doc.thread-comment-panel';
export const PLUGIN_NAME = 'DOC_THREAD_COMMENT_UI_PLUGIN';
export const DEFAULT_TEMP_COMMENT_ID = 'default_comment';

View File

@@ -0,0 +1,27 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { MenuConfig } from '@univerjs/ui';
export const DOCS_THREAD_COMMENT_UI_PLUGIN_CONFIG_KEY = 'docs-thread-comment-ui.config';
export const configSymbol = Symbol(DOCS_THREAD_COMMENT_UI_PLUGIN_CONFIG_KEY);
export interface IUniverDocsThreadCommentUIConfig {
menu?: MenuConfig;
}
export const defaultPluginConfig: IUniverDocsThreadCommentUIConfig = {};

View File

@@ -0,0 +1,191 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CustomDecorationType } from '@univerjs/core';
import { SetTextSelectionsOperation } from '@univerjs/docs';
import { DEFAULT_DOC_SUBUNIT_ID } from '@univerjs/docs-thread-comment';
import { SetActiveCommentOperation } from '@univerjs/thread-comment-ui';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { ShowCommentPanelOperation } from '../../commands/operations/show-comment-panel.operation';
import { DocThreadCommentSelectionController } from '../doc-thread-comment-selection.controller';
describe('DocThreadCommentSelectionController', () => {
it('should show comment panel when selection enters a comment decoration', () => {
const activeCommentId$ = new Subject<any>();
const threadCommentPanelService = {
activeCommentId: null,
activeCommentId$,
};
const doc = {
getBody: () => ({
customDecorations: [
{ id: 'outer', type: CustomDecorationType.COMMENT, startIndex: 0, endIndex: 10 },
{ id: 'older', type: CustomDecorationType.COMMENT, startIndex: 0, endIndex: 5 },
{ id: 'not-comment', type: CustomDecorationType.DELETED, startIndex: 0, endIndex: 5 },
{ id: 'newest', type: CustomDecorationType.COMMENT, startIndex: 0, endIndex: 5 },
],
}),
};
const univerInstanceService = {
getUnit: vi.fn(() => doc),
};
let onExecutedHandler: any;
const executeCommand = vi.fn();
const commandService = {
onCommandExecuted: vi.fn((fn) => {
onExecutedHandler = fn;
return { dispose: vi.fn() };
}),
executeCommand,
};
const docThreadCommentService = { addingComment: null, endAdd: vi.fn() };
const renderManagerService = { getRenderUnitById: vi.fn(() => null) };
const threadCommentModel = { getComment: vi.fn(() => ({ resolved: false })) };
const controller = new DocThreadCommentSelectionController(
threadCommentPanelService as any,
univerInstanceService as any,
commandService as any,
docThreadCommentService as any,
renderManagerService as any,
threadCommentModel as any
);
onExecutedHandler({
id: SetTextSelectionsOperation.id,
params: {
unitId: 'doc-1',
ranges: [{ startOffset: 1, endOffset: 2, collapsed: true }],
},
});
expect(executeCommand).toHaveBeenCalledWith(ShowCommentPanelOperation.id, {
activeComment: { unitId: 'doc-1', subUnitId: DEFAULT_DOC_SUBUNIT_ID, commentId: 'newest' },
});
controller.dispose();
});
it('should reset active comment when selection moves out', () => {
const activeCommentId$ = new Subject<any>();
const threadCommentPanelService = {
activeCommentId: { unitId: 'doc-1', subUnitId: DEFAULT_DOC_SUBUNIT_ID, commentId: 'c1' },
activeCommentId$,
};
const doc = {
getBody: () => ({ customDecorations: [{ id: 'c1', type: CustomDecorationType.COMMENT, startIndex: 0, endIndex: 5 }] }),
};
const univerInstanceService = { getUnit: vi.fn(() => doc) };
let onExecutedHandler: any;
const executeCommand = vi.fn();
const commandService = {
onCommandExecuted: vi.fn((fn) => {
onExecutedHandler = fn;
return { dispose: vi.fn() };
}),
executeCommand,
};
const docThreadCommentService = { addingComment: null, endAdd: vi.fn() };
const renderManagerService = { getRenderUnitById: vi.fn(() => null) };
const threadCommentModel = { getComment: vi.fn(() => ({ resolved: false })) };
const controller = new DocThreadCommentSelectionController(
threadCommentPanelService as any,
univerInstanceService as any,
commandService as any,
docThreadCommentService as any,
renderManagerService as any,
threadCommentModel as any
);
onExecutedHandler({
id: SetTextSelectionsOperation.id,
params: {
unitId: 'doc-1',
ranges: [{ startOffset: 10, endOffset: 10, collapsed: true }],
},
});
expect(executeCommand).toHaveBeenCalledWith(SetActiveCommentOperation.id);
controller.dispose();
});
it('should keep the temporary adding comment active when selection changes before the comment is saved', () => {
const activeCommentId$ = new Subject<any>();
const threadCommentPanelService = {
activeCommentId: { unitId: 'doc-1', subUnitId: DEFAULT_DOC_SUBUNIT_ID, commentId: '' },
activeCommentId$,
};
const doc = {
getBody: () => ({ customDecorations: [] }),
};
const univerInstanceService = { getUnit: vi.fn(() => doc) };
let onExecutedHandler: any;
const executeCommand = vi.fn();
const commandService = {
onCommandExecuted: vi.fn((fn) => {
onExecutedHandler = fn;
return { dispose: vi.fn() };
}),
executeCommand,
};
const docThreadCommentService = {
addingComment: {
unitId: 'doc-1',
subUnitId: DEFAULT_DOC_SUBUNIT_ID,
id: '',
},
endAdd: vi.fn(),
};
const renderManagerService = { getRenderUnitById: vi.fn(() => null) };
const threadCommentModel = { getComment: vi.fn(() => null) };
const controller = new DocThreadCommentSelectionController(
threadCommentPanelService as any,
univerInstanceService as any,
commandService as any,
docThreadCommentService as any,
renderManagerService as any,
threadCommentModel as any
);
onExecutedHandler({
id: SetTextSelectionsOperation.id,
params: {
unitId: 'doc-1',
ranges: [{ startOffset: 10, endOffset: 10, collapsed: true }],
},
});
expect(executeCommand).not.toHaveBeenCalledWith(SetActiveCommentOperation.id);
controller.dispose();
});
});

View File

@@ -0,0 +1,50 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Disposable, Inject } from '@univerjs/core';
import { CommentIcon, InsertCommentDoubleIcon } from '@univerjs/icons';
import { ComponentManager, IconManager } from '@univerjs/ui';
import { DOCS_THREAD_COMMENT_PANEL } from '../common/const';
import { DocThreadCommentPanel } from '../views/DocThreadCommentPanel';
export class ComponentsController extends Disposable {
constructor(
@Inject(ComponentManager) private readonly _componentManager: ComponentManager,
@Inject(IconManager) private readonly _iconManager: IconManager
) {
super();
this._registerComponents();
this._registerIcons();
}
private _registerComponents(): void {
([
[DOCS_THREAD_COMMENT_PANEL, DocThreadCommentPanel],
] as const).forEach(([id, comp]) => {
this.disposeWithMe(
this._componentManager.register(id, comp)
);
});
}
private _registerIcons(): void {
this.disposeWithMe(this._iconManager.register({
CommentIcon,
InsertCommentDoubleIcon,
}));
}
}

View File

@@ -0,0 +1,145 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, ICustomDecoration, ITextRange } from '@univerjs/core';
import type { ISetTextSelectionsOperationParams } from '@univerjs/docs';
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
import {
CustomDecorationType,
Disposable,
ICommandService,
Inject,
isInternalEditorID,
IUniverInstanceService,
UniverInstanceType,
} from '@univerjs/core';
import { SetTextSelectionsOperation } from '@univerjs/docs';
import { DEFAULT_DOC_SUBUNIT_ID } from '@univerjs/docs-thread-comment';
import { DocBackScrollRenderController } from '@univerjs/docs-ui';
import { IRenderManagerService } from '@univerjs/engine-render';
import { ThreadCommentModel } from '@univerjs/thread-comment';
import { SetActiveCommentOperation, ThreadCommentPanelService } from '@univerjs/thread-comment-ui';
import { ShowCommentPanelOperation } from '../commands/operations/show-comment-panel.operation';
import { DocThreadCommentService } from '../services/doc-thread-comment.service';
export class DocThreadCommentSelectionController extends Disposable {
constructor(
@Inject(ThreadCommentPanelService) private readonly _threadCommentPanelService: ThreadCommentPanelService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@ICommandService private readonly _commandService: ICommandService,
@Inject(DocThreadCommentService) private readonly _docThreadCommentService: DocThreadCommentService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@Inject(ThreadCommentModel) private readonly _threadCommentModel: ThreadCommentModel
) {
super();
this._initSelectionChange();
this._initActiveCommandChange();
}
private _initSelectionChange() {
let lastSelection: ITextRange | undefined;
this.disposeWithMe(
this._commandService.onCommandExecuted((commandInfo) => {
if (commandInfo.id === SetTextSelectionsOperation.id) {
const params = commandInfo.params as ISetTextSelectionsOperationParams;
const { unitId, ranges } = params;
if (isInternalEditorID(unitId)) return;
const doc = this._univerInstanceService.getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
const primary = ranges[0] as ITextRangeWithStyle | undefined;
if (lastSelection?.startOffset === primary?.startOffset && lastSelection?.endOffset === primary?.endOffset) {
return;
}
lastSelection = primary;
if (primary && doc) {
const { startOffset, endOffset, collapsed } = primary;
const selectionEnd = collapsed ? startOffset : endOffset - 1;
const customRange = this._findActiveCommentDecoration(doc, unitId, startOffset, selectionEnd);
if (customRange) {
this._commandService.executeCommand(ShowCommentPanelOperation.id, {
activeComment: {
unitId,
subUnitId: DEFAULT_DOC_SUBUNIT_ID,
commentId: customRange.id,
},
});
return;
}
}
if (!this._threadCommentPanelService.activeCommentId) {
return;
}
const addingComment = this._docThreadCommentService.addingComment;
const activeComment = this._threadCommentPanelService.activeCommentId;
if (
addingComment &&
activeComment?.unitId === addingComment.unitId &&
activeComment?.subUnitId === DEFAULT_DOC_SUBUNIT_ID &&
activeComment?.commentId === addingComment.id
) {
return;
}
this._commandService.executeCommand(SetActiveCommentOperation.id);
}
})
);
}
private _findActiveCommentDecoration(
doc: DocumentDataModel,
unitId: string,
selectionStart: number,
selectionEnd: number
): ICustomDecoration | undefined {
return [...(doc.getBody()?.customDecorations ?? [])]
.reverse()
.filter((decoration) => decoration.type === CustomDecorationType.COMMENT
&& decoration.startIndex <= selectionStart
&& decoration.endIndex >= selectionEnd)
.sort((left, right) => (left.endIndex - left.startIndex) - (right.endIndex - right.startIndex))
.find((decoration) => {
const comment = this._threadCommentModel.getComment(unitId, DEFAULT_DOC_SUBUNIT_ID, decoration.id);
return comment && !comment.resolved;
});
}
private _initActiveCommandChange() {
this.disposeWithMe(this._threadCommentPanelService.activeCommentId$.subscribe((activeComment) => {
if (activeComment) {
const doc = this._univerInstanceService.getUnit<DocumentDataModel>(activeComment.unitId);
if (doc) {
const backScrollController = this._renderManagerService.getRenderUnitById(activeComment.unitId)?.with(DocBackScrollRenderController);
const customRange = doc.getBody()?.customDecorations?.find((range) => range.id === activeComment.commentId);
if (customRange && backScrollController) {
backScrollController.scrollToRange({
startOffset: customRange.startIndex,
endOffset: customRange.endIndex,
collapsed: false,
});
}
}
}
if (!activeComment || activeComment.commentId !== this._docThreadCommentService.addingComment?.id) {
this._docThreadCommentService.endAdd();
}
}));
}
}

View File

@@ -0,0 +1,297 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { CustomDecorationType } from '@univerjs/core';
import { DOC_INTERCEPTOR_POINT, RichTextEditingMutation } from '@univerjs/docs';
import { DEFAULT_DOC_SUBUNIT_ID } from '@univerjs/docs-thread-comment';
import { getDrawingShapeKeyByDrawingSearch } from '@univerjs/drawing';
import { Vector2 } from '@univerjs/engine-render';
import { serializeThreadCommentAnchor, ThreadCommentAnchorKind } from '@univerjs/thread-comment';
import { BehaviorSubject, Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { DocThreadCommentRenderController } from '../render.controller';
describe('DocThreadCommentRenderController', () => {
it('should intercept decoration view-model based on active comment and resolved status', () => {
let handler: any;
const docInterceptorService = {
intercept: vi.fn((point, config) => {
if (point === DOC_INTERCEPTOR_POINT.CUSTOM_DECORATION) {
handler = config.handler;
}
return { dispose: vi.fn() };
}),
};
const makeDirty = vi.fn();
const activeCommentId$ = new BehaviorSubject<any>(undefined);
const hoveredCommentId$ = new BehaviorSubject<any>(undefined);
const threadCommentPanelService = {
activeCommentId: { unitId: 'doc-1', subUnitId: DEFAULT_DOC_SUBUNIT_ID, commentId: 'c2' },
activeCommentId$,
hoveredCommentId: undefined as any,
hoveredCommentId$,
};
const univerInstanceService = {
getCurrentUnitOfType: vi.fn(() => ({ getUnitId: () => 'doc-1' })),
};
const commentUpdate$ = new Subject<any>();
const threadCommentModel = {
commentUpdate$,
getComment: vi.fn((_unitId: string, _subUnitId: string, id: string) => (id === 'c1' ? null : { id, ref: 'text', resolved: false })),
query: vi.fn(() => []),
addComment: vi.fn(),
syncThreadComments: vi.fn(),
};
let onCommandExecuted: any;
const commandService = {
onCommandExecuted: vi.fn((fn) => {
onCommandExecuted = fn;
return { dispose: vi.fn() };
}),
};
const unit = {
getUnitId: () => 'doc-1',
getBody: () => ({ customDecorations: [{ id: 'c1', type: CustomDecorationType.COMMENT }, { id: 'c2', type: CustomDecorationType.COMMENT }] }),
};
const context = {
unit,
unitId: 'doc-1',
mainComponent: { makeDirty },
scene: {
addObject: vi.fn(),
getObject: vi.fn(),
getObjectIncludeInGroup: vi.fn(),
},
engine: {
onTransformChange$: {
subscribeEvent: vi.fn(() => ({ dispose: vi.fn() })),
},
},
};
const drawingManagerService = {
add$: new Subject<never[]>(),
update$: new Subject<never[]>(),
remove$: new Subject<never[]>(),
};
const themeService = {
currentTheme$: new Subject<void>(),
getColorFromTheme: vi.fn((token: string) => token),
};
const controller = new DocThreadCommentRenderController(
context as any,
docInterceptorService as any,
threadCommentPanelService as any,
univerInstanceService as any,
threadCommentModel as any,
commandService as any,
drawingManagerService as any,
themeService as any
);
expect(makeDirty).not.toHaveBeenCalled();
expect(threadCommentModel.addComment).not.toHaveBeenCalled();
const next = (v: any) => v;
const outActive = handler(
{ id: 'c2' },
{
unitId: 'doc-1',
index: 3,
customDecorations: [{ id: 'c2', startIndex: 0, endIndex: 5 }],
},
next
);
expect(outActive.active).toBe(true);
expect(outActive.show).toBe(true);
const outResolved = handler(
{ id: 'c1' },
{
unitId: 'doc-1',
index: 3,
customDecorations: [{ id: 'c2', startIndex: 0, endIndex: 5 }],
},
next
);
expect(outResolved.show).toBe(false);
threadCommentPanelService.hoveredCommentId = {
unitId: 'doc-1',
subUnitId: DEFAULT_DOC_SUBUNIT_ID,
commentId: 'c3',
};
const outOverlapping = handler(
{ id: 'c2' },
{
unitId: 'doc-1',
index: 3,
customDecorations: [
{ id: 'c2', startIndex: 0, endIndex: 5 },
{ id: 'c3', startIndex: 2, endIndex: 4 },
],
},
next
);
expect(outOverlapping.active).toBe(true);
const outHovered = handler(
{ id: 'c3' },
{
unitId: 'doc-1',
index: 3,
customDecorations: [
{ id: 'c2', startIndex: 0, endIndex: 5 },
{ id: 'c3', startIndex: 2, endIndex: 4 },
],
},
next
);
expect(outHovered.active).toBe(true);
threadCommentPanelService.hoveredCommentId = {
unitId: 'other-doc',
subUnitId: DEFAULT_DOC_SUBUNIT_ID,
commentId: 'c3',
};
const outActiveWithForeignHover = handler({ id: 'c2' }, { unitId: 'doc-1' }, next);
expect(outActiveWithForeignHover.active).toBe(true);
hoveredCommentId$.next(threadCommentPanelService.hoveredCommentId);
expect(makeDirty).toHaveBeenCalledWith(true);
// resolved branch triggers rerender
threadCommentModel.getComment.mockImplementation((_unitId: string, _subUnitId: string, id: string) => {
if (id === 'c1') return null;
return { id, ref: 'text', resolved: id === 'c2' };
});
const outResolvedComment = handler(
{ id: 'c2' },
{
unitId: 'doc-1',
index: 3,
customDecorations: [{ id: 'c2', startIndex: 0, endIndex: 5 }],
},
next
);
expect(outResolvedComment.show).toBe(false);
// rerender on active comment changes
activeCommentId$.next({ unitId: 'doc-1', subUnitId: DEFAULT_DOC_SUBUNIT_ID, commentId: 'c1' });
expect(makeDirty).toHaveBeenCalledWith(true);
// rerender on resolve updates
commentUpdate$.next({ type: 'resolve', unitId: 'doc-1' });
expect(makeDirty).toHaveBeenCalledWith(true);
// trigger sync on rich text mutations (add new decoration)
// NOTE: `_initSyncComments` compares sorted thread ids. We emulate that the doc body returns a stable
// list that changes when mutation happens.
unit.getBody = () => ({
customDecorations: [
{ id: 'c1', type: CustomDecorationType.COMMENT },
{ id: 'c2', type: CustomDecorationType.COMMENT },
{ id: 'c3', type: CustomDecorationType.COMMENT },
],
});
onCommandExecuted({ id: RichTextEditingMutation.id, params: { unitId: 'doc-1' } });
expect(threadCommentModel.addComment).not.toHaveBeenCalled();
expect(threadCommentModel.syncThreadComments).toHaveBeenCalledWith('doc-1', DEFAULT_DOC_SUBUNIT_ID, ['c3']);
controller.dispose();
});
it('keeps drawing comment underlines attached to the rendered object', () => {
const ref = serializeThreadCommentAnchor({
kind: ThreadCommentAnchorKind.DOC_DRAWING,
pageId: 'doc-1',
elementId: 'shape-1',
});
const drawingKey = getDrawingShapeKeyByDrawingSearch({
unitId: 'doc-1',
subUnitId: 'doc-1',
drawingId: 'shape-1',
});
let bounds = { left: 20, top: 40, width: 80, height: 60 };
let roots = ['first-comment', 'newest-comment'];
const scene = {
addObject: vi.fn(),
getObjectIncludeInGroup: vi.fn((key: string) => key === drawingKey ? { getRealBound: () => bounds } : null),
getObject: vi.fn(),
};
const context = {
unitId: 'doc-1',
unit: { getUnitId: () => 'doc-1', getBody: () => ({ customDecorations: [] }) },
mainComponent: { makeDirty: vi.fn() },
scene,
engine: { onTransformChange$: { subscribeEvent: vi.fn(() => ({ dispose: vi.fn() })) } },
};
const activeCommentId$ = new BehaviorSubject({
unitId: 'doc-1',
subUnitId: 'doc-1',
commentId: 'first-comment',
});
const hoveredCommentId$ = new BehaviorSubject<undefined>(undefined);
const panelService = {
activeCommentId: activeCommentId$.value,
hoveredCommentId: hoveredCommentId$.value,
activeCommentId$,
hoveredCommentId$,
};
const commentUpdate$ = new Subject<{ type: string; unitId: string }>();
const commentModel = {
commentUpdate$,
query: vi.fn(() => roots.map((id) => ({ root: { id, ref }, subUnitId: 'doc-1' }))),
getComment: vi.fn((_unitId: string, _subUnitId: string, id: string) => ({ id, ref, resolved: false })),
syncThreadComments: vi.fn(),
};
const drawingManager = { add$: new Subject(), update$: new Subject(), remove$: new Subject() };
const controller = new DocThreadCommentRenderController(
context as never,
{ intercept: vi.fn(() => ({ dispose: vi.fn() })) } as never,
panelService as never,
{ getCurrentUnitOfType: vi.fn(() => context.unit) } as never,
commentModel as never,
{ executeCommand: vi.fn(() => Promise.resolve(true)), onCommandExecuted: vi.fn(() => ({ dispose: vi.fn() })) } as never,
drawingManager as never,
{ currentTheme$: new Subject(), getColorFromTheme: vi.fn((token: string) => token) } as never
);
const overlay = scene.addObject.mock.calls[0][0];
expect(overlay.isHit(new Vector2(60, 102))).toBe(true);
expect(overlay.hitCommentId).toBe('newest-comment');
roots = ['first-comment'];
commentUpdate$.next({ type: 'delete', unitId: 'doc-1' });
expect(overlay.isHit(new Vector2(60, 102))).toBe(true);
expect(overlay.hitCommentId).toBe('first-comment');
bounds = { left: 120, top: 140, width: 100, height: 70 };
drawingManager.update$.next([{ unitId: 'doc-1' }]);
expect(overlay.isHit(new Vector2(60, 102))).toBe(false);
expect(overlay.isHit(new Vector2(170, 212))).toBe(true);
roots = [];
commentUpdate$.next({ type: 'resolve', unitId: 'doc-1' });
expect(overlay.isHit(new Vector2(170, 212))).toBe(false);
controller.dispose();
});
});

View File

@@ -0,0 +1,267 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, EventState } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { IMouseEvent, IPointerEvent, IRenderContext, IRenderModule } from '@univerjs/engine-render';
import type { IThreadCommentCanvasOutline, IThreadCommentCanvasUnderline } from '@univerjs/thread-comment-ui';
import {
CustomDecorationType,
Disposable,
ICommandService,
Inject,
IUniverInstanceService,
ThemeService,
toDisposable,
UniverInstanceType,
} from '@univerjs/core';
import { DOC_INTERCEPTOR_POINT, DocInterceptorService, RichTextEditingMutation } from '@univerjs/docs';
import { DEFAULT_DOC_SUBUNIT_ID } from '@univerjs/docs-thread-comment';
import { getDrawingShapeKeyByDrawingSearch, IDrawingManagerService } from '@univerjs/drawing';
import { deserializeThreadCommentAnchor, ThreadCommentAnchorKind, ThreadCommentModel } from '@univerjs/thread-comment';
import { ThreadCommentCanvasOverlay, ThreadCommentPanelService } from '@univerjs/thread-comment-ui';
import { pairwise, skip, startWith } from 'rxjs';
import { ShowCommentPanelOperation } from '../../commands/operations/show-comment-panel.operation';
const DOC_COMMENT_DRAWING_OVERLAY_KEY = 'doc-thread-comment-drawing-overlay';
const DOC_COMMENT_DRAWING_OVERLAY_LAYER_INDEX = 10_100;
export class DocThreadCommentRenderController extends Disposable implements IRenderModule {
private readonly _drawingOverlay: ThreadCommentCanvasOverlay;
private readonly _drawingCommentSubUnits = new Map<string, string>();
constructor(
private readonly _context: IRenderContext<DocumentDataModel>,
@Inject(DocInterceptorService) private readonly _docInterceptorService: DocInterceptorService,
@Inject(ThreadCommentPanelService) private readonly _threadCommentPanelService: ThreadCommentPanelService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@Inject(ThreadCommentModel) private readonly _threadCommentModel: ThreadCommentModel,
@ICommandService private readonly _commandService: ICommandService,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@Inject(ThemeService) private readonly _themeService: ThemeService
) {
super();
this._drawingOverlay = new ThreadCommentCanvasOverlay(DOC_COMMENT_DRAWING_OVERLAY_KEY, {
...this._getDrawingOverlayColors(),
zoomRatio: 1,
markers: [],
underlines: [],
});
this._context.scene.addObject(this._drawingOverlay, DOC_COMMENT_DRAWING_OVERLAY_LAYER_INDEX);
this._interceptorViewModel();
this._initReRender();
this._initSyncComments();
this._initDrawingOverlay();
}
private _initReRender() {
[
this._threadCommentPanelService.activeCommentId$,
this._threadCommentPanelService.hoveredCommentId$,
].forEach((observable) => this.disposeWithMe(observable.pipe(
skip(1),
startWith(undefined),
pairwise()
).subscribe(([previous, current]) => {
const currentUnitId = this._univerInstanceService
.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC)
?.getUnitId();
if (new Set([previous?.unitId, current?.unitId, currentUnitId]).has(this._context.unitId)) {
this._context.mainComponent?.makeDirty(true);
}
})));
this.disposeWithMe(this._threadCommentModel.commentUpdate$.subscribe((update) => {
if (update.type === 'resolve' && update.unitId === this._context.unitId) {
this._context.mainComponent?.makeDirty(true);
}
if (update.unitId === this._context.unitId) {
this._syncDrawingOverlay();
}
}));
}
private _initDrawingOverlay(): void {
this.disposeWithMe(toDisposable(this._drawingOverlay.onPointerDown$.subscribeEvent(
(_event: IPointerEvent | IMouseEvent, state: EventState) => {
const commentId = this._drawingOverlay.hitCommentId;
const subUnitId = commentId && this._drawingCommentSubUnits.get(commentId);
if (!commentId || !subUnitId) {
return;
}
state.stopPropagation();
this._commandService.executeCommand(ShowCommentPanelOperation.id, {
activeComment: { unitId: this._context.unitId, subUnitId, commentId, trigger: 'doc-canvas' },
});
}
)));
this.disposeWithMe(toDisposable(
this._context.engine.onTransformChange$.subscribeEvent(() => this._syncDrawingOverlay())
));
[this._threadCommentPanelService.activeCommentId$, this._threadCommentPanelService.hoveredCommentId$]
.forEach((observable) => this.disposeWithMe(observable.subscribe(() => this._syncDrawingOverlay())));
[this._drawingManagerService.add$, this._drawingManagerService.update$, this._drawingManagerService.remove$]
.forEach((observable) => this.disposeWithMe(observable.subscribe((drawings) => {
if (drawings.some((drawing) => drawing.unitId === this._context.unitId)) {
this._syncDrawingOverlay();
}
})));
this.disposeWithMe(this._themeService.currentTheme$.subscribe(() => this._syncDrawingOverlay()));
this._syncDrawingOverlay();
}
private _syncDrawingOverlay(): void {
const underlines = new Map<string, IThreadCommentCanvasUnderline>();
this._drawingCommentSubUnits.clear();
this._threadCommentModel.query({
unitIds: [this._context.unitId],
anchorKinds: [ThreadCommentAnchorKind.DOC_DRAWING],
resolved: false,
}).forEach(({ root, subUnitId }) => {
const anchor = deserializeThreadCommentAnchor(root.ref);
if (anchor?.kind !== ThreadCommentAnchorKind.DOC_DRAWING) {
return;
}
const outline = this._getDrawingOutline(anchor.pageId ?? subUnitId, anchor.elementId);
if (!outline) {
return;
}
this._drawingCommentSubUnits.set(root.id, subUnitId);
underlines.set(`${anchor.pageId ?? subUnitId}\0${anchor.elementId}`, {
commentId: root.id,
left: outline.left,
top: outline.top + outline.height + 2,
width: outline.width,
});
});
const focusedCommentIds: string[] = [];
const focusOutlines = new Map<string, IThreadCommentCanvasOutline>();
[this._threadCommentPanelService.activeCommentId, this._threadCommentPanelService.hoveredCommentId]
.forEach((target) => {
if (!target || target.unitId !== this._context.unitId) {
return;
}
const comment = this._threadCommentModel.getComment(target.unitId, target.subUnitId, target.commentId);
const anchor = comment && deserializeThreadCommentAnchor(comment.ref);
if (anchor?.kind !== ThreadCommentAnchorKind.DOC_DRAWING) {
return;
}
focusedCommentIds.push(target.commentId);
const outline = this._getDrawingOutline(anchor.pageId ?? target.subUnitId, anchor.elementId);
if (outline) {
focusOutlines.set(`${anchor.pageId ?? target.subUnitId}\0${anchor.elementId}`, outline);
}
});
this._drawingOverlay.updateState({
...this._getDrawingOverlayColors(),
markers: [],
underlines: Array.from(underlines.values()),
focusedCommentIds,
focusOutlines: Array.from(focusOutlines.values()),
});
}
private _getDrawingOutline(subUnitId: string, drawingId: string): IThreadCommentCanvasOutline | null {
const objectKey = getDrawingShapeKeyByDrawingSearch({
unitId: this._context.unitId,
subUnitId,
drawingId,
});
const object = this._context.scene.getObjectIncludeInGroup?.(objectKey)
?? this._context.scene.getObject(objectKey);
if (!object) {
return null;
}
const bounds = object.getRealBound();
return { left: bounds.left, top: bounds.top, width: bounds.width, height: bounds.height };
}
private _getDrawingOverlayColors(): { accentColor: string; foregroundColor: string; outlineColor: string } {
return {
accentColor: this._themeService.getColorFromTheme('yellow.400'),
foregroundColor: this._themeService.getColorFromTheme('gray.900'),
outlineColor: this._themeService.getColorFromTheme('white'),
};
}
private _interceptorViewModel() {
this._docInterceptorService.intercept(DOC_INTERCEPTOR_POINT.CUSTOM_DECORATION, {
handler: (data, pos, next) => {
if (!data) {
return next(data);
}
const { unitId } = pos;
const focusedComments = [
this._threadCommentPanelService.activeCommentId,
this._threadCommentPanelService.hoveredCommentId,
];
const comment = this._threadCommentModel.getComment(unitId, DEFAULT_DOC_SUBUNIT_ID, data.id);
if (!comment) {
return next({
...data,
show: false,
});
}
const isActive = focusedComments.some((focusedComment) => (
focusedComment?.unitId === unitId
&& focusedComment.subUnitId === DEFAULT_DOC_SUBUNIT_ID
&& focusedComment.commentId === data.id
));
return next({
...data,
active: isActive,
show: !comment.resolved,
});
},
});
}
private _initSyncComments() {
const unitId = this._context.unit.getUnitId();
const subUnitId = DEFAULT_DOC_SUBUNIT_ID;
const threadIds = this._context.unit.getBody()?.customDecorations?.filter((i) => i.type === CustomDecorationType.COMMENT).map((i) => i.id) ?? [];
threadIds.length && this._threadCommentModel.syncThreadComments(this._context.unit.getUnitId(), DEFAULT_DOC_SUBUNIT_ID, threadIds);
let prevThreadIds: string[] = threadIds.sort();
this.disposeWithMe(this._commandService.onCommandExecuted((commandInfo) => {
if (commandInfo.id === RichTextEditingMutation.id) {
const params = commandInfo.params as IRichTextEditingMutationParams;
if (params.unitId !== this._context.unit.getUnitId()) {
return;
}
const currentThreadIds = this._context.unit.getBody()?.customDecorations?.filter((i) => i.type === CustomDecorationType.COMMENT).map((i) => i.id) ?? [];
const currentThreadIdsSorted = currentThreadIds.sort();
if (JSON.stringify(prevThreadIds) !== JSON.stringify(currentThreadIdsSorted)) {
const preIds = new Set(prevThreadIds);
const addIds = new Set<string>();
currentThreadIds.forEach((id) => {
if (!preIds.has(id)) {
addIds.add(id);
}
});
prevThreadIds = currentThreadIdsSorted;
this._threadCommentModel.syncThreadComments(unitId, subUnitId, [...addIds]);
}
}
}));
}
}

View File

@@ -0,0 +1,57 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Disposable, ICommandService } from '@univerjs/core';
import { FLOAT_TOOLBAR_MENU_POSITION } from '@univerjs/docs-ui';
import { IMenuManagerService } from '@univerjs/ui';
import { AddDocCommentComment } from '../commands/commands/add-doc-comment.command';
import { DeleteDocCommentComment } from '../commands/commands/delete-doc-comment.command';
import {
AddDocDrawingCommentOperation,
ShowCommentPanelOperation,
StartAddCommentOperation,
ToggleCommentPanelOperation,
} from '../commands/operations/show-comment-panel.operation';
import { menuSchema } from '../menu/schema';
export class DocThreadCommentUIController extends Disposable {
constructor(
@ICommandService private readonly _commandService: ICommandService,
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService
) {
super();
this._initCommands();
this._initMenus();
}
private _initCommands() {
[
AddDocDrawingCommentOperation,
AddDocCommentComment,
DeleteDocCommentComment,
ShowCommentPanelOperation,
StartAddCommentOperation,
ToggleCommentPanelOperation,
].forEach((command) => {
this.disposeWithMe(this._commandService.registerCommand(command));
});
}
private _initMenus() {
this._menuManagerService.appendRootMenu({ [FLOAT_TOOLBAR_MENU_POSITION]: {} });
this._menuManagerService.mergeMenu(menuSchema);
}
}

View File

@@ -0,0 +1,2 @@
@tailwind components;
@tailwind utilities;

View File

@@ -0,0 +1,28 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import './global.css';
export { AddDocCommentComment } from './commands/commands/add-doc-comment.command';
export type { IAddDocCommentComment } from './commands/commands/add-doc-comment.command';
export { DeleteDocCommentComment } from './commands/commands/delete-doc-comment.command';
export type { IDeleteDocCommentComment } from './commands/commands/delete-doc-comment.command';
export { ShowCommentPanelOperation, StartAddCommentOperation } from './commands/operations/show-comment-panel.operation';
export type { IShowCommentPanelOperationParams } from './commands/operations/show-comment-panel.operation';
export type { IUniverDocsThreadCommentUIConfig } from './config/config';
export { menuSchema as DocsThreadCommentUIMenuSchema } from './menu/schema';
export { UniverDocsThreadCommentMobileUIPlugin } from './mobile-plugin';
export { UniverDocsThreadCommentUIPlugin } from './plugin';

View File

@@ -0,0 +1,29 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-thread-comment-ui': {
panel: {
title: 'إدارة التعليقات',
addComment: 'إضافة تعليق',
openComments: 'فتح التعليقات',
},
},
};
export default locale;

View File

@@ -0,0 +1,29 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-thread-comment-ui': {
panel: {
title: 'Gestió de comentaris',
addComment: 'Afegeix un comentari',
openComments: 'Obre els comentaris',
},
},
};
export default locale;

View File

@@ -0,0 +1,29 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-thread-comment-ui': {
panel: {
title: 'Kommentarverwaltung',
addComment: 'Kommentar hinzufügen',
openComments: 'Kommentare öffnen',
},
},
};
export default locale;

View File

@@ -0,0 +1,27 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
const locale = {
'docs-thread-comment-ui': {
panel: {
title: 'Comment Management',
addComment: 'Add Comment',
openComments: 'Open Comments',
},
},
};
export default locale;

View File

@@ -0,0 +1,29 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-thread-comment-ui': {
panel: {
title: 'Gestión de comentarios',
addComment: 'Añadir comentario',
openComments: 'Abrir comentarios',
},
},
};
export default locale;

View File

@@ -0,0 +1,29 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-thread-comment-ui': {
panel: {
title: 'مدیریت نظرات',
addComment: 'افزودن نظر',
openComments: 'باز کردن نظرات',
},
},
};
export default locale;

View File

@@ -0,0 +1,29 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-thread-comment-ui': {
panel: {
title: 'Gestion des commentaires',
addComment: 'Ajouter un commentaire',
openComments: 'Ouvrir les commentaires',
},
},
};
export default locale;

View File

@@ -0,0 +1,29 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-thread-comment-ui': {
panel: {
title: 'Manajemen Komentar',
addComment: 'Tambah Komentar',
openComments: 'Buka Komentar',
},
},
};
export default locale;

View File

@@ -0,0 +1,29 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-thread-comment-ui': {
panel: {
title: 'Gestione Commenti',
addComment: 'Aggiungi Commento',
openComments: 'Apri commenti',
},
},
};
export default locale;

View File

@@ -0,0 +1,29 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-thread-comment-ui': {
panel: {
title: 'コメント管理',
addComment: 'コメントを追加',
openComments: 'コメントを開く',
},
},
};
export default locale;

View File

@@ -0,0 +1,29 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-thread-comment-ui': {
panel: {
title: '댓글 관리',
addComment: '댓글 추가',
openComments: '댓글 열기',
},
},
};
export default locale;

View File

@@ -0,0 +1,29 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-thread-comment-ui': {
panel: {
title: 'Zarządzanie komentarzami',
addComment: 'Dodaj komentarz',
openComments: 'Otwórz komentarze',
},
},
};
export default locale;

View File

@@ -0,0 +1,29 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-thread-comment-ui': {
panel: {
title: 'Gerenciamento de Comentários',
addComment: 'Adicionar Comentário',
openComments: 'Abrir comentários',
},
},
};
export default locale;

View File

@@ -0,0 +1,29 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-thread-comment-ui': {
panel: {
title: 'Управление комментариями',
addComment: 'Добавить комментарий',
openComments: 'Открыть комментарии',
},
},
};
export default locale;

View File

@@ -0,0 +1,29 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-thread-comment-ui': {
panel: {
title: 'Správa komentárov',
addComment: 'Pridať komentár',
openComments: 'Otvoriť komentáre',
},
},
};
export default locale;

View File

@@ -0,0 +1,20 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { LocaleLeafKeys } from '@univerjs/core';
import type enUS from './en-US';
export type LocaleKey = LocaleLeafKeys<typeof enUS>;

View File

@@ -0,0 +1,29 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-thread-comment-ui': {
panel: {
title: 'Quản lý Bình luận',
addComment: 'Thêm bình luận',
openComments: 'Mở bình luận',
},
},
};
export default locale;

View File

@@ -0,0 +1,29 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-thread-comment-ui': {
panel: {
title: '评论管理',
addComment: '添加评论',
openComments: '打开评论',
},
},
};
export default locale;

View File

@@ -0,0 +1,29 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-thread-comment-ui': {
panel: {
title: '評論管理',
addComment: '新增評論',
openComments: '開啟評論',
},
},
};
export default locale;

View File

@@ -0,0 +1,29 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type enUS from './en-US';
const locale: typeof enUS = {
'docs-thread-comment-ui': {
panel: {
title: '評論管理',
addComment: '新增評論',
openComments: '開啟評論',
},
},
};
export default locale;

View File

@@ -0,0 +1,121 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { IPermissionService, IUniverInstanceService, SHEET_EDITOR_UNITS, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService, DocSkeletonManagerService, getDocumentPermissionValue } from '@univerjs/docs';
import { DocumentEditArea, IRenderManagerService, withCurrentTypeOfRenderer } from '@univerjs/engine-render';
import { UnitAction } from '@univerjs/protocol';
import { getMenuHiddenObservable, MenuItemType } from '@univerjs/ui';
import { combineLatest, debounceTime, map, startWith } from 'rxjs';
import {
AddDocDrawingCommentOperation,
StartAddCommentOperation,
ToggleCommentPanelOperation,
} from '../commands/operations/show-comment-panel.operation';
type MenuAccessor = Parameters<typeof getMenuHiddenObservable>[0];
function getCommentPermissionDisabled$(accessor: MenuAccessor) {
const instanceService = accessor.get(IUniverInstanceService);
const permissionService = accessor.get(IPermissionService);
return combineLatest([
instanceService.getCurrentTypeOfUnit$(UniverInstanceType.UNIVER_DOC),
permissionService.permissionPointUpdate$.pipe(startWith(undefined)),
]).pipe(map(([document]) => !document || !getDocumentPermissionValue(
permissionService,
document.getUnitId(),
document.getUnitId(),
UnitAction.Comment
)));
}
export function AddDocDrawingCommentMenuItemFactory(accessor: MenuAccessor) {
return {
id: AddDocDrawingCommentOperation.id,
type: MenuItemType.BUTTON,
icon: 'InsertCommentDoubleIcon',
title: 'docs-thread-comment-ui.panel.addComment',
tooltip: 'docs-thread-comment-ui.panel.addComment',
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
disabled$: getCommentPermissionDisabled$(accessor),
};
}
export const shouldDisableAddComment = (accessor: MenuAccessor) => {
const renderManagerService = accessor.get(IRenderManagerService);
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
const instanceService = accessor.get(IUniverInstanceService);
const permissionService = accessor.get(IPermissionService);
const document = instanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC);
if (!document || !getDocumentPermissionValue(
permissionService,
document.getUnitId(),
document.getUnitId(),
UnitAction.Comment
)) {
return true;
}
const skeleton = withCurrentTypeOfRenderer(
UniverInstanceType.UNIVER_DOC,
DocSkeletonManagerService,
instanceService,
renderManagerService
)?.getSkeleton();
const editArea = skeleton?.getViewModel().getEditArea();
if (editArea === DocumentEditArea.FOOTER || editArea === DocumentEditArea.HEADER) {
return true;
}
const range = docSelectionManagerService.getActiveTextRange();
if (range == null || range.collapsed) {
return true;
}
return false;
};
export function AddDocCommentMenuItemFactory(accessor: MenuAccessor) {
return {
id: StartAddCommentOperation.id,
type: MenuItemType.BUTTON,
icon: 'InsertCommentDoubleIcon',
title: 'docs-thread-comment-ui.panel.addComment',
tooltip: 'docs-thread-comment-ui.panel.addComment',
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC, undefined, SHEET_EDITOR_UNITS),
disabled$: combineLatest([
accessor.get(DocSelectionManagerService).textSelection$.pipe(
debounceTime(16),
map(() => shouldDisableAddComment(accessor)),
startWith(shouldDisableAddComment(accessor))
),
getCommentPermissionDisabled$(accessor),
]).pipe(map(([selectionDisabled, permissionDisabled]) =>
selectionDisabled || permissionDisabled)),
};
}
export function ToolbarDocCommentMenuItemFactory(accessor: MenuAccessor) {
return {
id: ToggleCommentPanelOperation.id,
type: MenuItemType.BUTTON,
icon: 'CommentIcon',
title: 'docs-thread-comment-ui.panel.openComments',
tooltip: 'docs-thread-comment-ui.panel.openComments',
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
};
}

View File

@@ -0,0 +1,68 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { MenuSchemaType } from '@univerjs/ui';
import { FLOAT_TOOLBAR_MENU_POSITION } from '@univerjs/docs-ui';
import { ContextMenuGroup, ContextMenuPosition, FloatingObjectToolbarPosition, RibbonInsertGroup } from '@univerjs/ui';
import {
AddDocDrawingCommentOperation,
StartAddCommentOperation,
ToggleCommentPanelOperation,
} from '../commands/operations/show-comment-panel.operation';
import { AddDocCommentMenuItemFactory, AddDocDrawingCommentMenuItemFactory, ToolbarDocCommentMenuItemFactory } from './menu';
export const menuSchema: MenuSchemaType = {
[RibbonInsertGroup.MEDIA]: {
[ToggleCommentPanelOperation.id]: {
order: 3,
gridLayout: { row: 1, column: 4, showLabel: true },
menuItemFactory: ToolbarDocCommentMenuItemFactory,
},
[StartAddCommentOperation.id]: {
order: 3.1,
gridLayout: { row: 2, column: 4, showLabel: true },
menuItemFactory: AddDocCommentMenuItemFactory,
},
},
[FLOAT_TOOLBAR_MENU_POSITION]: {
[StartAddCommentOperation.id]: {
order: 21,
menuItemFactory: AddDocCommentMenuItemFactory,
},
},
[FloatingObjectToolbarPosition.DOC]: {
[AddDocDrawingCommentOperation.id]: {
order: 10,
menuItemFactory: AddDocDrawingCommentMenuItemFactory,
},
},
[ContextMenuPosition.MAIN_AREA]: {
[ContextMenuGroup.DATA]: {
[StartAddCommentOperation.id]: {
order: 1,
menuItemFactory: AddDocCommentMenuItemFactory,
},
},
},
[ContextMenuPosition.DRAWING]: {
[ContextMenuGroup.DATA]: {
[AddDocDrawingCommentOperation.id]: {
order: 1,
menuItemFactory: AddDocDrawingCommentMenuItemFactory,
},
},
},
};

View File

@@ -0,0 +1,93 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Dependency } from '@univerjs/core';
import type { IUniverDocsThreadCommentUIConfig } from './config/config';
import { DependentOn, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core';
import { UniverDocsPlugin } from '@univerjs/docs';
import { UniverDocsThreadCommentPlugin } from '@univerjs/docs-thread-comment';
import { UniverDocsMobileUIPlugin } from '@univerjs/docs-ui';
import { UniverDrawingPlugin } from '@univerjs/drawing';
import { IRenderManagerService, UniverRenderEnginePlugin } from '@univerjs/engine-render';
import { UniverThreadCommentPlugin } from '@univerjs/thread-comment';
import { UniverThreadCommentMobileUIPlugin } from '@univerjs/thread-comment-ui';
import pkg from '../package.json';
import { PLUGIN_NAME } from './common/const';
import { defaultPluginConfig, DOCS_THREAD_COMMENT_UI_PLUGIN_CONFIG_KEY } from './config/config';
import { ComponentsController } from './controllers/components.controller';
import { DocThreadCommentSelectionController } from './controllers/doc-thread-comment-selection.controller';
import { DocThreadCommentRenderController } from './controllers/render-controllers/render.controller';
import { DocThreadCommentUIController } from './controllers/ui.controller';
import { DocThreadCommentService } from './services/doc-thread-comment.service';
@DependentOn(
UniverDocsPlugin,
UniverDocsThreadCommentPlugin,
UniverThreadCommentPlugin,
UniverDrawingPlugin,
UniverRenderEnginePlugin,
UniverDocsMobileUIPlugin,
UniverThreadCommentMobileUIPlugin
)
export class UniverDocsThreadCommentMobileUIPlugin extends Plugin {
static override pluginName = PLUGIN_NAME;
static override packageName = pkg.name;
static override version = pkg.version;
static override type = UniverInstanceType.UNIVER_DOC;
constructor(
private readonly _config: Partial<IUniverDocsThreadCommentUIConfig> = defaultPluginConfig,
@Inject(Injector) protected override _injector: Injector,
@IRenderManagerService private readonly _renderManagerSrv: IRenderManagerService,
@IConfigService private readonly _configService: IConfigService
) {
super();
const { menu, ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
if (menu) {
this._configService.setConfig('menu', menu, { merge: true });
}
this._configService.setConfig(DOCS_THREAD_COMMENT_UI_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
([
[ComponentsController],
[DocThreadCommentUIController],
[DocThreadCommentSelectionController],
[DocThreadCommentService],
] as Dependency[]).forEach((dep) => {
this._injector.add(dep);
});
this._injector.get(ComponentsController);
}
override onRendered(): void {
this._initRenderModule();
this._injector.get(DocThreadCommentSelectionController);
this._injector.get(DocThreadCommentUIController);
}
private _initRenderModule() {
this._renderManagerSrv.registerRenderModule(UniverInstanceType.UNIVER_DOC, [DocThreadCommentRenderController]);
}
}

View File

@@ -0,0 +1,94 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IUniverDocsThreadCommentUIConfig } from './config/config';
import { DependentOn, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core';
import { UniverDocsPlugin } from '@univerjs/docs';
import { UniverDocsThreadCommentPlugin } from '@univerjs/docs-thread-comment';
import { UniverDocsUIPlugin } from '@univerjs/docs-ui';
import { UniverDrawingPlugin } from '@univerjs/drawing';
import { IRenderManagerService, UniverRenderEnginePlugin } from '@univerjs/engine-render';
import { UniverThreadCommentPlugin } from '@univerjs/thread-comment';
import { UniverThreadCommentUIPlugin } from '@univerjs/thread-comment-ui';
import pkg from '../package.json';
import { PLUGIN_NAME } from './common/const';
import { defaultPluginConfig, DOCS_THREAD_COMMENT_UI_PLUGIN_CONFIG_KEY } from './config/config';
import { ComponentsController } from './controllers/components.controller';
import { DocThreadCommentSelectionController } from './controllers/doc-thread-comment-selection.controller';
import { DocThreadCommentRenderController } from './controllers/render-controllers/render.controller';
import { DocThreadCommentUIController } from './controllers/ui.controller';
import { DocThreadCommentService } from './services/doc-thread-comment.service';
const STARTING_DEPENDENCIES: Array<Parameters<Injector['add']>[0]> = [
[DocThreadCommentUIController],
[DocThreadCommentSelectionController],
[DocThreadCommentService],
];
@DependentOn(
UniverDocsPlugin,
UniverDocsThreadCommentPlugin,
UniverThreadCommentPlugin,
UniverDrawingPlugin,
UniverRenderEnginePlugin,
UniverDocsUIPlugin,
UniverThreadCommentUIPlugin
)
export class UniverDocsThreadCommentUIPlugin extends Plugin {
static override pluginName = PLUGIN_NAME;
static override packageName = pkg.name;
static override version = pkg.version;
static override type = UniverInstanceType.UNIVER_DOC;
constructor(
private readonly _config: Partial<IUniverDocsThreadCommentUIConfig> = defaultPluginConfig,
@Inject(Injector) protected _injector: Injector,
@IRenderManagerService private readonly _renderManagerSrv: IRenderManagerService,
@IConfigService private readonly _configService: IConfigService
) {
super();
// Manage the plugin configuration.
const { menu, ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
if (menu) {
this._configService.setConfig('menu', menu, { merge: true });
}
this._configService.setConfig(DOCS_THREAD_COMMENT_UI_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
this._injector.add([ComponentsController]);
this._injector.get(ComponentsController);
STARTING_DEPENDENCIES.forEach((dep) => {
this._injector.add(dep);
});
}
override onRendered(): void {
this._initRenderModule();
this._injector.get(DocThreadCommentSelectionController);
this._injector.get(DocThreadCommentUIController);
}
private _initRenderModule() {
this._renderManagerSrv.registerRenderModule(UniverInstanceType.UNIVER_DOC, [DocThreadCommentRenderController]);
}
}

View File

@@ -0,0 +1,46 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Injector } from '@univerjs/core';
import { ThreadCommentPanelService } from '@univerjs/thread-comment-ui';
import { ISidebarService } from '@univerjs/ui';
import { describe, expect, it } from 'vitest';
import { DocThreadCommentService } from '../doc-thread-comment.service';
class TestSidebarService {}
class TestThreadCommentPanelService {}
describe('DocThreadCommentService', () => {
it('should track adding comment lifecycle', () => {
const injector = new Injector();
injector.add([ISidebarService, { useClass: TestSidebarService }]);
injector.add([ThreadCommentPanelService, { useClass: TestThreadCommentPanelService }]);
injector.add([DocThreadCommentService]);
const service = injector.get(DocThreadCommentService);
expect(service.addingComment).toBeUndefined();
const comment = { id: 'c1', startOffset: 1, endOffset: 2 };
service.startAdd(comment as any);
expect(service.addingComment).toEqual(comment);
service.endAdd();
expect(service.addingComment).toBeUndefined();
service.dispose();
});
});

View File

@@ -0,0 +1,50 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ITextRange, Nullable } from '@univerjs/core';
import type { IThreadComment } from '@univerjs/thread-comment';
import { Disposable, Inject } from '@univerjs/core';
import { ThreadCommentPanelService } from '@univerjs/thread-comment-ui';
import { ISidebarService } from '@univerjs/ui';
import { BehaviorSubject } from 'rxjs';
export class DocThreadCommentService extends Disposable {
private _addingComment$ = new BehaviorSubject<Nullable<IThreadComment & ITextRange>>(undefined);
readonly addingComment$ = this._addingComment$.asObservable();
get addingComment() {
return this._addingComment$.getValue();
}
constructor(
@ISidebarService private readonly _sidebarService: ISidebarService,
@Inject(ThreadCommentPanelService) private readonly _threadCommentPanelService: ThreadCommentPanelService
) {
super();
this.disposeWithMe(() => {
this._addingComment$.complete();
});
}
startAdd(comment: IThreadComment & ITextRange) {
this._addingComment$.next(comment);
}
endAdd() {
this._addingComment$.next(undefined);
}
}

View File

@@ -0,0 +1,183 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel } from '@univerjs/core';
import type { IAddDocCommentComment } from '../commands/commands/add-doc-comment.command';
import type { IDeleteDocCommentComment } from '../commands/commands/delete-doc-comment.command';
import { ICommandService, Injector, IPermissionService, isInternalEditorID, IUniverInstanceService, UniverInstanceType, UserManagerService } from '@univerjs/core';
import { DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { DEFAULT_DOC_SUBUNIT_ID } from '@univerjs/docs-thread-comment';
import { deserializeThreadCommentAnchor, serializeThreadCommentAnchor, ThreadCommentAnchorKind, ThreadCommentModel } from '@univerjs/thread-comment';
import { ThreadCommentDraftService, ThreadCommentPanel } from '@univerjs/thread-comment-ui';
import { useDependency, useObservable } from '@univerjs/ui';
import { useEffect, useMemo, useState } from 'react';
import { debounceTime, filter, map, merge, Observable } from 'rxjs';
import { AddDocCommentComment } from '../commands/commands/add-doc-comment.command';
import { DeleteDocCommentComment } from '../commands/commands/delete-doc-comment.command';
import { StartAddCommentOperation } from '../commands/operations/show-comment-panel.operation';
import { shouldDisableAddComment } from '../menu/menu';
import { DocThreadCommentService } from '../services/doc-thread-comment.service';
export function getDocCommentPanelSubUnitId(draft: { unitId: string; subUnitId: string } | null, unitId: string | undefined): string {
return draft && draft.unitId === unitId ? draft.subUnitId : DEFAULT_DOC_SUBUNIT_ID;
}
export const DocThreadCommentPanel = () => {
const univerInstanceService = useDependency(IUniverInstanceService);
const injector = useDependency(Injector);
const doc$ = useMemo(() => univerInstanceService.getCurrentTypeOfUnit$<DocumentDataModel>(UniverInstanceType.UNIVER_DOC).pipe(filter((doc) => !!doc && !isInternalEditorID(doc.getUnitId()))), [univerInstanceService]);
const doc = useObservable(doc$);
const draftService = useDependency(ThreadCommentDraftService);
const drawingDraft = useObservable(draftService.draft$, draftService.draft);
const subUnitId$ = useMemo(
() => new Observable<string>((sub) => sub.next(getDocCommentPanelSubUnitId(drawingDraft, doc?.getUnitId()))),
[doc, drawingDraft]
);
const docSelectionManagerService = useDependency(DocSelectionManagerService);
const selectionChange$ = useMemo(
() => docSelectionManagerService.textSelection$.pipe(debounceTime(16)),
[docSelectionManagerService.textSelection$]
);
const permissionService = useDependency(IPermissionService);
const disableAddChange$ = useMemo(
() => merge(selectionChange$, permissionService.permissionPointUpdate$),
[permissionService.permissionPointUpdate$, selectionChange$]
);
const disableAdd = useObservable(
() => disableAddChange$.pipe(map(() => shouldDisableAddComment(injector))),
shouldDisableAddComment(injector),
false,
[disableAddChange$, injector]
);
const commandService = useDependency(ICommandService);
const threadCommentModel = useDependency(ThreadCommentModel);
useObservable(threadCommentModel.commentUpdate$);
const docCommentService = useDependency(DocThreadCommentService);
const textTempComment = useObservable(docCommentService.addingComment$);
const userManagerService = useDependency(UserManagerService);
const drawingTempComment = drawingDraft?.anchor.kind === ThreadCommentAnchorKind.DOC_DRAWING && drawingDraft.unitId === doc?.getUnitId()
? {
id: '',
threadId: '',
unitId: drawingDraft.unitId,
subUnitId: drawingDraft.subUnitId,
ref: serializeThreadCommentAnchor(drawingDraft.anchor),
dT: '',
personId: userManagerService.getCurrentUser().userID,
text: { dataStream: '\r\n' },
}
: null;
const tempComment = drawingTempComment ?? textTempComment;
const drawingIds = new Set(Object.keys(doc?.getSnapshot().drawings ?? {}));
const isDrawingComment = (comment: { ref: string }) => {
const anchor = deserializeThreadCommentAnchor(comment.ref);
return anchor?.kind === ThreadCommentAnchorKind.DOC_DRAWING
|| (comment.ref.startsWith('#') && drawingIds.has(comment.ref.slice(1)));
};
const drawingCommentIds = doc
? threadCommentModel.getUnit(doc.getUnitId())
.filter((thread) => isDrawingComment(thread.root))
.map((thread) => thread.root.id)
: [];
const [commentIds, setCommentIds] = useState<string[]>([]);
useEffect(() => {
const set = new Set<string>();
const customRanges = doc?.getCustomDecorations();
setCommentIds(customRanges?.map((r) => r.id).filter((i) => {
const hasRepeat = set.has(i);
set.add(i);
return !hasRepeat;
}) ?? []);
const dispose = commandService.onCommandExecuted((command) => {
if (command.id === RichTextEditingMutation.id) {
const set = new Set<string>();
const customRanges = doc?.getCustomDecorations();
setCommentIds(customRanges?.map((r) => r.id).filter((i) => {
const hasRepeat = set.has(i);
set.add(i);
return !hasRepeat;
}) ?? []);
}
});
return () => {
dispose.dispose();
};
}, [commandService, doc]);
if (!doc) {
return null;
}
const unitId = doc.getUnitId();
return (
<ThreadCommentPanel
unitId={unitId}
subUnitId$={subUnitId$}
type={UniverInstanceType.UNIVER_DOC}
onAdd={() => {
commandService.executeCommand(StartAddCommentOperation.id);
}}
getSubUnitName={() => ''}
disableAdd={disableAdd}
tempComment={tempComment}
onAddComment={async (comment) => {
if (drawingTempComment && !comment.parentId) {
return true;
}
// attach an comment to an custom-range
if (!comment.parentId) {
const params: IAddDocCommentComment = {
unitId,
range: textTempComment!,
comment,
};
const success = await commandService.executeCommand(AddDocCommentComment.id, params);
if (!success) {
throw new Error('Failed to add document comment.');
}
docCommentService.endAdd();
return false;
}
return true;
}}
onAfterDeleteComment={async (comment) => {
if (!comment.parentId) {
if (isDrawingComment(comment)) {
return;
}
const params: IDeleteDocCommentComment = {
unitId,
commentId: comment.id,
};
await commandService.executeCommand(DeleteDocCommentComment.id, params);
}
}}
showComments={[
...commentIds,
...drawingCommentIds,
]}
onTempCommentClose={() => draftService.cancel()}
formatRef={(comment) => {
const anchor = deserializeThreadCommentAnchor(comment.ref);
return anchor?.kind === ThreadCommentAnchorKind.DOC_DRAWING ? `#${anchor.elementId}` : comment.ref;
}}
/>
);
};

View File

@@ -0,0 +1,267 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IDisposable, IDocumentBody, IDocumentData, Injector } from '@univerjs/core';
import type { IRender } from '@univerjs/engine-render';
import type { IThreadComment } from '@univerjs/thread-comment';
import type { ISidebarMethodOptions } from '@univerjs/ui';
import type { Root } from 'react-dom/client';
import {
CustomDecorationType,
Disposable,
ICommandService,
IUniverInstanceService,
LocaleType,
toDisposable,
Univer,
UniverInstanceType,
UserManagerService,
} from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { DEFAULT_DOC_SUBUNIT_ID } from '@univerjs/docs-thread-comment';
import { IRenderManagerService } from '@univerjs/engine-render';
import { IThreadCommentDataSourceService, ThreadCommentDataSourceService, ThreadCommentModel } from '@univerjs/thread-comment';
import { SetActiveCommentOperation, ThreadCommentDraftService, ThreadCommentPanelService } from '@univerjs/thread-comment-ui';
import threadCommentEnUS from '@univerjs/thread-comment-ui/locale/en-US';
import { ISidebarService, RediContext } from '@univerjs/ui';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { Subject } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DocThreadCommentService } from '../../services/doc-thread-comment.service';
import { DocThreadCommentPanel, getDocCommentPanelSubUnitId } from '../DocThreadCommentPanel';
const DOC_ID = 'doc-thread-comment-panel-test';
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
class TestSidebarService extends Disposable implements ISidebarService {
readonly sidebarOptions$ = new Subject<ISidebarMethodOptions>();
readonly scrollEvent$ = new Subject<Event>();
private _options: ISidebarMethodOptions = {};
private _container?: HTMLElement;
open(params: ISidebarMethodOptions): IDisposable {
this._options = { ...params, visible: true };
this.sidebarOptions$.next(this._options);
return toDisposable(() => this.close());
}
close(): void {
this._options = { ...this._options, visible: false };
this.sidebarOptions$.next(this._options);
this._options.onClose?.();
}
get visible(): boolean {
return this._options.visible ?? false;
}
get options(): ISidebarMethodOptions {
return this._options;
}
get width(): number | undefined {
return undefined;
}
setWidth(): void {}
getContainer(): HTMLElement | undefined {
return this._container;
}
setContainer(element?: HTMLElement): void {
this._container = element;
}
override dispose(): void {
super.dispose();
this.sidebarOptions$.complete();
this.scrollEvent$.complete();
}
}
class TestRenderManagerService implements Pick<IRenderManagerService, 'getRenderUnitById'> {
getRenderUnitById(): IRender | null {
return null;
}
}
function createDocData(decorationIds: string[]): IDocumentData {
return {
id: DOC_ID,
body: {
dataStream: 'Alpha beta gamma\r\n',
textRuns: [],
paragraphs: [],
sectionBreaks: [],
customBlocks: [],
customDecorations: decorationIds.map((id) => ({
id,
type: CustomDecorationType.COMMENT,
startIndex: 0,
endIndex: 4,
})),
},
documentStyle: {
pageSize: {
width: 594.3,
height: 840.51,
},
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
function createTextBody(text: string): IDocumentBody {
const dataStream = `${text}\r\n`;
return {
dataStream,
textRuns: [],
paragraphs: [
{
startIndex: dataStream.length - 2,
paragraphId: `paragraph-${text}`,
},
],
sectionBreaks: [
{
sectionId: `section-${text}`,
startIndex: dataStream.length - 1,
},
],
};
}
function createThread(id: string, text: string, resolved = false): IThreadComment {
return {
id,
threadId: id,
ref: `Range ${id}`,
unitId: DOC_ID,
subUnitId: DEFAULT_DOC_SUBUNIT_ID,
dT: '2026-06-17T00:00:00.000Z',
personId: 'user-1',
resolved,
text: createTextBody(text),
};
}
function createPanelTestBed(decorationIds: string[]) {
const univer = new Univer({
locale: LocaleType.EN_US,
locales: {
[LocaleType.EN_US]: threadCommentEnUS,
},
});
const injector = univer.__getInjector();
const get = injector.get.bind(injector) as Injector['get'];
injector.add([DocSelectionManagerService]);
injector.add([IRenderManagerService, { useClass: TestRenderManagerService as never }]);
injector.add([IThreadCommentDataSourceService, { useClass: ThreadCommentDataSourceService }]);
injector.add([ThreadCommentModel]);
injector.add([ISidebarService, { useClass: TestSidebarService as never }]);
injector.add([ThreadCommentDraftService]);
injector.add([ThreadCommentPanelService]);
injector.add([DocThreadCommentService]);
univer.createUnit<IDocumentData, DocumentDataModel>(UniverInstanceType.UNIVER_DOC, createDocData(decorationIds));
get(IUniverInstanceService).focusUnit(DOC_ID);
get(UserManagerService).setCurrentUser({ userID: 'user-1', name: 'Ada' });
get(ICommandService).registerCommand(SetActiveCommentOperation);
return {
univer,
injector,
get,
threadCommentModel: get(ThreadCommentModel),
panelService: get(ThreadCommentPanelService),
};
}
function renderPanel(injector: Injector) {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<RediContext.Provider value={{ injector }}>
<DocThreadCommentPanel />
</RediContext.Provider>
);
});
return { container, root };
}
describe('DocThreadCommentPanel', () => {
let univer: Univer | undefined;
let root: Root | undefined;
let container: HTMLElement | undefined;
let scrollIntoViewDescriptor: PropertyDescriptor | undefined;
beforeEach(() => {
scrollIntoViewDescriptor = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollIntoView');
Object.defineProperty(Element.prototype, 'scrollIntoView', {
configurable: true,
value() {},
});
});
afterEach(() => {
if (root) {
act(() => root!.unmount());
}
container?.remove();
univer?.dispose();
if (scrollIntoViewDescriptor) {
Object.defineProperty(Element.prototype, 'scrollIntoView', scrollIntoViewDescriptor);
} else {
delete (Element.prototype as { scrollIntoView?: unknown }).scrollIntoView;
}
root = undefined;
container = undefined;
univer = undefined;
});
it('renders only thread comments attached to document custom ranges', () => {
const testBed = createPanelTestBed(['visible-thread', 'visible-thread']);
univer = testBed.univer;
testBed.threadCommentModel.addComment(DOC_ID, DEFAULT_DOC_SUBUNIT_ID, createThread('visible-thread', 'Visible document feedback'));
testBed.threadCommentModel.addComment(DOC_ID, DEFAULT_DOC_SUBUNIT_ID, createThread('detached-thread', 'Detached feedback'));
const rendered = renderPanel(testBed.injector);
root = rendered.root;
container = rendered.container;
expect(container.textContent).toContain('Visible document feedback');
expect(container.textContent).not.toContain('Detached feedback');
expect(container.textContent?.match(/Visible document feedback/g)).toHaveLength(1);
});
it('uses the drawing subunit while a drawing comment draft is active', () => {
expect(getDocCommentPanelSubUnitId({ unitId: DOC_ID, subUnitId: DOC_ID }, DOC_ID)).toBe(DOC_ID);
expect(getDocCommentPanelSubUnitId({ unitId: 'other-doc', subUnitId: 'other-doc' }, DOC_ID)).toBe(DEFAULT_DOC_SUBUNIT_ID);
expect(getDocCommentPanelSubUnitId(null, DOC_ID)).toBe(DEFAULT_DOC_SUBUNIT_ID);
expect(getDocCommentPanelSubUnitId(null, undefined)).toBe(DEFAULT_DOC_SUBUNIT_ID);
});
});

View File

@@ -0,0 +1,27 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Config } from 'tailwindcss';
import preset from '@univerjs-infra/shared/tailwind';
const config: Config = {
presets: [preset],
content: [
'./src/**/*.{js,ts,jsx,tsx}',
],
};
export default config;

View File

@@ -0,0 +1,8 @@
{
"extends": "@univerjs-infra/shared/tsconfigs/base",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"]
}

View File

@@ -0,0 +1,18 @@
{
"extends": "@univerjs-infra/shared/tsconfigs/node",
"compilerOptions": {
"rootDir": "src",
"declaration": true,
"emitDeclarationOnly": true,
"noEmit": false,
"outDir": "lib/types"
},
"include": [
"src"
],
"exclude": [
"src/**/__tests__/**",
"src/**/*.spec.ts",
"src/**/*.test.ts"
]
}

View File

@@ -0,0 +1,3 @@
import createConfig from '@univerjs-infra/shared/vitest';
export default createConfig();