From 3fbd045d253418154f91a5379f482f9854f69045 Mon Sep 17 00:00:00 2001 From: Univer <68851825+DR-Univer@users.noreply.github.com> Date: Wed, 26 Aug 2026 12:36:15 +0800 Subject: [PATCH] feat(thread-comment): support unified comment anchors and facade APIs (#7585) --- packages/core/src/bases/typedef.ts | 7 + .../src/menu/drawing-popup-menu.controller.ts | 35 +- .../src/facade/f-document-image.ts | 9 +- packages/docs-thread-comment-ui/README.md | 10 +- packages/docs-thread-comment-ui/package.json | 2 + .../__tests__/add-doc-comment.command.spec.ts | 167 ++++++- .../commands/add-doc-comment.command.ts | 79 ++-- .../show-comment-panel.operation.spec.ts | 27 +- .../show-comment-panel.operation.ts | 39 +- .../src/common/const.ts | 2 - ...hread-comment-selection.controller.spec.ts | 16 +- .../src/controllers/components.controller.ts | 3 +- ...doc-thread-comment-selection.controller.ts | 49 +- .../__tests__/render.controller.spec.ts | 161 ++++++- .../render-controllers/render.controller.ts | 186 +++++++- .../src/controllers/ui.controller.ts | 2 + .../src/locale/ar-SA.ts | 1 + .../src/locale/ca-ES.ts | 1 + .../src/locale/de-DE.ts | 1 + .../src/locale/en-US.ts | 1 + .../src/locale/es-ES.ts | 1 + .../src/locale/fa-IR.ts | 1 + .../src/locale/fr-FR.ts | 1 + .../src/locale/id-ID.ts | 1 + .../src/locale/it-IT.ts | 1 + .../src/locale/ja-JP.ts | 1 + .../src/locale/ko-KR.ts | 1 + .../src/locale/pl-PL.ts | 1 + .../src/locale/pt-BR.ts | 1 + .../src/locale/ru-RU.ts | 1 + .../src/locale/sk-SK.ts | 1 + .../src/locale/vi-VN.ts | 1 + .../src/locale/zh-CN.ts | 1 + .../src/locale/zh-HK.ts | 1 + .../src/locale/zh-TW.ts | 1 + .../docs-thread-comment-ui/src/menu/menu.ts | 18 +- .../docs-thread-comment-ui/src/menu/schema.ts | 25 +- packages/docs-thread-comment-ui/src/plugin.ts | 21 +- .../src/views/DocThreadCommentPanel.tsx | 78 +++- .../__tests__/DocThreadCommentPanel.spec.tsx | 14 +- packages/docs-thread-comment/README.md | 39 ++ packages/docs-thread-comment/package.json | 87 ++++ ...ate-doc-text-range-comment.command.spec.ts | 113 +++++ .../create-doc-text-range-comment.command.ts | 206 +++++++++ .../docs-thread-comment/src/common/const.ts | 20 + .../docs-thread-comment/src/config/config.ts | 24 + .../src/facade/f-document-text-range.ts | 130 ++++++ .../docs-thread-comment/src/facade/index.ts | 22 + packages/docs-thread-comment/src/index.ts | 30 ++ packages/docs-thread-comment/src/plugin.ts | 65 +++ packages/docs-thread-comment/tsconfig.json | 8 + .../docs-thread-comment/tsconfig.node.json | 18 + packages/docs-thread-comment/vitest.config.ts | 3 + .../doc-contextmenu.render-controller.spec.ts | 16 +- .../doc-resize.render-controller.spec.ts | 51 ++ .../doc-contextmenu.render-controller.ts | 8 +- .../doc-resize.render-controller.ts | 33 +- .../selection/doc-selection-render.service.ts | 5 + .../render-manager/render-manager.service.ts | 3 + .../src/mobile-plugin.ts | 4 +- .../sheets-data-validation-ui/src/plugin.ts | 4 +- .../drawing-popup-menu.controller.spec.ts | 33 +- .../src/menu/drawing-popup-menu.controller.ts | 31 +- .../src/facade/f-over-grid-image.ts | 10 + packages/sheets-drawing/src/facade/index.ts | 2 +- packages/sheets-hyper-link-ui/src/plugin.ts | 4 +- .../sheets-thread-comment-ui/package.json | 1 + .../__tests__/comment.operation.spec.ts | 25 +- .../commands/operations/comment.operation.ts | 78 +++- ...read-comment-copy-paste.controller.spec.ts | 25 + ...read-comment-permission.controller.spec.ts | 87 +++- ...ts-thread-comment-popup.controller.spec.ts | 54 +++ .../src/controllers/components.controller.ts | 3 +- .../drawing.render-controller.spec.ts | 104 +++++ .../drawing.render-controller.ts | 161 +++++++ ...ts-thread-comment-permission.controller.ts | 64 ++- .../sheets-thread-comment-popup.controller.ts | 43 +- .../src/locale/ar-SA.ts | 1 + .../src/locale/ca-ES.ts | 1 + .../src/locale/de-DE.ts | 1 + .../src/locale/en-US.ts | 1 + .../src/locale/es-ES.ts | 1 + .../src/locale/fa-IR.ts | 1 + .../src/locale/fr-FR.ts | 1 + .../src/locale/id-ID.ts | 1 + .../src/locale/it-IT.ts | 1 + .../src/locale/ja-JP.ts | 1 + .../src/locale/ko-KR.ts | 1 + .../src/locale/pl-PL.ts | 1 + .../src/locale/pt-BR.ts | 1 + .../src/locale/ru-RU.ts | 1 + .../src/locale/sk-SK.ts | 1 + .../src/locale/vi-VN.ts | 1 + .../src/locale/zh-CN.ts | 1 + .../src/locale/zh-HK.ts | 1 + .../src/locale/zh-TW.ts | 1 + .../sheets-thread-comment-ui/src/menu/menu.ts | 16 +- .../src/menu/schema.ts | 27 +- .../sheets-thread-comment-ui/src/plugin.ts | 14 +- .../src/views/SheetsThreadCommentPanel.tsx | 33 +- .../SheetsThreadCommentPanel.spec.tsx | 8 +- ...ets-thread-comment-ref-range.controller.ts | 5 +- .../src/facade/__tests__/f-range.spec.ts | 25 +- .../src/facade/f-range.ts | 176 ++++--- .../src/models/sheets-thread-comment.model.ts | 14 +- packages/thread-comment-ui/package.json | 2 +- packages/thread-comment-ui/src/index.ts | 7 + .../thread-comment-ui/src/locale/ar-SA.ts | 1 + .../thread-comment-ui/src/locale/ca-ES.ts | 1 + .../thread-comment-ui/src/locale/de-DE.ts | 1 + .../thread-comment-ui/src/locale/en-US.ts | 1 + .../thread-comment-ui/src/locale/es-ES.ts | 1 + .../thread-comment-ui/src/locale/fa-IR.ts | 1 + .../thread-comment-ui/src/locale/fr-FR.ts | 1 + .../thread-comment-ui/src/locale/id-ID.ts | 1 + .../thread-comment-ui/src/locale/it-IT.ts | 1 + .../thread-comment-ui/src/locale/ja-JP.ts | 1 + .../thread-comment-ui/src/locale/ko-KR.ts | 1 + .../thread-comment-ui/src/locale/pl-PL.ts | 1 + .../thread-comment-ui/src/locale/pt-BR.ts | 1 + .../thread-comment-ui/src/locale/ru-RU.ts | 1 + .../thread-comment-ui/src/locale/sk-SK.ts | 1 + .../thread-comment-ui/src/locale/vi-VN.ts | 1 + .../thread-comment-ui/src/locale/zh-CN.ts | 1 + .../thread-comment-ui/src/locale/zh-HK.ts | 1 + .../thread-comment-ui/src/locale/zh-TW.ts | 1 + packages/thread-comment-ui/src/plugin.ts | 2 + .../thread-comment-canvas-overlay.spec.ts | 207 +++++++++ .../thread-comment-canvas-overlay.ts | 297 ++++++++++++ .../thread-comment-draft.service.spec.ts | 92 ++++ .../thread-comment-panel.service.spec.ts | 35 +- .../services/thread-comment-draft.service.ts | 106 +++++ .../services/thread-comment-panel.service.ts | 31 +- .../src/views/ThreadCommentEditor.tsx | 101 +++- .../src/views/ThreadCommentPanel.tsx | 111 ++++- .../src/views/ThreadCommentTree.tsx | 186 +++++--- .../__tests__/ThreadCommentEditor.spec.tsx | 203 +++++++- .../__tests__/ThreadCommentPanel.spec.tsx | 181 +++++++- .../__tests__/ThreadCommentTree.spec.tsx | 101 +++- .../__tests__/util.spec.ts | 21 +- .../src/views/thread-comment-panel/util.ts | 20 + packages/thread-comment/README.md | 46 +- packages/thread-comment/package.json | 11 + .../__tests__/comment.command.spec.ts | 434 +++++++++++++++++- .../src/commands/commands/comment.command.ts | 67 ++- .../__tests__/tc-resource.controller.spec.ts | 77 +++- .../src/controllers/tc-resource.controller.ts | 59 ++- packages/thread-comment/src/facade/f-enum.ts | 43 ++ .../thread-comment/src/facade/f-univer.ts | 211 +++++++++ packages/thread-comment/src/facade/index.ts | 19 + packages/thread-comment/src/index.ts | 24 +- .../__tests__/thread-comment.model.spec.ts | 283 ++++++++++++ .../src/models/thread-comment.model.ts | 223 ++++++++- packages/thread-comment/src/plugin.ts | 2 + .../__tests__/tc-datasource.service.spec.ts | 72 ++- .../thread-comment-api.service.spec.ts | 55 +++ .../src/services/tc-datasource.service.ts | 40 +- .../services/thread-comment-api.service.ts | 304 ++++++++++++ .../types/__tests__/comment-anchor.spec.ts | 46 ++ .../src/types/comment-anchor.ts | 134 ++++++ .../src/types/interfaces/i-thread-comment.ts | 8 + packages/ui/src/index.ts | 1 + .../__tests__/menu-manager.service.spec.ts | 13 + .../src/services/menu/menu-manager.service.ts | 18 +- packages/ui/src/services/menu/types.ts | 7 + pnpm-lock.yaml | 189 ++++---- 166 files changed, 6386 insertions(+), 642 deletions(-) create mode 100644 packages/docs-thread-comment/README.md create mode 100644 packages/docs-thread-comment/package.json create mode 100644 packages/docs-thread-comment/src/commands/commands/__tests__/create-doc-text-range-comment.command.spec.ts create mode 100644 packages/docs-thread-comment/src/commands/commands/create-doc-text-range-comment.command.ts create mode 100644 packages/docs-thread-comment/src/common/const.ts create mode 100644 packages/docs-thread-comment/src/config/config.ts create mode 100644 packages/docs-thread-comment/src/facade/f-document-text-range.ts create mode 100644 packages/docs-thread-comment/src/facade/index.ts create mode 100644 packages/docs-thread-comment/src/index.ts create mode 100644 packages/docs-thread-comment/src/plugin.ts create mode 100644 packages/docs-thread-comment/tsconfig.json create mode 100644 packages/docs-thread-comment/tsconfig.node.json create mode 100644 packages/docs-thread-comment/vitest.config.ts create mode 100644 packages/docs-ui/src/controllers/render-controllers/__tests__/doc-resize.render-controller.spec.ts create mode 100644 packages/sheets-thread-comment-ui/src/controllers/__tests__/sheets-thread-comment-popup.controller.spec.ts create mode 100644 packages/sheets-thread-comment-ui/src/controllers/render-controllers/__tests__/drawing.render-controller.spec.ts create mode 100644 packages/sheets-thread-comment-ui/src/controllers/render-controllers/drawing.render-controller.ts create mode 100644 packages/thread-comment-ui/src/render-objects/__tests__/thread-comment-canvas-overlay.spec.ts create mode 100644 packages/thread-comment-ui/src/render-objects/thread-comment-canvas-overlay.ts create mode 100644 packages/thread-comment-ui/src/services/__tests__/thread-comment-draft.service.spec.ts create mode 100644 packages/thread-comment-ui/src/services/thread-comment-draft.service.ts create mode 100644 packages/thread-comment/src/facade/f-enum.ts create mode 100644 packages/thread-comment/src/facade/f-univer.ts create mode 100644 packages/thread-comment/src/facade/index.ts create mode 100644 packages/thread-comment/src/services/__tests__/thread-comment-api.service.spec.ts create mode 100644 packages/thread-comment/src/services/thread-comment-api.service.ts create mode 100644 packages/thread-comment/src/types/__tests__/comment-anchor.spec.ts create mode 100644 packages/thread-comment/src/types/comment-anchor.ts diff --git a/packages/core/src/bases/typedef.ts b/packages/core/src/bases/typedef.ts index a8817c6196..3098959af9 100644 --- a/packages/core/src/bases/typedef.ts +++ b/packages/core/src/bases/typedef.ts @@ -875,6 +875,13 @@ export type BaseHitTestResult = viewId: ViewId; recordId: RecordId; } + | { + type: 'base-record-action' | 'base-record-action-badge'; + tableId: TableId; + viewId: ViewId; + recordId: RecordId; + actionId: string; + } | { type: 'grid-hierarchy-toggle' | 'grid-hierarchy-add-child'; tableId: TableId; diff --git a/packages/docs-drawing-ui/src/menu/drawing-popup-menu.controller.ts b/packages/docs-drawing-ui/src/menu/drawing-popup-menu.controller.ts index e0654b2e82..4be67c9adf 100644 --- a/packages/docs-drawing-ui/src/menu/drawing-popup-menu.controller.ts +++ b/packages/docs-drawing-ui/src/menu/drawing-popup-menu.controller.ts @@ -39,6 +39,7 @@ import { OpenImageCropOperation, } from '@univerjs/drawing-ui'; import { IRenderManagerService } from '@univerjs/engine-render'; +import { FloatingObjectToolbarPosition, IMenuManagerService, MenuItemType } from '@univerjs/ui'; import { takeUntil } from 'rxjs'; import { EditDocDrawingOperation } from '../commands/operations/edit-doc-drawing.operation'; import { SidebarDocDrawingOperation } from '../commands/operations/open-drawing-panel.operation'; @@ -59,7 +60,8 @@ export class DocDrawingPopupMenuController extends RxDisposable { @IContextService private readonly _contextService: IContextService, @IDocDrawingAdapterService private readonly _drawingAdapterService: IDocDrawingAdapterService, @Inject(DocDrawingFloatingToolbarAdapterService) private readonly _floatingToolbarAdapterService: DocDrawingFloatingToolbarAdapterService, - @ICommandService private readonly _commandService: ICommandService + @ICommandService private readonly _commandService: ICommandService, + @IMenuManagerService private readonly _menuManagerService: IMenuManagerService ) { super(); @@ -265,15 +267,11 @@ export class DocDrawingPopupMenuController extends RxDisposable { const floatingToolbarMenuItems = drawing ? this._floatingToolbarAdapterService.getItems({ unitId, subUnitId, drawing }) : null; - if (floatingToolbarMenuItems) { - return floatingToolbarMenuItems; - } - const editCommandInfo = drawing ? this._drawingAdapterService.getEditDrawingCommandInfo({ unitId, subUnitId, drawing }) : null; - return [ + const defaultItems = [ { label: editCommandInfo?.label ?? 'docs-drawing-ui.image-popup.edit', index: 0, @@ -307,5 +305,30 @@ export class DocDrawingPopupMenuController extends RxDisposable { disable: true, // TODO: @JOCS, feature is not ready. }, ]; + + return [ + ...(floatingToolbarMenuItems ?? defaultItems), + ...this._getFloatingObjectMenuItems(), + ]; + } + + private _getFloatingObjectMenuItems() { + return this._menuManagerService + .getFlatMenuByPositionKey(FloatingObjectToolbarPosition.DOC) + .flatMap(({ item }, index) => { + if (!item || item.type !== MenuItemType.BUTTON || !item.title || typeof item.icon !== 'string') { + return []; + } + + return [{ + type: 'button' as const, + label: item.title, + index: 100 + index, + commandId: item.commandId ?? item.id, + commandParams: typeof item.params === 'function' ? item.params() : item.params, + disable: false, + icon: item.icon, + }]; + }); } } diff --git a/packages/docs-drawing/src/facade/f-document-image.ts b/packages/docs-drawing/src/facade/f-document-image.ts index 731a3ab93c..2f3f1e71dd 100644 --- a/packages/docs-drawing/src/facade/f-document-image.ts +++ b/packages/docs-drawing/src/facade/f-document-image.ts @@ -22,6 +22,7 @@ import { DrawingTypeEnum, ICommandService, } from '@univerjs/core'; +import { FBase } from '@univerjs/core/facade'; import { RemoveDocDrawingCommand, SetDocDrawingArrangeCommand, @@ -33,12 +34,14 @@ import { * Facade API for an image in a document. * @hideconstructor */ -export class FDocumentImage { +export class FDocumentImage extends FBase { constructor( private readonly _document: FDocument, private readonly _imageId: string, - private readonly _injector: Injector - ) {} + protected readonly _injector: Injector + ) { + super(); + } /** * Gets the id of the document containing the image. diff --git a/packages/docs-thread-comment-ui/README.md b/packages/docs-thread-comment-ui/README.md index 082a549334..57b84fd46a 100644 --- a/packages/docs-thread-comment-ui/README.md +++ b/packages/docs-thread-comment-ui/README.md @@ -15,9 +15,9 @@ ## Installation ```sh -pnpm add @univerjs/docs-thread-comment-ui +pnpm add @univerjs/docs-thread-comment @univerjs/docs-thread-comment-ui # or -npm install @univerjs/docs-thread-comment-ui +npm install @univerjs/docs-thread-comment @univerjs/docs-thread-comment-ui ``` Keep all `@univerjs/*` packages on the same version. @@ -26,14 +26,18 @@ Keep all `@univerjs/*` packages on the same version. ```ts import '@univerjs/docs-thread-comment-ui/lib/index.css'; +import '@univerjs/docs-thread-comment/facade'; import { UniverDocsThreadCommentUIPlugin } from '@univerjs/docs-thread-comment-ui'; univer.registerPlugin(UniverDocsThreadCommentUIPlugin); + +const range = univerAPI.getActiveDocument()?.getTextRange(0, 12); +await range?.createCommentAsync('Verify this introduction.', { id: 'review-intro' }); ``` ## Integration Notes -Use this package with `@univerjs/thread-comment` and `@univerjs/thread-comment-ui` for shared comment behavior. +Use `@univerjs/docs-thread-comment` for headless model commands and Facade APIs. This UI package adds menus, rendering, and the shared side panel. ## Resources diff --git a/packages/docs-thread-comment-ui/package.json b/packages/docs-thread-comment-ui/package.json index 393f654719..abca0c1a06 100644 --- a/packages/docs-thread-comment-ui/package.json +++ b/packages/docs-thread-comment-ui/package.json @@ -76,7 +76,9 @@ "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/thread-comment": "workspace:*", diff --git a/packages/docs-thread-comment-ui/src/commands/commands/__tests__/add-doc-comment.command.spec.ts b/packages/docs-thread-comment-ui/src/commands/commands/__tests__/add-doc-comment.command.spec.ts index 7d6acb403d..1c12414c77 100644 --- a/packages/docs-thread-comment-ui/src/commands/commands/__tests__/add-doc-comment.command.spec.ts +++ b/packages/docs-thread-comment-ui/src/commands/commands/__tests__/add-doc-comment.command.spec.ts @@ -14,22 +14,29 @@ * limitations under the License. */ -import type { DocumentDataModel, ICommand, IDocumentData, Injector } from '@univerjs/core'; +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 } from 'vitest'; -import { DEFAULT_DOC_SUBUNIT_ID } from '../../../common/const'; +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'; @@ -74,6 +81,7 @@ function createComment(id = 'comment-1'): IThreadComment { describe('AddDocCommentComment', () => { let univer: Univer; + let injector: Injector; let get: Injector['get']; let commandService: ICommandService; @@ -84,7 +92,7 @@ describe('AddDocCommentComment', () => { beforeEach(() => { univer = new Univer(); - const injector = univer.__getInjector(); + injector = univer.__getInjector(); get = injector.get.bind(injector); injector.add([DocSelectionManagerService]); @@ -92,6 +100,7 @@ describe('AddDocCommentComment', () => { injector.add([IRenderManagerService, { useClass: RenderManagerService }]); injector.add([IThreadCommentDataSourceService, { useClass: ThreadCommentDataSourceService }]); injector.add([ThreadCommentModel]); + injector.add([ThreadCommentFacadeService]); injector.add([ISidebarService, { useClass: DesktopSidebarService }]); injector.add([ThreadCommentPanelService]); @@ -100,32 +109,18 @@ describe('AddDocCommentComment', () => { commandService = get(ICommandService); commandService.registerCommand(AddDocCommentComment); + commandService.registerCommand(CreateDocTextRangeCommentCommand); + commandService.registerCommand(AddDocCommentDecorationMutation); commandService.registerCommand(AddCommentMutation); commandService.registerCommand(SetActiveCommentOperation); - commandService.registerCommand(RichTextEditingMutation as unknown as ICommand); + commandService.registerCommand(RichTextEditingMutation); }); afterEach(() => { univer.dispose(); }); - it('adds a thread comment to the selected document text and makes it active', 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, - }, - ]); - + 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, @@ -133,6 +128,7 @@ describe('AddDocCommentComment', () => { range: { startOffset: 0, endOffset: 5, + collapsed: false, }, }); @@ -165,21 +161,142 @@ describe('AddDocCommentComment', () => { }); }); - it('does not attach a comment when the document has no selected text', async () => { - const comment = createComment('comment-without-selection'); + 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: 0, + 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(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 () => { diff --git a/packages/docs-thread-comment-ui/src/commands/commands/add-doc-comment.command.ts b/packages/docs-thread-comment-ui/src/commands/commands/add-doc-comment.command.ts index 366d46d6f1..d124f52d35 100644 --- a/packages/docs-thread-comment-ui/src/commands/commands/add-doc-comment.command.ts +++ b/packages/docs-thread-comment-ui/src/commands/commands/add-doc-comment.command.ts @@ -14,20 +14,19 @@ * limitations under the License. */ -import type { ICommand, ITextRange } from '@univerjs/core'; -import type { IThreadComment } from '@univerjs/thread-comment'; -import { CommandType, CustomDecorationType, ICommandService, sequenceExecute } from '@univerjs/core'; -import { addCustomDecorationBySelectionFactory } from '@univerjs/docs-ui'; -import { AddCommentMutation, IThreadCommentDataSourceService } from '@univerjs/thread-comment'; +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'; -import { DEFAULT_DOC_SUBUNIT_ID } from '../../common/const'; -export interface IAddDocCommentComment { - unitId: string; - comment: IThreadComment; - range: ITextRange; -} +export type IAddDocCommentComment = IAddDocTextRangeCommentParams; +/** Adds a document comment and activates it in the UI comment panel. */ export const AddDocCommentComment: ICommand = { id: 'docs.command.add-comment', type: CommandType.COMMAND, @@ -35,41 +34,31 @@ export const AddDocCommentComment: ICommand = { if (!params) { return false; } - const { comment: originComment, unitId } = params; - const dataSourceService = accessor.get(IThreadCommentDataSourceService); - const comment = await dataSourceService.addComment(originComment); - const commandService = accessor.get(ICommandService); - const doMutation = addCustomDecorationBySelectionFactory( - accessor, - { - id: comment.threadId, - type: CustomDecorationType.COMMENT, - unitId, - } - ); - if (doMutation) { - const addComment = { - id: AddCommentMutation.id, - params: { - unitId, - subUnitId: DEFAULT_DOC_SUBUNIT_ID, - comment, - }, - }; - - const activeOperation = { - id: SetActiveCommentOperation.id, - params: { - unitId, - subUnitId: DEFAULT_DOC_SUBUNIT_ID, - commentId: comment.id, - }, - }; - - return (await sequenceExecute([addComment, doMutation, activeOperation], commandService)).result; + const prepared = await prepareDocTextRangeComment(accessor, params); + if (!prepared) { + return false; } - - 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; }, }; diff --git a/packages/docs-thread-comment-ui/src/commands/operations/__tests__/show-comment-panel.operation.spec.ts b/packages/docs-thread-comment-ui/src/commands/operations/__tests__/show-comment-panel.operation.spec.ts index bd3a240b22..37ffe53f0e 100644 --- a/packages/docs-thread-comment-ui/src/commands/operations/__tests__/show-comment-panel.operation.spec.ts +++ b/packages/docs-thread-comment-ui/src/commands/operations/__tests__/show-comment-panel.operation.spec.ts @@ -19,6 +19,7 @@ import type { IRender } from '@univerjs/engine-render'; import type { ISidebarMethodOptions } from '@univerjs/ui'; import { Disposable, + DrawingTypeEnum, ICommandService, IUniverInstanceService, toDisposable, @@ -26,14 +27,17 @@ import { 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 { DEFAULT_DOC_SUBUNIT_ID, DOCS_THREAD_COMMENT_PANEL } from '../../../common/const'; +import { DOCS_THREAD_COMMENT_PANEL } from '../../../common/const'; import { DocThreadCommentService } from '../../../services/doc-thread-comment.service'; import { + AddDocDrawingCommentOperation, ShowCommentPanelOperation, StartAddCommentOperation, ToggleCommentPanelOperation, @@ -218,4 +222,25 @@ describe('doc thread comment panel operations', () => { 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(); + }); }); diff --git a/packages/docs-thread-comment-ui/src/commands/operations/show-comment-panel.operation.ts b/packages/docs-thread-comment-ui/src/commands/operations/show-comment-panel.operation.ts index 3bde082811..27969fb7c7 100644 --- a/packages/docs-thread-comment-ui/src/commands/operations/show-comment-panel.operation.ts +++ b/packages/docs-thread-comment-ui/src/commands/operations/show-comment-panel.operation.ts @@ -18,12 +18,14 @@ 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 } from '@univerjs/thread-comment'; -import { ThreadCommentPanelService } from '@univerjs/thread-comment-ui'; +import { getDT, ThreadCommentAnchorKind } from '@univerjs/thread-comment'; +import { ThreadCommentDraftService, ThreadCommentPanelService } from '@univerjs/thread-comment-ui'; import { ISidebarService } from '@univerjs/ui'; -import { DEFAULT_DOC_SUBUNIT_ID, DOCS_THREAD_COMMENT_PANEL } from '../../common/const'; +import { DOCS_THREAD_COMMENT_PANEL } from '../../common/const'; import { DocThreadCommentService } from '../../services/doc-thread-comment.service'; export interface IShowCommentPanelOperationParams { @@ -142,3 +144,34 @@ export const StartAddCommentOperation: ICommand = { 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(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; + }, +}; diff --git a/packages/docs-thread-comment-ui/src/common/const.ts b/packages/docs-thread-comment-ui/src/common/const.ts index 7a4c981852..917d42e927 100644 --- a/packages/docs-thread-comment-ui/src/common/const.ts +++ b/packages/docs-thread-comment-ui/src/common/const.ts @@ -18,6 +18,4 @@ export const DOCS_THREAD_COMMENT_PANEL = 'univer.doc.thread-comment-panel'; export const PLUGIN_NAME = 'DOC_THREAD_COMMENT_UI_PLUGIN'; -export const DEFAULT_DOC_SUBUNIT_ID = 'default_doc'; - export const DEFAULT_TEMP_COMMENT_ID = 'default_comment'; diff --git a/packages/docs-thread-comment-ui/src/controllers/__tests__/doc-thread-comment-selection.controller.spec.ts b/packages/docs-thread-comment-ui/src/controllers/__tests__/doc-thread-comment-selection.controller.spec.ts index 4cc6b8c112..deaa7952e9 100644 --- a/packages/docs-thread-comment-ui/src/controllers/__tests__/doc-thread-comment-selection.controller.spec.ts +++ b/packages/docs-thread-comment-ui/src/controllers/__tests__/doc-thread-comment-selection.controller.spec.ts @@ -14,12 +14,13 @@ * 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 { DEFAULT_DOC_SUBUNIT_ID } from '../../common/const'; import { DocThreadCommentSelectionController } from '../doc-thread-comment-selection.controller'; describe('DocThreadCommentSelectionController', () => { @@ -31,7 +32,14 @@ describe('DocThreadCommentSelectionController', () => { }; const doc = { - getBody: () => ({ customDecorations: [{ id: 'c1', startIndex: 0, endIndex: 5 }] }), + 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 = { @@ -70,7 +78,7 @@ describe('DocThreadCommentSelectionController', () => { }); expect(executeCommand).toHaveBeenCalledWith(ShowCommentPanelOperation.id, { - activeComment: { unitId: 'doc-1', subUnitId: DEFAULT_DOC_SUBUNIT_ID, commentId: 'c1' }, + activeComment: { unitId: 'doc-1', subUnitId: DEFAULT_DOC_SUBUNIT_ID, commentId: 'newest' }, }); controller.dispose(); @@ -84,7 +92,7 @@ describe('DocThreadCommentSelectionController', () => { }; const doc = { - getBody: () => ({ customDecorations: [{ id: 'c1', startIndex: 0, endIndex: 5 }] }), + getBody: () => ({ customDecorations: [{ id: 'c1', type: CustomDecorationType.COMMENT, startIndex: 0, endIndex: 5 }] }), }; const univerInstanceService = { getUnit: vi.fn(() => doc) }; diff --git a/packages/docs-thread-comment-ui/src/controllers/components.controller.ts b/packages/docs-thread-comment-ui/src/controllers/components.controller.ts index 2317faf2eb..91eec14c30 100644 --- a/packages/docs-thread-comment-ui/src/controllers/components.controller.ts +++ b/packages/docs-thread-comment-ui/src/controllers/components.controller.ts @@ -15,7 +15,7 @@ */ import { Disposable, Inject } from '@univerjs/core'; -import { CommentIcon } from '@univerjs/icons'; +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'; @@ -44,6 +44,7 @@ export class ComponentsController extends Disposable { private _registerIcons(): void { this.disposeWithMe(this._iconManager.register({ CommentIcon, + InsertCommentDoubleIcon, })); } } diff --git a/packages/docs-thread-comment-ui/src/controllers/doc-thread-comment-selection.controller.ts b/packages/docs-thread-comment-ui/src/controllers/doc-thread-comment-selection.controller.ts index bf0a488508..d3997bdd89 100644 --- a/packages/docs-thread-comment-ui/src/controllers/doc-thread-comment-selection.controller.ts +++ b/packages/docs-thread-comment-ui/src/controllers/doc-thread-comment-selection.controller.ts @@ -14,10 +14,11 @@ * limitations under the License. */ -import type { DocumentDataModel, ITextRange } from '@univerjs/core'; +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, @@ -26,12 +27,12 @@ import { 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 { DEFAULT_DOC_SUBUNIT_ID } from '../common/const'; import { DocThreadCommentService } from '../services/doc-thread-comment.service'; export class DocThreadCommentSelectionController extends Disposable { @@ -65,25 +66,17 @@ export class DocThreadCommentSelectionController extends Disposable { lastSelection = primary; if (primary && doc) { const { startOffset, endOffset, collapsed } = primary; - let customRange; - - if (collapsed) { // cursor - customRange = doc.getBody()?.customDecorations?.find((value) => value.startIndex <= startOffset && value.endIndex >= (endOffset - 1)); - } else { // range - customRange = doc.getBody()?.customDecorations?.find((value) => value.startIndex <= startOffset && value.endIndex >= (endOffset - 1)); - } + const selectionEnd = collapsed ? startOffset : endOffset - 1; + const customRange = this._findActiveCommentDecoration(doc, unitId, startOffset, selectionEnd); if (customRange) { - const comment = this._threadCommentModel.getComment(unitId, DEFAULT_DOC_SUBUNIT_ID, customRange.id); - if (comment && !comment.resolved) { - this._commandService.executeCommand(ShowCommentPanelOperation.id, { - activeComment: { - unitId, - subUnitId: DEFAULT_DOC_SUBUNIT_ID, - commentId: customRange.id, - }, - }); - } + this._commandService.executeCommand(ShowCommentPanelOperation.id, { + activeComment: { + unitId, + subUnitId: DEFAULT_DOC_SUBUNIT_ID, + commentId: customRange.id, + }, + }); return; } } @@ -109,6 +102,24 @@ export class DocThreadCommentSelectionController extends Disposable { ); } + 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) { diff --git a/packages/docs-thread-comment-ui/src/controllers/render-controllers/__tests__/render.controller.spec.ts b/packages/docs-thread-comment-ui/src/controllers/render-controllers/__tests__/render.controller.spec.ts index 20742a432e..bd29979ae2 100644 --- a/packages/docs-thread-comment-ui/src/controllers/render-controllers/__tests__/render.controller.spec.ts +++ b/packages/docs-thread-comment-ui/src/controllers/render-controllers/__tests__/render.controller.spec.ts @@ -16,9 +16,12 @@ import { CustomDecorationType } from '@univerjs/core'; import { DOC_INTERCEPTOR_POINT, RichTextEditingMutation } from '@univerjs/docs'; -import { Subject } from 'rxjs'; +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 { DEFAULT_DOC_SUBUNIT_ID } from '../../../common/const'; import { DocThreadCommentRenderController } from '../render.controller'; describe('DocThreadCommentRenderController', () => { @@ -37,9 +40,12 @@ describe('DocThreadCommentRenderController', () => { const docRenderController = { reRender }; const activeCommentId$ = new Subject(); + const hoveredCommentId$ = new Subject(); const threadCommentPanelService = { activeCommentId: { unitId: 'doc-1', subUnitId: DEFAULT_DOC_SUBUNIT_ID, commentId: 'c2' }, activeCommentId$, + hoveredCommentId: undefined as any, + hoveredCommentId$, }; const univerInstanceService = { @@ -49,7 +55,8 @@ describe('DocThreadCommentRenderController', () => { const commentUpdate$ = new Subject(); const threadCommentModel = { commentUpdate$, - getComment: vi.fn((_unitId: string, _subUnitId: string, id: string) => (id === 'c1' ? null : { id, resolved: false })), + 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(), }; @@ -66,7 +73,29 @@ describe('DocThreadCommentRenderController', () => { getUnitId: () => 'doc-1', getBody: () => ({ customDecorations: [{ id: 'c1', type: CustomDecorationType.COMMENT }, { id: 'c2', type: CustomDecorationType.COMMENT }] }), }; - const context = { unit }; + const context = { + unit, + unitId: 'doc-1', + scene: { + addObject: vi.fn(), + getObject: vi.fn(), + getObjectIncludeInGroup: vi.fn(), + }, + engine: { + onTransformChange$: { + subscribeEvent: vi.fn(() => ({ dispose: vi.fn() })), + }, + }, + }; + const drawingManagerService = { + add$: new Subject(), + update$: new Subject(), + remove$: new Subject(), + }; + const themeService = { + currentTheme$: new Subject(), + getColorFromTheme: vi.fn((token: string) => token), + }; const controller = new DocThreadCommentRenderController( context as any, @@ -75,7 +104,9 @@ describe('DocThreadCommentRenderController', () => { docRenderController as any, univerInstanceService as any, threadCommentModel as any, - commandService as any + commandService as any, + drawingManagerService as any, + themeService as any ); expect(threadCommentModel.addComment).not.toHaveBeenCalled(); @@ -104,10 +135,52 @@ describe('DocThreadCommentRenderController', () => { ); 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(reRender).toHaveBeenCalledWith('doc-1'); + // resolved branch triggers rerender threadCommentModel.getComment.mockImplementation((_unitId: string, _subUnitId: string, id: string) => { if (id === 'c1') return null; - return { id, resolved: id === 'c2' }; + return { id, ref: 'text', resolved: id === 'c2' }; }); const outResolvedComment = handler( { id: 'c2' }, @@ -145,4 +218,80 @@ describe('DocThreadCommentRenderController', () => { 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: [] }) }, + 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); + 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, + { reRender: vi.fn() } 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(); + }); }); diff --git a/packages/docs-thread-comment-ui/src/controllers/render-controllers/render.controller.ts b/packages/docs-thread-comment-ui/src/controllers/render-controllers/render.controller.ts index 95bbe32b58..c773ab53d0 100644 --- a/packages/docs-thread-comment-ui/src/controllers/render-controllers/render.controller.ts +++ b/packages/docs-thread-comment-ui/src/controllers/render-controllers/render.controller.ts @@ -14,24 +14,36 @@ * limitations under the License. */ -import type { DocumentDataModel } from '@univerjs/core'; +import type { DocumentDataModel, EventState } from '@univerjs/core'; import type { IRichTextEditingMutationParams } from '@univerjs/docs'; -import type { IRenderContext, IRenderModule } from '@univerjs/engine-render'; +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 { DocRenderController } from '@univerjs/docs-ui'; -import { ThreadCommentModel } from '@univerjs/thread-comment'; -import { ThreadCommentPanelService } from '@univerjs/thread-comment-ui'; -import { DEFAULT_DOC_SUBUNIT_ID } from '../../common/const'; +import { getDrawingShapeKeyByDrawingSearch, IDrawingManagerService } from '@univerjs/drawing'; +import { deserializeThreadCommentAnchor, ThreadCommentAnchorKind, ThreadCommentModel } from '@univerjs/thread-comment'; +import { ThreadCommentCanvasOverlay, ThreadCommentPanelService } from '@univerjs/thread-comment-ui'; +import { pairwise, 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(); + constructor( private readonly _context: IRenderContext, @Inject(DocInterceptorService) private readonly _docInterceptorService: DocInterceptorService, @@ -39,45 +51,168 @@ export class DocThreadCommentRenderController extends Disposable implements IRen @Inject(DocRenderController) private readonly _docRenderController: DocRenderController, @IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService, @Inject(ThreadCommentModel) private readonly _threadCommentModel: ThreadCommentModel, - @ICommandService private readonly _commandService: ICommandService + @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.disposeWithMe(this._threadCommentPanelService.activeCommentId$.subscribe((activeComment) => { - if (activeComment) { - this._docRenderController.reRender(activeComment.unitId); - return; - } - - const unitId = this._univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC)?.getUnitId(); - if (unitId) { - this._docRenderController.reRender(unitId); - } - })); + [ + this._threadCommentPanelService.activeCommentId$, + this._threadCommentPanelService.hoveredCommentId$, + ].forEach((observable) => this.disposeWithMe(observable.pipe( + startWith(undefined), + pairwise() + ).subscribe(([previous, current]) => { + const currentUnitId = this._univerInstanceService + .getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC) + ?.getUnitId(); + new Set([previous?.unitId, current?.unitId, currentUnitId]).forEach((unitId) => { + if (unitId) { + this._docRenderController.reRender(unitId); + } + }); + }))); this.disposeWithMe(this._threadCommentModel.commentUpdate$.subscribe((update) => { if (update.type === 'resolve') { this._docRenderController.reRender(update.unitId); } + 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(); + 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(); + [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, index, customDecorations } = pos; - const activeComment = this._threadCommentPanelService.activeCommentId; - const { commentId, unitId: commentUnitID } = activeComment || {}; - const activeCustomDecoration = customDecorations.find((i) => i.id === commentId); + 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({ @@ -86,11 +221,14 @@ export class DocThreadCommentRenderController extends Disposable implements IRen }); } - const isActiveIndex = activeCustomDecoration && index >= activeCustomDecoration.startIndex && index <= activeCustomDecoration.endIndex; - const isActive = commentUnitID === unitId && data.id === commentId; + const isActive = focusedComments.some((focusedComment) => ( + focusedComment?.unitId === unitId + && focusedComment.subUnitId === DEFAULT_DOC_SUBUNIT_ID + && focusedComment.commentId === data.id + )); return next({ ...data, - active: isActive || isActiveIndex, + active: isActive, show: !comment.resolved, }); }, diff --git a/packages/docs-thread-comment-ui/src/controllers/ui.controller.ts b/packages/docs-thread-comment-ui/src/controllers/ui.controller.ts index 60d09b921e..8360d42ea9 100644 --- a/packages/docs-thread-comment-ui/src/controllers/ui.controller.ts +++ b/packages/docs-thread-comment-ui/src/controllers/ui.controller.ts @@ -20,6 +20,7 @@ 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, @@ -38,6 +39,7 @@ export class DocThreadCommentUIController extends Disposable { private _initCommands() { [ + AddDocDrawingCommentOperation, AddDocCommentComment, DeleteDocCommentComment, ShowCommentPanelOperation, diff --git a/packages/docs-thread-comment-ui/src/locale/ar-SA.ts b/packages/docs-thread-comment-ui/src/locale/ar-SA.ts index e350a7ba65..7055fc854f 100644 --- a/packages/docs-thread-comment-ui/src/locale/ar-SA.ts +++ b/packages/docs-thread-comment-ui/src/locale/ar-SA.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { panel: { title: 'إدارة التعليقات', addComment: 'إضافة تعليق', + openComments: 'فتح التعليقات', }, }, }; diff --git a/packages/docs-thread-comment-ui/src/locale/ca-ES.ts b/packages/docs-thread-comment-ui/src/locale/ca-ES.ts index 7837845440..0c79a007ad 100644 --- a/packages/docs-thread-comment-ui/src/locale/ca-ES.ts +++ b/packages/docs-thread-comment-ui/src/locale/ca-ES.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { panel: { title: 'Gestió de comentaris', addComment: 'Afegeix un comentari', + openComments: 'Obre els comentaris', }, }, }; diff --git a/packages/docs-thread-comment-ui/src/locale/de-DE.ts b/packages/docs-thread-comment-ui/src/locale/de-DE.ts index e53b54e14f..a43fc463f7 100644 --- a/packages/docs-thread-comment-ui/src/locale/de-DE.ts +++ b/packages/docs-thread-comment-ui/src/locale/de-DE.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { panel: { title: 'Kommentarverwaltung', addComment: 'Kommentar hinzufügen', + openComments: 'Kommentare öffnen', }, }, }; diff --git a/packages/docs-thread-comment-ui/src/locale/en-US.ts b/packages/docs-thread-comment-ui/src/locale/en-US.ts index 490f0f1b00..23bff0e330 100644 --- a/packages/docs-thread-comment-ui/src/locale/en-US.ts +++ b/packages/docs-thread-comment-ui/src/locale/en-US.ts @@ -19,6 +19,7 @@ const locale = { panel: { title: 'Comment Management', addComment: 'Add Comment', + openComments: 'Open Comments', }, }, }; diff --git a/packages/docs-thread-comment-ui/src/locale/es-ES.ts b/packages/docs-thread-comment-ui/src/locale/es-ES.ts index fb4c445774..8adf63825c 100644 --- a/packages/docs-thread-comment-ui/src/locale/es-ES.ts +++ b/packages/docs-thread-comment-ui/src/locale/es-ES.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { panel: { title: 'Gestión de comentarios', addComment: 'Añadir comentario', + openComments: 'Abrir comentarios', }, }, }; diff --git a/packages/docs-thread-comment-ui/src/locale/fa-IR.ts b/packages/docs-thread-comment-ui/src/locale/fa-IR.ts index 198cb01240..be4c81fe4d 100644 --- a/packages/docs-thread-comment-ui/src/locale/fa-IR.ts +++ b/packages/docs-thread-comment-ui/src/locale/fa-IR.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { panel: { title: 'مدیریت نظرات', addComment: 'افزودن نظر', + openComments: 'باز کردن نظرات', }, }, }; diff --git a/packages/docs-thread-comment-ui/src/locale/fr-FR.ts b/packages/docs-thread-comment-ui/src/locale/fr-FR.ts index 6736b936a7..147cb67097 100644 --- a/packages/docs-thread-comment-ui/src/locale/fr-FR.ts +++ b/packages/docs-thread-comment-ui/src/locale/fr-FR.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { panel: { title: 'Gestion des commentaires', addComment: 'Ajouter un commentaire', + openComments: 'Ouvrir les commentaires', }, }, }; diff --git a/packages/docs-thread-comment-ui/src/locale/id-ID.ts b/packages/docs-thread-comment-ui/src/locale/id-ID.ts index cc3890532a..8eba81de86 100644 --- a/packages/docs-thread-comment-ui/src/locale/id-ID.ts +++ b/packages/docs-thread-comment-ui/src/locale/id-ID.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { panel: { title: 'Manajemen Komentar', addComment: 'Tambah Komentar', + openComments: 'Buka Komentar', }, }, }; diff --git a/packages/docs-thread-comment-ui/src/locale/it-IT.ts b/packages/docs-thread-comment-ui/src/locale/it-IT.ts index a9f3159809..c3c2c5b78e 100644 --- a/packages/docs-thread-comment-ui/src/locale/it-IT.ts +++ b/packages/docs-thread-comment-ui/src/locale/it-IT.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { panel: { title: 'Gestione Commenti', addComment: 'Aggiungi Commento', + openComments: 'Apri commenti', }, }, }; diff --git a/packages/docs-thread-comment-ui/src/locale/ja-JP.ts b/packages/docs-thread-comment-ui/src/locale/ja-JP.ts index 61fa29b331..b40788defc 100644 --- a/packages/docs-thread-comment-ui/src/locale/ja-JP.ts +++ b/packages/docs-thread-comment-ui/src/locale/ja-JP.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { panel: { title: 'コメント管理', addComment: 'コメントを追加', + openComments: 'コメントを開く', }, }, }; diff --git a/packages/docs-thread-comment-ui/src/locale/ko-KR.ts b/packages/docs-thread-comment-ui/src/locale/ko-KR.ts index 92b2442b8f..75a105ff03 100644 --- a/packages/docs-thread-comment-ui/src/locale/ko-KR.ts +++ b/packages/docs-thread-comment-ui/src/locale/ko-KR.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { panel: { title: '댓글 관리', addComment: '댓글 추가', + openComments: '댓글 열기', }, }, }; diff --git a/packages/docs-thread-comment-ui/src/locale/pl-PL.ts b/packages/docs-thread-comment-ui/src/locale/pl-PL.ts index ad540bcd63..806ebb85e0 100644 --- a/packages/docs-thread-comment-ui/src/locale/pl-PL.ts +++ b/packages/docs-thread-comment-ui/src/locale/pl-PL.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { panel: { title: 'Zarządzanie komentarzami', addComment: 'Dodaj komentarz', + openComments: 'Otwórz komentarze', }, }, }; diff --git a/packages/docs-thread-comment-ui/src/locale/pt-BR.ts b/packages/docs-thread-comment-ui/src/locale/pt-BR.ts index 20df07b7f8..30c01e07b2 100644 --- a/packages/docs-thread-comment-ui/src/locale/pt-BR.ts +++ b/packages/docs-thread-comment-ui/src/locale/pt-BR.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { panel: { title: 'Gerenciamento de Comentários', addComment: 'Adicionar Comentário', + openComments: 'Abrir comentários', }, }, }; diff --git a/packages/docs-thread-comment-ui/src/locale/ru-RU.ts b/packages/docs-thread-comment-ui/src/locale/ru-RU.ts index 87fc049d84..cf94854d35 100644 --- a/packages/docs-thread-comment-ui/src/locale/ru-RU.ts +++ b/packages/docs-thread-comment-ui/src/locale/ru-RU.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { panel: { title: 'Управление комментариями', addComment: 'Добавить комментарий', + openComments: 'Открыть комментарии', }, }, }; diff --git a/packages/docs-thread-comment-ui/src/locale/sk-SK.ts b/packages/docs-thread-comment-ui/src/locale/sk-SK.ts index 06fc2655d6..5b15131d22 100644 --- a/packages/docs-thread-comment-ui/src/locale/sk-SK.ts +++ b/packages/docs-thread-comment-ui/src/locale/sk-SK.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { panel: { title: 'Správa komentárov', addComment: 'Pridať komentár', + openComments: 'Otvoriť komentáre', }, }, }; diff --git a/packages/docs-thread-comment-ui/src/locale/vi-VN.ts b/packages/docs-thread-comment-ui/src/locale/vi-VN.ts index 85bb300815..fc591da09b 100644 --- a/packages/docs-thread-comment-ui/src/locale/vi-VN.ts +++ b/packages/docs-thread-comment-ui/src/locale/vi-VN.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { panel: { title: 'Quản lý Bình luận', addComment: 'Thêm bình luận', + openComments: 'Mở bình luận', }, }, }; diff --git a/packages/docs-thread-comment-ui/src/locale/zh-CN.ts b/packages/docs-thread-comment-ui/src/locale/zh-CN.ts index 240308374a..23988f22ad 100644 --- a/packages/docs-thread-comment-ui/src/locale/zh-CN.ts +++ b/packages/docs-thread-comment-ui/src/locale/zh-CN.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { panel: { title: '评论管理', addComment: '添加评论', + openComments: '打开评论', }, }, }; diff --git a/packages/docs-thread-comment-ui/src/locale/zh-HK.ts b/packages/docs-thread-comment-ui/src/locale/zh-HK.ts index 3feb5c5d45..3d6b83650d 100644 --- a/packages/docs-thread-comment-ui/src/locale/zh-HK.ts +++ b/packages/docs-thread-comment-ui/src/locale/zh-HK.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { panel: { title: '評論管理', addComment: '新增評論', + openComments: '開啟評論', }, }, }; diff --git a/packages/docs-thread-comment-ui/src/locale/zh-TW.ts b/packages/docs-thread-comment-ui/src/locale/zh-TW.ts index 3feb5c5d45..3d6b83650d 100644 --- a/packages/docs-thread-comment-ui/src/locale/zh-TW.ts +++ b/packages/docs-thread-comment-ui/src/locale/zh-TW.ts @@ -21,6 +21,7 @@ const locale: typeof enUS = { panel: { title: '評論管理', addComment: '新增評論', + openComments: '開啟評論', }, }, }; diff --git a/packages/docs-thread-comment-ui/src/menu/menu.ts b/packages/docs-thread-comment-ui/src/menu/menu.ts index 603776a77d..1b38f56302 100644 --- a/packages/docs-thread-comment-ui/src/menu/menu.ts +++ b/packages/docs-thread-comment-ui/src/menu/menu.ts @@ -23,10 +23,22 @@ import { DocumentEditArea, IRenderManagerService, withCurrentTypeOfRenderer } fr import { getMenuHiddenObservable, MenuItemType } from '@univerjs/ui'; import { debounceTime, Observable } from 'rxjs'; import { + AddDocDrawingCommentOperation, StartAddCommentOperation, ToggleCommentPanelOperation, } from '../commands/operations/show-comment-panel.operation'; +export function AddDocDrawingCommentMenuItemFactory(accessor: IAccessor): IMenuButtonItem { + 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), + }; +} + export const shouldDisableAddComment = (accessor: IAccessor) => { const renderManagerService = accessor.get(IRenderManagerService); const docSelectionManagerService = accessor.get(DocSelectionManagerService); @@ -55,7 +67,7 @@ export function AddDocCommentMenuItemFactory(accessor: IAccessor): IMenuButtonIt return { id: StartAddCommentOperation.id, type: MenuItemType.BUTTON, - icon: 'CommentIcon', + 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), @@ -77,8 +89,8 @@ export function ToolbarDocCommentMenuItemFactory(accessor: IAccessor): IMenuButt id: ToggleCommentPanelOperation.id, type: MenuItemType.BUTTON, icon: 'CommentIcon', - title: 'docs-thread-comment-ui.panel.addComment', - tooltip: 'docs-thread-comment-ui.panel.addComment', + title: 'docs-thread-comment-ui.panel.openComments', + tooltip: 'docs-thread-comment-ui.panel.openComments', hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC), }; } diff --git a/packages/docs-thread-comment-ui/src/menu/schema.ts b/packages/docs-thread-comment-ui/src/menu/schema.ts index 5b4ffdd0b6..cf710a784a 100644 --- a/packages/docs-thread-comment-ui/src/menu/schema.ts +++ b/packages/docs-thread-comment-ui/src/menu/schema.ts @@ -16,19 +16,26 @@ import type { MenuSchemaType } from '@univerjs/ui'; import { FLOAT_TOOLBAR_MENU_POSITION } from '@univerjs/docs-ui'; -import { ContextMenuGroup, ContextMenuPosition, RibbonInsertGroup } from '@univerjs/ui'; +import { ContextMenuGroup, ContextMenuPosition, FloatingObjectToolbarPosition, RibbonInsertGroup } from '@univerjs/ui'; import { + AddDocDrawingCommentOperation, StartAddCommentOperation, ToggleCommentPanelOperation, } from '../commands/operations/show-comment-panel.operation'; -import { AddDocCommentMenuItemFactory, ToolbarDocCommentMenuItemFactory } from './menu'; +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]: { @@ -36,6 +43,12 @@ export const menuSchema: MenuSchemaType = { menuItemFactory: AddDocCommentMenuItemFactory, }, }, + [FloatingObjectToolbarPosition.DOC]: { + [AddDocDrawingCommentOperation.id]: { + order: 10, + menuItemFactory: AddDocDrawingCommentMenuItemFactory, + }, + }, [ContextMenuPosition.MAIN_AREA]: { [ContextMenuGroup.DATA]: { [StartAddCommentOperation.id]: { @@ -44,4 +57,12 @@ export const menuSchema: MenuSchemaType = { }, }, }, + [ContextMenuPosition.DRAWING]: { + [ContextMenuGroup.DATA]: { + [AddDocDrawingCommentOperation.id]: { + order: 1, + menuItemFactory: AddDocDrawingCommentMenuItemFactory, + }, + }, + }, }; diff --git a/packages/docs-thread-comment-ui/src/plugin.ts b/packages/docs-thread-comment-ui/src/plugin.ts index 16e7c1cf3e..01f8494ca5 100644 --- a/packages/docs-thread-comment-ui/src/plugin.ts +++ b/packages/docs-thread-comment-ui/src/plugin.ts @@ -14,11 +14,12 @@ * 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 { 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'; @@ -31,9 +32,17 @@ import { DocThreadCommentRenderController } from './controllers/render-controlle import { DocThreadCommentUIController } from './controllers/ui.controller'; import { DocThreadCommentService } from './services/doc-thread-comment.service'; +const STARTING_DEPENDENCIES: Array[0]> = [ + [DocThreadCommentUIController], + [DocThreadCommentSelectionController], + [DocThreadCommentService], +]; + @DependentOn( UniverDocsPlugin, + UniverDocsThreadCommentPlugin, UniverThreadCommentPlugin, + UniverDrawingPlugin, UniverRenderEnginePlugin, UniverDocsUIPlugin, UniverThreadCommentUIPlugin @@ -67,11 +76,7 @@ export class UniverDocsThreadCommentUIPlugin extends Plugin { override onStarting(): void { this._injector.add([ComponentsController]); this._injector.get(ComponentsController); - ([ - [DocThreadCommentUIController], - [DocThreadCommentSelectionController], - [DocThreadCommentService], - ] as Dependency[]).forEach((dep) => { + STARTING_DEPENDENCIES.forEach((dep) => { this._injector.add(dep); }); } @@ -84,8 +89,6 @@ export class UniverDocsThreadCommentUIPlugin extends Plugin { } private _initRenderModule() { - [DocThreadCommentRenderController].forEach((dep) => { - this._renderManagerSrv.registerRenderModule(UniverInstanceType.UNIVER_DOC, dep as unknown as Dependency); - }); + this._renderManagerSrv.registerRenderModule(UniverInstanceType.UNIVER_DOC, [DocThreadCommentRenderController]); } } diff --git a/packages/docs-thread-comment-ui/src/views/DocThreadCommentPanel.tsx b/packages/docs-thread-comment-ui/src/views/DocThreadCommentPanel.tsx index 26a6da99c1..1dd87ecf29 100644 --- a/packages/docs-thread-comment-ui/src/views/DocThreadCommentPanel.tsx +++ b/packages/docs-thread-comment-ui/src/views/DocThreadCommentPanel.tsx @@ -17,25 +17,35 @@ 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, isInternalEditorID, IUniverInstanceService, UniverInstanceType } from '@univerjs/core'; +import { ICommandService, Injector, isInternalEditorID, IUniverInstanceService, UniverInstanceType, UserManagerService } from '@univerjs/core'; import { DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs'; -import { ThreadCommentPanel } from '@univerjs/thread-comment-ui'; +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, 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 { DEFAULT_DOC_SUBUNIT_ID } from '../common/const'; 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$(UniverInstanceType.UNIVER_DOC).pipe(filter((doc) => !!doc && !isInternalEditorID(doc.getUnitId()))), [univerInstanceService]); const doc = useObservable(doc$); - const subUnitId$ = useMemo(() => new Observable((sub) => sub.next(DEFAULT_DOC_SUBUNIT_ID)), []); + const draftService = useDependency(ThreadCommentDraftService); + const drawingDraft = useObservable(draftService.draft$, draftService.draft); + const subUnitId$ = useMemo( + () => new Observable((sub) => sub.next(getDocCommentPanelSubUnitId(drawingDraft, doc?.getUnitId()))), + [doc, drawingDraft] + ); const docSelectionManagerService = useDependency(DocSelectionManagerService); const selectionChange$ = useMemo( () => docSelectionManagerService.textSelection$.pipe(debounceTime(16)), @@ -48,8 +58,35 @@ export const DocThreadCommentPanel = () => { [injector, selectionChange$] ); const commandService = useDependency(ICommandService); + const threadCommentModel = useDependency(ThreadCommentModel); + useObservable(threadCommentModel.commentUpdate$); const docCommentService = useDependency(DocThreadCommentService); - const tempComment = useObservable(docCommentService.addingComment$); + 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([]); useEffect(() => { @@ -94,33 +131,48 @@ export const DocThreadCommentPanel = () => { getSubUnitName={() => ''} disableAdd={disableAdd} tempComment={tempComment} - onAddComment={(comment) => { + onAddComment={async (comment) => { + if (drawingTempComment && !comment.parentId) { + return true; + } // attach an comment to an custom-range if (!comment.parentId) { const params: IAddDocCommentComment = { unitId, - range: tempComment!, + range: textTempComment!, comment, }; - commandService.executeCommand(AddDocCommentComment.id, params); + const success = await commandService.executeCommand(AddDocCommentComment.id, params); + if (!success) { + throw new Error('Failed to add document comment.'); + } docCommentService.endAdd(); return false; } return true; }} - onDeleteComment={(comment) => { + onAfterDeleteComment={async (comment) => { if (!comment.parentId) { + if (isDrawingComment(comment)) { + return; + } const params: IDeleteDocCommentComment = { unitId, commentId: comment.id, }; - commandService.executeCommand(DeleteDocCommentComment.id, params); - return false; + await commandService.executeCommand(DeleteDocCommentComment.id, params); } - return true; }} - showComments={commentIds} + showComments={[ + ...commentIds, + ...drawingCommentIds, + ]} + onTempCommentClose={() => draftService.cancel()} + formatRef={(comment) => { + const anchor = deserializeThreadCommentAnchor(comment.ref); + return anchor?.kind === ThreadCommentAnchorKind.DOC_DRAWING ? `#${anchor.elementId}` : comment.ref; + }} /> ); }; diff --git a/packages/docs-thread-comment-ui/src/views/__tests__/DocThreadCommentPanel.spec.tsx b/packages/docs-thread-comment-ui/src/views/__tests__/DocThreadCommentPanel.spec.tsx index 812cdcbd1f..71b11052a9 100644 --- a/packages/docs-thread-comment-ui/src/views/__tests__/DocThreadCommentPanel.spec.tsx +++ b/packages/docs-thread-comment-ui/src/views/__tests__/DocThreadCommentPanel.spec.tsx @@ -31,18 +31,18 @@ import { 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, ThreadCommentPanelService } from '@univerjs/thread-comment-ui'; +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 { DEFAULT_DOC_SUBUNIT_ID } from '../../common/const'; import { DocThreadCommentService } from '../../services/doc-thread-comment.service'; -import { DocThreadCommentPanel } from '../DocThreadCommentPanel'; +import { DocThreadCommentPanel, getDocCommentPanelSubUnitId } from '../DocThreadCommentPanel'; const DOC_ID = 'doc-thread-comment-panel-test'; @@ -179,6 +179,7 @@ function createPanelTestBed(decorationIds: string[]) { injector.add([IThreadCommentDataSourceService, { useClass: ThreadCommentDataSourceService }]); injector.add([ThreadCommentModel]); injector.add([ISidebarService, { useClass: TestSidebarService as never }]); + injector.add([ThreadCommentDraftService]); injector.add([ThreadCommentPanelService]); injector.add([DocThreadCommentService]); @@ -256,4 +257,11 @@ describe('DocThreadCommentPanel', () => { 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); + }); }); diff --git a/packages/docs-thread-comment/README.md b/packages/docs-thread-comment/README.md new file mode 100644 index 0000000000..1ec6267c35 --- /dev/null +++ b/packages/docs-thread-comment/README.md @@ -0,0 +1,39 @@ +# @univerjs/docs-thread-comment + +`@univerjs/docs-thread-comment` provides model commands and Facade APIs for comments anchored to fixed document text ranges. It has no UI or rendering dependency and can be used in Node environments. + +## Package Overview + +| Package | UMD global | CSS | Locales | Facade entry | +| --- | --- | :---: | :---: | :---: | +| `@univerjs/docs-thread-comment` | `UniverDocsThreadComment` | No | No | Yes | + +## Installation + +```sh +pnpm add @univerjs/docs-thread-comment +# or +npm install @univerjs/docs-thread-comment +``` + +Keep all `@univerjs/*` packages on the same version. + +## Usage + +```ts +import { UniverDocsThreadCommentPlugin } from '@univerjs/docs-thread-comment'; +import '@univerjs/docs-thread-comment/facade'; + +univer.registerPlugin(UniverDocsThreadCommentPlugin); + +const range = univerAPI.getActiveDocument()?.getTextRange(0, 12); +await range?.createCommentAsync('Verify this introduction.', { id: 'review-intro' }); +``` + +Use `@univerjs/docs-thread-comment-ui` separately when menus, canvas decorations, or the shared side panel are required. + +## Resources + +- [Documentation](https://docs.univer.ai) +- [NPM package](https://npmjs.com/package/@univerjs/docs-thread-comment) +- [GitHub repository](https://github.com/dream-num/univer) diff --git a/packages/docs-thread-comment/package.json b/packages/docs-thread-comment/package.json new file mode 100644 index 0000000000..284bebe763 --- /dev/null +++ b/packages/docs-thread-comment/package.json @@ -0,0 +1,87 @@ +{ + "name": "@univerjs/docs-thread-comment", + "version": "1.0.0-beta.2", + "private": false, + "description": "Thread comment model integration and Facade APIs for Univer Docs.", + "author": "DreamNum Co., Ltd. ", + "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" + ], + "exports": { + ".": "./src/index.ts", + "./facade": "./src/facade/index.ts", + "./*": "./src/*" + }, + "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" + }, + "./facade": { + "import": "./lib/es/facade.js", + "require": "./lib/cjs/facade.js", + "types": "./lib/types/facade/index.d.ts" + }, + "./lib/facade": { + "import": "./lib/es/facade.js", + "require": "./lib/cjs/facade.js", + "types": "./lib/types/facade/index.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" + }, + "dependencies": { + "@univerjs/core": "workspace:*", + "@univerjs/docs": "workspace:*", + "@univerjs/thread-comment": "workspace:*" + }, + "devDependencies": { + "@univerjs-infra/shared": "workspace:*", + "rxjs": "^7.8.2", + "typescript": "^6.0.3", + "vitest": "^4.1.10" + } +} diff --git a/packages/docs-thread-comment/src/commands/commands/__tests__/create-doc-text-range-comment.command.spec.ts b/packages/docs-thread-comment/src/commands/commands/__tests__/create-doc-text-range-comment.command.spec.ts new file mode 100644 index 0000000000..f94e7da9c5 --- /dev/null +++ b/packages/docs-thread-comment/src/commands/commands/__tests__/create-doc-text-range-comment.command.spec.ts @@ -0,0 +1,113 @@ +/** + * 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 { + + ICommandService, + + IUniverInstanceService, + Univer, + UniverInstanceType, +} from '@univerjs/core'; +import { FDocument } from '@univerjs/docs/facade'; +import { + AddCommentMutation, + IThreadCommentDataSourceService, + ThreadCommentDataSourceService, + ThreadCommentFacadeService, + ThreadCommentModel, +} from '@univerjs/thread-comment'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { DEFAULT_DOC_SUBUNIT_ID } from '../../../common/const'; +import { + AddDocCommentDecorationMutation, + CreateDocTextRangeCommentCommand, +} from '../create-doc-text-range-comment.command'; +import '@univerjs/docs-thread-comment/facade'; + +const DOC_ID = 'headless-doc-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, + }, + }; +} + +describe('CreateDocTextRangeCommentCommand', () => { + let univer: Univer; + + beforeEach(() => { + univer = new Univer(); + const injector = univer.__getInjector(); + injector.add([IThreadCommentDataSourceService, { useClass: ThreadCommentDataSourceService }]); + injector.add([ThreadCommentModel]); + injector.add([ThreadCommentFacadeService]); + univer.createUnit(UniverInstanceType.UNIVER_DOC, createDocData()); + + const commandService = injector.get(ICommandService); + commandService.registerCommand(CreateDocTextRangeCommentCommand); + commandService.registerCommand(AddCommentMutation); + commandService.registerCommand(AddDocCommentDecorationMutation); + }); + + afterEach(() => univer.dispose()); + + it('creates and queries a fixed text comment without UI services', async () => { + const injector = univer.__getInjector(); + const commandService = injector.get(ICommandService); + await expect(commandService.executeCommand(CreateDocTextRangeCommentCommand.id, { + unitId: DOC_ID, + range: { startOffset: 0, endOffset: 5 }, + content: 'Check the greeting.', + id: 'agent-comment-1', + personId: 'agent-user-1', + })).resolves.toBe(true); + + const documentModel = injector.get(IUniverInstanceService) + .getUnit(DOC_ID, UniverInstanceType.UNIVER_DOC); + if (!documentModel) { + throw new Error('Test document was not created.'); + } + const range = injector.createInstance(FDocument, documentModel).getTextRange(0, 5); + expect(range.getComments()).toHaveLength(1); + expect(injector.get(ThreadCommentModel).getThread( + DOC_ID, + DEFAULT_DOC_SUBUNIT_ID, + 'agent-comment-1' + )?.root).toMatchObject({ + ref: 'Hello', + startOffset: 0, + endOffset: 5, + personId: 'agent-user-1', + }); + }); +}); diff --git a/packages/docs-thread-comment/src/commands/commands/create-doc-text-range-comment.command.ts b/packages/docs-thread-comment/src/commands/commands/create-doc-text-range-comment.command.ts new file mode 100644 index 0000000000..67860568d6 --- /dev/null +++ b/packages/docs-thread-comment/src/commands/commands/create-doc-text-range-comment.command.ts @@ -0,0 +1,206 @@ +/** + * 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, IAccessor, ICommand, IMutation, IMutationInfo, ITextRangeParam, JSONXActions } from '@univerjs/core'; +import type { IThreadComment, ThreadCommentContent } from '@univerjs/thread-comment'; +import { + BuildTextUtils, + CommandType, + CustomDecorationType, + + generateRandomId, + + ICommandService, + + IUniverInstanceService, + JSONX, + + sequenceExecute, + UniverInstanceType, + UserManagerService, +} from '@univerjs/core'; +import { + AddCommentMutation, + getDT, + + IThreadCommentDataSourceService, + normalizeThreadCommentContent, + +} from '@univerjs/thread-comment'; +import { DEFAULT_DOC_SUBUNIT_ID } from '../../common/const'; + +export interface IAddDocTextRangeCommentParams { + unitId: string; + comment: IThreadComment; + range: ITextRangeParam; +} + +/** Parameters accepted by the document text-range Facade. */ +export interface ICreateDocTextRangeCommentParams { + unitId: string; + range: ITextRangeParam; + content: ThreadCommentContent; + attachments?: string[]; + id?: string; + threadId?: string; + personId?: string; + dateTime?: Date; +} + +interface IValidTextRange extends ITextRangeParam { + startOffset: number; + endOffset: number; +} + +export interface IPreparedDocTextRangeComment { + comment: IThreadComment; + commentMutation: IMutationInfo; + decorationMutationParams: IDocCommentDecorationMutationParams; +} + +export interface IDocCommentDecorationMutationParams { + unitId: string; + actions: JSONXActions; + segmentId?: string; +} + +/** Applies document comment decoration actions without render or selection services. */ +export const AddDocCommentDecorationMutation: IMutation< + IDocCommentDecorationMutationParams, + IDocCommentDecorationMutationParams | false +> = { + id: 'docs-thread-comment.mutation.add-decoration', + type: CommandType.MUTATION, + handler(accessor, params) { + if (!params || JSONX.isNoop(params.actions)) { + return false; + } + const document = accessor.get(IUniverInstanceService) + .getUnit(params.unitId, UniverInstanceType.UNIVER_DOC); + if (!document) { + return false; + } + const undoActions = JSONX.invertWithDoc(params.actions, document.getSnapshot()); + document.apply(params.actions); + return { ...params, actions: undoActions }; + }, +}; + +function getValidRange(accessor: IAccessor, unitId: string, range: ITextRangeParam): IValidTextRange | null { + const { startOffset, endOffset } = range; + if (!Number.isInteger(startOffset) || !Number.isInteger(endOffset) || startOffset < 0 || endOffset <= startOffset) { + return null; + } + + const document = accessor.get(IUniverInstanceService).getUnit(unitId, UniverInstanceType.UNIVER_DOC); + const body = document?.getSelfOrHeaderFooterModel(range.segmentId)?.getBody(); + if (!body || endOffset > body.dataStream.length) { + return null; + } + + return { ...range, startOffset, endOffset, collapsed: false }; +} + +/** Builds the model mutations shared by headless and UI document comment commands. */ +export async function prepareDocTextRangeComment( + accessor: IAccessor, + params: IAddDocTextRangeCommentParams +): Promise { + const range = getValidRange(accessor, params.unitId, params.range); + if (!range) { + return null; + } + + const savedComment = await accessor.get(IThreadCommentDataSourceService).addComment(params.comment); + const comment: IThreadComment = { + ...params.comment, + ...savedComment, + unitId: params.unitId, + subUnitId: DEFAULT_DOC_SUBUNIT_ID, + startOffset: range.startOffset, + endOffset: range.endOffset, + segmentId: range.segmentId, + collapsed: false, + }; + const textX = BuildTextUtils.customDecoration.add({ + ranges: [range], + id: comment.id, + type: CustomDecorationType.COMMENT, + }); + const decorationMutationParams: IDocCommentDecorationMutationParams = { + unitId: params.unitId, + actions: JSONX.getInstance().editOp(textX.serialize()), + segmentId: range.segmentId, + }; + + return { + comment, + commentMutation: { + id: AddCommentMutation.id, + params: { unitId: params.unitId, subUnitId: DEFAULT_DOC_SUBUNIT_ID, comment }, + }, + decorationMutationParams, + }; +} + +/** + * Creates a root comment and document decoration for an explicit text range. + * This model command is safe to execute without loading document UI packages. + */ +export const CreateDocTextRangeCommentCommand: ICommand = { + id: 'docs.command.create-text-range-comment', + type: CommandType.COMMAND, + async handler(accessor, params) { + if (!params) { + return false; + } + + const range = getValidRange(accessor, params.unitId, params.range); + if (!range) { + return false; + } + const document = accessor.get(IUniverInstanceService).getUnit(params.unitId, UniverInstanceType.UNIVER_DOC); + const body = document?.getSelfOrHeaderFooterModel(range.segmentId)?.getBody(); + if (!body) { + return false; + } + + const id = params.id ?? generateRandomId(); + const comment: IThreadComment = { + id, + threadId: params.threadId ?? id, + unitId: params.unitId, + subUnitId: DEFAULT_DOC_SUBUNIT_ID, + ref: BuildTextUtils.transform.getPlainText(body.dataStream.slice(range.startOffset, range.endOffset)), + text: normalizeThreadCommentContent(params.content), + attachments: params.attachments ?? [], + dT: getDT(params.dateTime), + personId: params.personId ?? accessor.get(UserManagerService).getCurrentUser().userID, + startOffset: range.startOffset, + endOffset: range.endOffset, + segmentId: range.segmentId, + collapsed: false, + }; + const prepared = await prepareDocTextRangeComment(accessor, { unitId: params.unitId, range, comment }); + if (!prepared) { + return false; + } + return (await sequenceExecute([ + prepared.commentMutation, + { id: AddDocCommentDecorationMutation.id, params: prepared.decorationMutationParams }, + ], accessor.get(ICommandService))).result; + }, +}; diff --git a/packages/docs-thread-comment/src/common/const.ts b/packages/docs-thread-comment/src/common/const.ts new file mode 100644 index 0000000000..7ee071ec31 --- /dev/null +++ b/packages/docs-thread-comment/src/common/const.ts @@ -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. + */ + +/** Stable subunit used by document text comments. */ +export const DEFAULT_DOC_SUBUNIT_ID = 'default_doc'; + +export const DOCS_THREAD_COMMENT_PLUGIN_NAME = 'DOC_THREAD_COMMENT_PLUGIN'; diff --git a/packages/docs-thread-comment/src/config/config.ts b/packages/docs-thread-comment/src/config/config.ts new file mode 100644 index 0000000000..82633dbef9 --- /dev/null +++ b/packages/docs-thread-comment/src/config/config.ts @@ -0,0 +1,24 @@ +/** + * 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_PLUGIN_CONFIG_KEY = 'docs-thread-comment.config'; + +export const configSymbol = Symbol(DOCS_THREAD_COMMENT_PLUGIN_CONFIG_KEY); + +export interface IUniverDocsThreadCommentConfig { +} + +export const defaultPluginConfig: IUniverDocsThreadCommentConfig = {}; diff --git a/packages/docs-thread-comment/src/facade/f-document-text-range.ts b/packages/docs-thread-comment/src/facade/f-document-text-range.ts new file mode 100644 index 0000000000..e00de8eba9 --- /dev/null +++ b/packages/docs-thread-comment/src/facade/f-document-text-range.ts @@ -0,0 +1,130 @@ +/** + * 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 { Injector } from '@univerjs/core'; +import { CustomDecorationType, ICommandService } from '@univerjs/core'; +import * as DocsThreadComment from '@univerjs/docs-thread-comment'; +import * as DocsFacade from '@univerjs/docs/facade'; +import * as ThreadComment from '@univerjs/thread-comment'; + +export type IDocumentTextRangeCommentCreateOptions = Omit< + DocsThreadComment.ICreateDocTextRangeCommentParams, + 'unitId' | 'range' | 'content' +>; + +/** Comment methods added to a fixed document text range. */ +export interface IFDocumentTextRangeThreadCommentMixin { + /** + * Creates a comment on this text range. + * @param content Plain text or a Univer document body for rich comment content. + * @param options Optional stable IDs, author, attachments, and creation time. + * @returns `true` when the create command succeeds; otherwise, `false`. + * @throws {TypeError} If the content is empty. + * @example + * ```ts + * const range = univerAPI.getActiveDocument()?.getTextRange(0, 12); + * await range?.createCommentAsync('Verify this introduction.', { id: 'review-intro' }); + * ``` + */ + createCommentAsync( + content: ThreadComment.ThreadCommentContent, + options?: IDocumentTextRangeCommentCreateOptions + ): Promise; + /** + * Returns locally loaded comments whose comment decorations overlap this text range. + * @returns Matching comment threads. The returned anchors use `DOC_TEXT_RANGE`. + * @example + * ```ts + * const range = univerAPI.getActiveDocument()?.getTextRange(0, 12); + * const comments = range?.getComments() ?? []; + * comments.forEach(({ root, children }) => console.log(root.id, children.length)); + * ``` + */ + getComments(): ThreadComment.IFacadeThreadCommentInfo[]; + /** + * Synchronizes known document threads and returns comments whose decorations overlap this text range. + * @returns A promise resolving to the synchronized matching comment threads. + * @example + * ```ts + * const range = univerAPI.getActiveDocument()?.getTextRange(0, 12); + * const comments = range ? await range.listCommentsAsync() : []; + * console.log(comments.length); + * ``` + */ + listCommentsAsync(): Promise; +} + +export class FDocumentTextRangeThreadCommentMixin extends DocsFacade.FDocumentTextRange implements IFDocumentTextRangeThreadCommentMixin { + declare private _threadCommentCommandService: ICommandService; + declare private _threadCommentFacadeService: ThreadComment.ThreadCommentFacadeService; + + override _initialize(injector: Injector): void { + this._threadCommentCommandService = injector.get(ICommandService); + let commentService: ThreadComment.ThreadCommentFacadeService | undefined; + Object.defineProperty(this, '_threadCommentFacadeService', { + get: () => commentService ??= injector.get(ThreadComment.ThreadCommentFacadeService), + }); + } + + /** @inheritdoc */ + override createCommentAsync( + content: ThreadComment.ThreadCommentContent, + options: IDocumentTextRangeCommentCreateOptions = {} + ): Promise { + return this._threadCommentCommandService.executeCommand(DocsThreadComment.CreateDocTextRangeCommentCommand.id, { + ...options, + unitId: this._document.getId(), + range: { ...this.getRange(), collapsed: false }, + content, + }); + } + + /** @inheritdoc */ + override getComments(): ThreadComment.IFacadeThreadCommentInfo[] { + const range = this.getRange(); + const commentIds = this._getOverlappingCommentIds(range); + return this._threadCommentFacadeService.getComments({ + unitIds: [this._document.getId()], + subUnitIds: [DocsThreadComment.DEFAULT_DOC_SUBUNIT_ID], + anchorKinds: [ThreadComment.ThreadCommentAnchorKind.DOC_TEXT_RANGE], + }).filter((comment) => commentIds.has(comment.root.id)); + } + + /** @inheritdoc */ + override async listCommentsAsync(): Promise { + const range = this.getRange(); + const commentIds = this._getOverlappingCommentIds(range); + const comments = await this._threadCommentFacadeService.listCommentsAsync({ + unitIds: [this._document.getId()], + subUnitIds: [DocsThreadComment.DEFAULT_DOC_SUBUNIT_ID], + anchorKinds: [ThreadComment.ThreadCommentAnchorKind.DOC_TEXT_RANGE], + }); + return comments.filter((comment) => commentIds.has(comment.root.id)); + } + + private _getOverlappingCommentIds(range: DocsFacade.IFDocumentTextRange): Set { + const decorations = this._document.getBody(range.segmentId).customDecorations ?? []; + return new Set(decorations.filter((decoration) => decoration.type === CustomDecorationType.COMMENT + && decoration.startIndex < range.endOffset + && decoration.endIndex >= range.startOffset).map((decoration) => decoration.id)); + } +} + +DocsFacade.FDocumentTextRange.extend(FDocumentTextRangeThreadCommentMixin); + +declare module '@univerjs/docs/facade' { + interface FDocumentTextRange extends IFDocumentTextRangeThreadCommentMixin {} +} diff --git a/packages/docs-thread-comment/src/facade/index.ts b/packages/docs-thread-comment/src/facade/index.ts new file mode 100644 index 0000000000..ee5e75e847 --- /dev/null +++ b/packages/docs-thread-comment/src/facade/index.ts @@ -0,0 +1,22 @@ +/** + * 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 './f-document-text-range'; + +export type { + IDocumentTextRangeCommentCreateOptions, + IFDocumentTextRangeThreadCommentMixin, +} from './f-document-text-range'; diff --git a/packages/docs-thread-comment/src/index.ts b/packages/docs-thread-comment/src/index.ts new file mode 100644 index 0000000000..e76ae6360c --- /dev/null +++ b/packages/docs-thread-comment/src/index.ts @@ -0,0 +1,30 @@ +/** + * 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 { + AddDocCommentDecorationMutation, + CreateDocTextRangeCommentCommand, + prepareDocTextRangeComment, +} from './commands/commands/create-doc-text-range-comment.command'; +export type { + IAddDocTextRangeCommentParams, + ICreateDocTextRangeCommentParams, + IDocCommentDecorationMutationParams, + IPreparedDocTextRangeComment, +} from './commands/commands/create-doc-text-range-comment.command'; +export { DEFAULT_DOC_SUBUNIT_ID } from './common/const'; +export type { IUniverDocsThreadCommentConfig } from './config/config'; +export { UniverDocsThreadCommentPlugin } from './plugin'; diff --git a/packages/docs-thread-comment/src/plugin.ts b/packages/docs-thread-comment/src/plugin.ts new file mode 100644 index 0000000000..248de11463 --- /dev/null +++ b/packages/docs-thread-comment/src/plugin.ts @@ -0,0 +1,65 @@ +/** + * 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 { IUniverDocsThreadCommentConfig } from './config/config'; +import { + DependentOn, + ICommandService, + IConfigService, + Inject, + Injector, + merge, + Plugin, + UniverInstanceType, +} from '@univerjs/core'; +import { UniverDocsPlugin } from '@univerjs/docs'; +import { UniverThreadCommentPlugin } from '@univerjs/thread-comment'; +import pkg from '../package.json'; +import { + AddDocCommentDecorationMutation, + CreateDocTextRangeCommentCommand, +} from './commands/commands/create-doc-text-range-comment.command'; +import { DOCS_THREAD_COMMENT_PLUGIN_NAME } from './common/const'; +import { defaultPluginConfig, DOCS_THREAD_COMMENT_PLUGIN_CONFIG_KEY } from './config/config'; + +@DependentOn(UniverDocsPlugin, UniverThreadCommentPlugin) +export class UniverDocsThreadCommentPlugin extends Plugin { + static override pluginName = DOCS_THREAD_COMMENT_PLUGIN_NAME; + static override packageName = pkg.name; + static override version = pkg.version; + static override type = UniverInstanceType.UNIVER_DOC; + + constructor( + private readonly _config: Partial = defaultPluginConfig, + @Inject(Injector) protected override _injector: Injector, + @IConfigService private readonly _configService: IConfigService, + @ICommandService private readonly _commandService: ICommandService + ) { + super(); + + const { ...rest } = merge( + {}, + defaultPluginConfig, + this._config + ); + this._configService.setConfig(DOCS_THREAD_COMMENT_PLUGIN_CONFIG_KEY, rest); + } + + override onStarting(): void { + this.disposeWithMe(this._commandService.registerCommand(CreateDocTextRangeCommentCommand)); + this.disposeWithMe(this._commandService.registerCommand(AddDocCommentDecorationMutation)); + } +} diff --git a/packages/docs-thread-comment/tsconfig.json b/packages/docs-thread-comment/tsconfig.json new file mode 100644 index 0000000000..d4f9172636 --- /dev/null +++ b/packages/docs-thread-comment/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "@univerjs-infra/shared/tsconfigs/base", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": ["src"] +} diff --git a/packages/docs-thread-comment/tsconfig.node.json b/packages/docs-thread-comment/tsconfig.node.json new file mode 100644 index 0000000000..2abfa07367 --- /dev/null +++ b/packages/docs-thread-comment/tsconfig.node.json @@ -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" + ] +} diff --git a/packages/docs-thread-comment/vitest.config.ts b/packages/docs-thread-comment/vitest.config.ts new file mode 100644 index 0000000000..af97c6d7a2 --- /dev/null +++ b/packages/docs-thread-comment/vitest.config.ts @@ -0,0 +1,3 @@ +import createConfig from '@univerjs-infra/shared/vitest'; + +export default createConfig(); diff --git a/packages/docs-ui/src/controllers/render-controllers/__tests__/doc-contextmenu.render-controller.spec.ts b/packages/docs-ui/src/controllers/render-controllers/__tests__/doc-contextmenu.render-controller.spec.ts index 42324f700e..6a95201c71 100644 --- a/packages/docs-ui/src/controllers/render-controllers/__tests__/doc-contextmenu.render-controller.spec.ts +++ b/packages/docs-ui/src/controllers/render-controllers/__tests__/doc-contextmenu.render-controller.spec.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { DocumentBlockRangeType, UniverInstanceType } from '@univerjs/core'; +import { DocumentBlockRangeType, FOCUSING_COMMON_DRAWINGS, UniverInstanceType } from '@univerjs/core'; import { RichTextEditingMutation } from '@univerjs/docs'; import { ContextMenuPosition } from '@univerjs/ui'; import { describe, expect, it, vi } from 'vitest'; @@ -38,6 +38,7 @@ function createController(options?: { textRanges?: Array<{ startOffset: number; endOffset: number }>; blockRanges?: Array<{ startIndex: number; endIndex: number; blockType: DocumentBlockRangeType }>; menuVisible?: boolean; + focusingDrawing?: boolean; }) { const onPointerDown$ = createEventSubject(); let commandHandler: ((command: { id: string }) => void) | undefined; @@ -60,6 +61,9 @@ function createController(options?: { } as never, { isPointerOnNonChecklistBullet: vi.fn(() => options?.pointerOnBullet ?? false) } as never, { getTextRanges: vi.fn(() => options?.textRanges ?? []) } as never, + { + getContextValue: vi.fn((key) => key === FOCUSING_COMMON_DRAWINGS && (options?.focusingDrawing ?? false)), + } as never, { getCurrentUnitOfType: vi.fn((type) => type === UniverInstanceType.UNIVER_DOC ? { getBody: () => ({ blockRanges: options?.blockRanges ?? [] }) } @@ -81,6 +85,16 @@ describe('DocContextMenuRenderController', () => { controller.dispose(); }); + it('opens the drawing context menu when a drawing is focused', () => { + const { controller, onPointerDown$, contextMenuService } = createController({ focusingDrawing: true }); + const event = { button: 2, offsetX: 10, offsetY: 20 }; + + onPointerDown$.emit(event); + + expect(contextMenuService.triggerContextMenu).toHaveBeenCalledWith(event, ContextMenuPosition.DRAWING); + controller.dispose(); + }); + it('does not open context menu for checklist bullets or selections inside code blocks', () => { const bullet = createController({ pointerOnBullet: true }); bullet.onPointerDown$.emit({ button: 2, offsetX: 10, offsetY: 20 }); diff --git a/packages/docs-ui/src/controllers/render-controllers/__tests__/doc-resize.render-controller.spec.ts b/packages/docs-ui/src/controllers/render-controllers/__tests__/doc-resize.render-controller.spec.ts new file mode 100644 index 0000000000..dbf072742f --- /dev/null +++ b/packages/docs-ui/src/controllers/render-controllers/__tests__/doc-resize.render-controller.spec.ts @@ -0,0 +1,51 @@ +/** + * 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 { EventSubject } from '@univerjs/core'; +import { Subject } from 'rxjs'; +import { describe, expect, it, vi } from 'vitest'; +import { DocResizeRenderController } from '../doc-resize.render-controller'; + +describe('DocResizeRenderController', () => { + it('refreshes page layout and text selection after sidebar layout changes', async () => { + const calculatePagePosition = vi.fn(); + const refreshRanges = vi.fn(); + const refreshSelection = vi.fn(); + const sidebarOptions$ = new Subject(); + const controller = new DocResizeRenderController( + { + unitId: 'doc-1', + engine: { + onTransformChange$: new EventSubject(), + }, + } as never, + { calculatePagePosition } as never, + { refreshSelection } as never, + { refreshRanges } as never, + { sidebarOptions$ } as never + ); + + sidebarOptions$.next({ visible: true }); + expect(calculatePagePosition).not.toHaveBeenCalled(); + + await new Promise((resolve) => requestAnimationFrame(() => resolve())); + + expect(calculatePagePosition).toHaveBeenCalledOnce(); + expect(refreshRanges).toHaveBeenCalledOnce(); + expect(refreshSelection).toHaveBeenCalledOnce(); + controller.dispose(); + }); +}); diff --git a/packages/docs-ui/src/controllers/render-controllers/doc-contextmenu.render-controller.ts b/packages/docs-ui/src/controllers/render-controllers/doc-contextmenu.render-controller.ts index 1f21785ece..1948ef3997 100644 --- a/packages/docs-ui/src/controllers/render-controllers/doc-contextmenu.render-controller.ts +++ b/packages/docs-ui/src/controllers/render-controllers/doc-contextmenu.render-controller.ts @@ -22,7 +22,9 @@ import { DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY, DOCS_NORMAL_EDITOR_UNIT_ID_KEY, DocumentBlockRangeType, + FOCUSING_COMMON_DRAWINGS, ICommandService, + IContextService, Inject, IUniverInstanceService, UniverInstanceType, @@ -48,6 +50,7 @@ export class DocContextMenuRenderController extends Disposable implements IRende @ICommandService private readonly _commandService: ICommandService, @Inject(DocEventManagerService) private readonly _docEventManagerService: DocEventManagerService, @Inject(DocSelectionManagerService) private readonly _docSelectionManagerService: DocSelectionManagerService, + @IContextService private readonly _contextService: IContextService, @Inject(IUniverInstanceService) private readonly _univerInstanceService: IUniverInstanceService ) { super(); @@ -70,7 +73,10 @@ export class DocContextMenuRenderController extends Disposable implements IRende return; } - this._contextMenuService.triggerContextMenu(event, ContextMenuPosition.MAIN_AREA); + const position = this._contextService.getContextValue(FOCUSING_COMMON_DRAWINGS) + ? ContextMenuPosition.DRAWING + : ContextMenuPosition.MAIN_AREA; + this._contextMenuService.triggerContextMenu(event, position); } }); this.disposeWithMe(documentsSubscription); diff --git a/packages/docs-ui/src/controllers/render-controllers/doc-resize.render-controller.ts b/packages/docs-ui/src/controllers/render-controllers/doc-resize.render-controller.ts index 0f569d3d57..216f55400f 100644 --- a/packages/docs-ui/src/controllers/render-controllers/doc-resize.render-controller.ts +++ b/packages/docs-ui/src/controllers/render-controllers/doc-resize.render-controller.ts @@ -17,16 +17,19 @@ import type { IRenderContext, IRenderModule } from '@univerjs/engine-render'; import { Disposable, fromEventSubject, Inject, isInternalEditorID } from '@univerjs/core'; import { DocSelectionManagerService } from '@univerjs/docs'; -import { TRANSFORM_CHANGE_OBSERVABLE_TYPE } from '@univerjs/engine-render'; -import { animationFrameScheduler, filter, throttleTime } from 'rxjs'; +import { ISidebarService } from '@univerjs/ui'; +import { animationFrameScheduler, observeOn, throttleTime } from 'rxjs'; import { DocPageLayoutService } from '../../services/doc-page-layout.service'; +import { DocSelectionRenderService } from '../../services/selection/doc-selection-render.service'; // REFACTOR: @JOCS, move to new-docs package. export class DocResizeRenderController extends Disposable implements IRenderModule { constructor( private _context: IRenderContext, @Inject(DocPageLayoutService) private readonly _docPageLayoutService: DocPageLayoutService, - @Inject(DocSelectionManagerService) private readonly _textSelectionManagerService: DocSelectionManagerService + @Inject(DocSelectionManagerService) private readonly _textSelectionManagerService: DocSelectionManagerService, + @Inject(DocSelectionRenderService) private readonly _docSelectionRenderService: DocSelectionRenderService, + @ISidebarService private readonly _sidebarService: ISidebarService ) { super(); @@ -39,16 +42,24 @@ export class DocResizeRenderController extends Disposable implements IRenderModu private _initResize() { this.disposeWithMe( fromEventSubject(this._context.engine.onTransformChange$).pipe( - filter((evt) => evt.type === TRANSFORM_CHANGE_OBSERVABLE_TYPE.resize), throttleTime(0, animationFrameScheduler) - ).subscribe(() => { - if (this._disposed) { - return; - } + ).subscribe(() => this._refreshLayoutAndSelection()) + ); - this._docPageLayoutService.calculatePagePosition(); - this._textSelectionManagerService.refreshSelection(); - }) + this.disposeWithMe( + this._sidebarService.sidebarOptions$.pipe( + observeOn(animationFrameScheduler) + ).subscribe(() => this._refreshLayoutAndSelection()) ); } + + private _refreshLayoutAndSelection() { + if (this._disposed) { + return; + } + + this._docPageLayoutService.calculatePagePosition(); + this._docSelectionRenderService.refreshRanges(); + this._textSelectionManagerService.refreshSelection(); + } } diff --git a/packages/docs-ui/src/services/selection/doc-selection-render.service.ts b/packages/docs-ui/src/services/selection/doc-selection-render.service.ts index ec6ae5c374..4292a0f1d4 100644 --- a/packages/docs-ui/src/services/selection/doc-selection-render.service.ts +++ b/packages/docs-ui/src/services/selection/doc-selection-render.service.ts @@ -232,6 +232,11 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo this._reserveRanges = status; } + refreshRanges() { + this._rangeList.forEach((range) => range.refresh()); + this._rectRangeList.forEach((range) => range.refresh()); + } + private _setRangeStyle(style: ITextSelectionStyle = NORMAL_TEXT_SELECTION_PLUGIN_STYLE) { this._selectionStyle = style; } diff --git a/packages/engine-render/src/render-manager/render-manager.service.ts b/packages/engine-render/src/render-manager/render-manager.service.ts index 96a74c62e3..09e8b0a95d 100644 --- a/packages/engine-render/src/render-manager/render-manager.service.ts +++ b/packages/engine-render/src/render-manager/render-manager.service.ts @@ -76,6 +76,7 @@ export interface IRenderManagerService extends IDisposable { * @param dep */ registerRenderModule(type: UniverInstanceType, dep: Dependency): IDisposable; + registerRenderModule(type: UniverInstanceType, dep: Dependency): IDisposable; } const DEFAULT_SCENE_SIZE = { width: 1500, height: 1000 }; @@ -148,6 +149,8 @@ export class RenderManagerService extends Disposable implements IRenderManagerSe * @param type * @param depCtor */ + registerRenderModule(type: UniverInstanceType, depCtor: Dependency): IDisposable; + registerRenderModule(type: UniverInstanceType, depCtor: Dependency): IDisposable; registerRenderModule(type: UniverInstanceType, depCtor: Dependency): IDisposable { if (!this._renderDependencies.has(type)) { this._renderDependencies.set(type, []); diff --git a/packages/sheets-data-validation-ui/src/mobile-plugin.ts b/packages/sheets-data-validation-ui/src/mobile-plugin.ts index 285f9ddee6..5c329e7a91 100644 --- a/packages/sheets-data-validation-ui/src/mobile-plugin.ts +++ b/packages/sheets-data-validation-ui/src/mobile-plugin.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Dependency, Workbook } from '@univerjs/core'; +import type { Dependency } from '@univerjs/core'; import type { IUniverSheetsDataValidationUIConfig } from './config/config'; import { DependentOn, ICommandService, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core'; import { UniverDataValidationPlugin } from '@univerjs/data-validation'; @@ -117,7 +117,7 @@ export class UniverSheetsDataValidationMobileUIPlugin extends Plugin { this._injector.get(DataValidationPermissionController); const renderManager = this._injector.get(IRenderManagerService); - renderManager.registerRenderModule( + renderManager.registerRenderModule( UniverInstanceType.UNIVER_SHEET, [SheetsDataValidationReRenderController] as Dependency ); diff --git a/packages/sheets-data-validation-ui/src/plugin.ts b/packages/sheets-data-validation-ui/src/plugin.ts index 26eb96bb52..0ce24d9fb0 100644 --- a/packages/sheets-data-validation-ui/src/plugin.ts +++ b/packages/sheets-data-validation-ui/src/plugin.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Dependency, Workbook } from '@univerjs/core'; +import type { Dependency } from '@univerjs/core'; import type { IUniverSheetsDataValidationUIConfig } from './config/config'; import { DependentOn, ICommandService, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core'; import { UniverDataValidationPlugin } from '@univerjs/data-validation'; @@ -119,7 +119,7 @@ export class UniverSheetsDataValidationUIPlugin extends Plugin { this._injector.get(DataValidationAlertController); const renderManager = this._injector.get(IRenderManagerService); - renderManager.registerRenderModule( + renderManager.registerRenderModule( UniverInstanceType.UNIVER_SHEET, [SheetsDataValidationReRenderController] as Dependency ); diff --git a/packages/sheets-drawing-ui/src/menu/__tests__/drawing-popup-menu.controller.spec.ts b/packages/sheets-drawing-ui/src/menu/__tests__/drawing-popup-menu.controller.spec.ts index 2d947cf7ab..4172a62a9b 100644 --- a/packages/sheets-drawing-ui/src/menu/__tests__/drawing-popup-menu.controller.spec.ts +++ b/packages/sheets-drawing-ui/src/menu/__tests__/drawing-popup-menu.controller.spec.ts @@ -27,7 +27,7 @@ import { import { IDrawingManagerService } from '@univerjs/drawing'; import { IRenderManagerService } from '@univerjs/engine-render'; import { SheetCanvasPopManagerService } from '@univerjs/sheets-ui'; -import { IMessageService } from '@univerjs/ui'; +import { IMenuManagerService, IMessageService, MenuItemType } from '@univerjs/ui'; import { Subject } from 'rxjs'; import { describe, expect, it, vi } from 'vitest'; import { DrawingPopupMenuController } from '../drawing-popup-menu.controller'; @@ -46,10 +46,14 @@ describe('DrawingPopupMenuController', () => { const currentWorkbook$ = new Subject(); const disposedWorkbook$ = new Subject(); const imageObject = { oKey: 'image-1' }; - const attachPopupToObject = vi.fn(() => ({ - dispose: vi.fn(), - canDispose: () => true, - })); + let attachedPopup: { extraProps?: Record } | undefined; + const attachPopupToObject = vi.fn((_targetObject: unknown, popup: { extraProps?: Record }) => { + attachedPopup = popup; + return { + dispose: vi.fn(), + canDispose: () => true, + }; + }); injector.add([LocaleService]); injector.add([IDrawingManagerService, { @@ -92,6 +96,19 @@ describe('DrawingPopupMenuController', () => { } as never, }]); injector.add([IMessageService, { useValue: { show: () => toDisposable(() => {}) } as never }]); + injector.add([IMenuManagerService, { + useValue: { + getFlatMenuByPositionKey: () => [{ + key: 'add-comment', + order: 0, + item: { + id: 'sheet.operation.add-drawing-comment', + type: MenuItemType.BUTTON, + title: 'sheets-thread-comment-ui.menu.addComment', + }, + }], + } as never, + }]); injector.add([IContextService, { useValue: { contextChanged$, @@ -113,6 +130,12 @@ describe('DrawingPopupMenuController', () => { direction: 'left', }) ); + expect(attachedPopup?.extraProps?.menuItems).toEqual(expect.arrayContaining([ + expect.objectContaining({ + commandId: 'sheet.operation.add-drawing-comment', + label: 'sheets-thread-comment-ui.menu.addComment', + }), + ])); controller.dispose(); injector.dispose(); diff --git a/packages/sheets-drawing-ui/src/menu/drawing-popup-menu.controller.ts b/packages/sheets-drawing-ui/src/menu/drawing-popup-menu.controller.ts index bf1aeb159a..8c7a58939a 100644 --- a/packages/sheets-drawing-ui/src/menu/drawing-popup-menu.controller.ts +++ b/packages/sheets-drawing-ui/src/menu/drawing-popup-menu.controller.ts @@ -43,7 +43,12 @@ import { import { IRenderManagerService } from '@univerjs/engine-render'; import { RemoveSheetDrawingCommand } from '@univerjs/sheets-drawing'; import { SheetCanvasPopManagerService } from '@univerjs/sheets-ui'; -import { IMessageService } from '@univerjs/ui'; +import { + FloatingObjectToolbarPosition, + IMenuManagerService, + IMessageService, + MenuItemType, +} from '@univerjs/ui'; import { FlipSheetDrawingCommand } from '../commands/commands/flip-drawings.command'; import { EditSheetDrawingOperation } from '../commands/operations/edit-sheet-drawing.operation'; @@ -58,6 +63,7 @@ export class DrawingPopupMenuController extends RxDisposable { @IRenderManagerService private readonly _renderManagerService: IRenderManagerService, @IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService, @IMessageService private readonly _messageService: IMessageService, + @IMenuManagerService private readonly _menuManagerService: IMenuManagerService, @IContextService private readonly _contextService: IContextService, @IImageIoService private readonly _ioService: ImageIoService, @ICommandService private readonly _commandService: ICommandService @@ -176,7 +182,10 @@ export class DrawingPopupMenuController extends RxDisposable { direction: this._localeService.getDirection() === 'rtl' ? 'left' : 'horizontal', offset: [2, 0], extraProps: { - menuItems: menus || this._getImageMenuItems(unitId, subUnitId, drawingId, drawingType), + menuItems: [ + ...(menus || this._getImageMenuItems(unitId, subUnitId, drawingId, drawingType)), + ...this._getFloatingObjectMenuItems(), + ], }, })); }) @@ -248,4 +257,22 @@ export class DrawingPopupMenuController extends RxDisposable { }, ]; } + + private _getFloatingObjectMenuItems() { + return this._menuManagerService + .getFlatMenuByPositionKey(FloatingObjectToolbarPosition.SHEET) + .flatMap(({ item }, index) => { + if (!item || item.type !== MenuItemType.BUTTON || !item.title) { + return []; + } + + return [{ + label: item.title, + index: 100 + index, + commandId: item.commandId ?? item.id, + commandParams: typeof item.params === 'function' ? item.params() : item.params, + disable: false, + }]; + }); + } } diff --git a/packages/sheets-drawing/src/facade/f-over-grid-image.ts b/packages/sheets-drawing/src/facade/f-over-grid-image.ts index bec494ba3c..4edba7f954 100644 --- a/packages/sheets-drawing/src/facade/f-over-grid-image.ts +++ b/packages/sheets-drawing/src/facade/f-over-grid-image.ts @@ -832,6 +832,16 @@ export class FOverGridImage extends FBase { return this._image.drawingId; } + /** Returns the workbook unit id that owns this image. */ + getUnitId(): string { + return this._image.unitId; + } + + /** Returns the worksheet id that owns this image. */ + getSubUnitId(): string { + return this._image.subUnitId; + } + /** * Get the drawing type of the image * @returns {DrawingTypeEnum} The drawing type of the image diff --git a/packages/sheets-drawing/src/facade/index.ts b/packages/sheets-drawing/src/facade/index.ts index 7591fe8dde..998e20d8d8 100644 --- a/packages/sheets-drawing/src/facade/index.ts +++ b/packages/sheets-drawing/src/facade/index.ts @@ -22,6 +22,6 @@ import './f-univer'; export type * from './f-enum'; export type * from './f-event'; -export type * from './f-over-grid-image'; +export { FOverGridImage, type FOverGridImageBuilder, type IFOverGridImage } from './f-over-grid-image'; export type * from './f-univer'; export type * from './f-worksheet'; diff --git a/packages/sheets-hyper-link-ui/src/plugin.ts b/packages/sheets-hyper-link-ui/src/plugin.ts index 81c21d4e67..80062e89f1 100644 --- a/packages/sheets-hyper-link-ui/src/plugin.ts +++ b/packages/sheets-hyper-link-ui/src/plugin.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import type { Dependency, Workbook } from '@univerjs/core'; +import type { Dependency } from '@univerjs/core'; import type { IUniverSheetsHyperLinkUIConfig } from './config/config'; import { DependentOn, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core'; import { UniverDocsPlugin } from '@univerjs/docs'; @@ -94,7 +94,7 @@ export class UniverSheetsHyperLinkUIPlugin extends Plugin { override onReady(): void { const renderManager = this._injector.get(IRenderManagerService); - renderManager.registerRenderModule(UniverInstanceType.UNIVER_SHEET, [SheetsHyperLinkRenderController] as Dependency); + renderManager.registerRenderModule(UniverInstanceType.UNIVER_SHEET, [SheetsHyperLinkRenderController] as Dependency); this._injector.get(SheetsHyperLinkAutoFillController); this._injector.get(SheetsHyperLinkCopyPasteController); diff --git a/packages/sheets-thread-comment-ui/package.json b/packages/sheets-thread-comment-ui/package.json index 6525093bfa..7149900588 100644 --- a/packages/sheets-thread-comment-ui/package.json +++ b/packages/sheets-thread-comment-ui/package.json @@ -75,6 +75,7 @@ }, "dependencies": { "@univerjs/core": "workspace:*", + "@univerjs/drawing": "workspace:*", "@univerjs/engine-formula": "workspace:*", "@univerjs/engine-render": "workspace:*", "@univerjs/icons": "1.38.0", diff --git a/packages/sheets-thread-comment-ui/src/commands/operations/__tests__/comment.operation.spec.ts b/packages/sheets-thread-comment-ui/src/commands/operations/__tests__/comment.operation.spec.ts index e6e0aecdb8..21040a0bcd 100644 --- a/packages/sheets-thread-comment-ui/src/commands/operations/__tests__/comment.operation.spec.ts +++ b/packages/sheets-thread-comment-ui/src/commands/operations/__tests__/comment.operation.spec.ts @@ -18,6 +18,7 @@ import type { IWorkbookData } from '@univerjs/core'; import type { ISelectionWithStyle } from '@univerjs/sheets'; import type { IThreadComment } from '@univerjs/thread-comment'; import { + DrawingTypeEnum, ICommandService, IUniverInstanceService, LifecycleService, @@ -27,6 +28,7 @@ import { Univer, UniverInstanceType, } from '@univerjs/core'; +import { DrawingManagerService, IDrawingManagerService } from '@univerjs/drawing'; import { SheetsSelectionsService } from '@univerjs/sheets'; import { SheetsThreadCommentModel } from '@univerjs/sheets-thread-comment'; import { CellPopupManagerService, SheetCanvasPopManagerService } from '@univerjs/sheets-ui'; @@ -41,7 +43,7 @@ import { DesktopSidebarService, ISidebarService } from '@univerjs/ui'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { SheetsThreadCommentPopupService } from '../../../services/sheets-thread-comment-popup.service'; import { SHEETS_THREAD_COMMENT_PANEL } from '../../../types/const'; -import { ShowAddSheetCommentModalOperation, ToggleSheetCommentPanelOperation } from '../comment.operation'; +import { AddSheetDrawingCommentOperation, ShowAddSheetCommentModalOperation, ToggleSheetCommentPanelOperation } from '../comment.operation'; const unitId = 'comment-workbook'; const subUnitId = 'sheet-1'; @@ -193,4 +195,25 @@ describe('sheet thread comment operations', () => { expect(panelService.panelVisible).toBe(false); expect(sidebarService.visible).toBe(false); }); + + it('rejects a focused drawing from another workbook or worksheet', () => { + const injector = univer.__getInjector(); + const drawing = { + unitId: 'other-book', + subUnitId, + 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(AddSheetDrawingCommentOperation.handler(injector, {})).toBe(false); + drawingManager.dispose(); + }); }); diff --git a/packages/sheets-thread-comment-ui/src/commands/operations/comment.operation.ts b/packages/sheets-thread-comment-ui/src/commands/operations/comment.operation.ts index a7665cf849..eaec8811ad 100644 --- a/packages/sheets-thread-comment-ui/src/commands/operations/comment.operation.ts +++ b/packages/sheets-thread-comment-ui/src/commands/operations/comment.operation.ts @@ -14,12 +14,14 @@ * limitations under the License. */ -import type { IOperation } from '@univerjs/core'; +import type { IOperation, Workbook } from '@univerjs/core'; import type { ISheetLocation } from '@univerjs/sheets'; -import { CommandType, IUniverInstanceService } from '@univerjs/core'; +import { CommandType, IUniverInstanceService, UniverInstanceType } from '@univerjs/core'; +import { IDrawingManagerService } from '@univerjs/drawing'; import { getSheetCommandTarget, SheetsSelectionsService } from '@univerjs/sheets'; import { SheetsThreadCommentModel } from '@univerjs/sheets-thread-comment'; -import { ThreadCommentPanelService } from '@univerjs/thread-comment-ui'; +import { ThreadCommentAnchorKind } from '@univerjs/thread-comment'; +import { ThreadCommentDraftService, ThreadCommentPanelService } from '@univerjs/thread-comment-ui'; import { ISidebarService } from '@univerjs/ui'; import { SheetsThreadCommentPopupService } from '../../services/sheets-thread-comment-popup.service'; import { SHEETS_THREAD_COMMENT_PANEL } from '../../types/const'; @@ -80,14 +82,72 @@ export const ToggleSheetCommentPanelOperation: IOperation = { sidebarService.close(); panelService.setPanelVisible(false); } else { - sidebarService.open({ - header: { title: 'sheets-thread-comment-ui.panel.title' }, - children: { label: SHEETS_THREAD_COMMENT_PANEL }, - width: 360, - }); - panelService.setPanelVisible(true); + openSheetCommentPanel(sidebarService, panelService); } return true; }, }; + +export const OpenSheetCommentPanelOperation: IOperation = { + id: 'sheet.operation.open-comment-panel', + type: CommandType.OPERATION, + handler(accessor) { + openSheetCommentPanel( + accessor.get(ISidebarService), + accessor.get(ThreadCommentPanelService) + ); + return true; + }, +}; + +function openSheetCommentPanel( + sidebarService: ISidebarService, + panelService: ThreadCommentPanelService +): void { + if (!panelService.panelVisible || sidebarService.options.children?.label !== SHEETS_THREAD_COMMENT_PANEL) { + sidebarService.open({ + header: { title: 'sheets-thread-comment-ui.panel.title' }, + children: { label: SHEETS_THREAD_COMMENT_PANEL }, + width: 360, + onClose: () => panelService.setPanelVisible(false), + }); + } + panelService.setPanelVisible(true); +} + +export const AddSheetDrawingCommentOperation: IOperation = { + id: 'sheet.operation.add-drawing-comment', + type: CommandType.OPERATION, + handler(accessor) { + const drawing = accessor.get(IDrawingManagerService).getFocusDrawings()[0]; + const workbook = accessor.get(IUniverInstanceService) + .getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET); + if ( + !drawing + || !workbook + || drawing.unitId !== workbook.getUnitId() + || drawing.subUnitId !== workbook.getActiveSheet()?.getSheetId() + ) { + return false; + } + accessor.get(ThreadCommentDraftService).place({ + unitId: drawing.unitId, + subUnitId: drawing.subUnitId, + anchor: { + kind: ThreadCommentAnchorKind.SHEET_DRAWING, + pageId: drawing.subUnitId, + elementId: drawing.drawingId, + }, + }); + const panelService = accessor.get(ThreadCommentPanelService); + accessor.get(ISidebarService).open({ + header: { title: 'sheets-thread-comment-ui.panel.title' }, + children: { label: SHEETS_THREAD_COMMENT_PANEL }, + width: 360, + onClose: () => panelService.setPanelVisible(false), + }); + panelService.setPanelVisible(true); + return true; + }, +}; diff --git a/packages/sheets-thread-comment-ui/src/controllers/__tests__/sheets-thread-comment-copy-paste.controller.spec.ts b/packages/sheets-thread-comment-ui/src/controllers/__tests__/sheets-thread-comment-copy-paste.controller.spec.ts index 79a6cc8073..cce311c27b 100644 --- a/packages/sheets-thread-comment-ui/src/controllers/__tests__/sheets-thread-comment-copy-paste.controller.spec.ts +++ b/packages/sheets-thread-comment-ui/src/controllers/__tests__/sheets-thread-comment-copy-paste.controller.spec.ts @@ -129,4 +129,29 @@ describe('SheetsThreadCommentCopyPasteController', () => { controller.dispose(); }); + + it('does not implicitly clone a comment during a normal cell copy', async () => { + await testBed.commandService.executeCommand(AddCommentMutation.id, { + unitId: 'test', + subUnitId: 'sheet1', + comment: createRootComment(), + }); + const controller = testBed.injector.createInstance(SheetsThreadCommentCopyPasteController); + const hook = testBed.getClipboardHook()!; + hook.onBeforeCopy('test', 'sheet1', { + startRow: 0, + endRow: 0, + startColumn: 0, + endColumn: 0, + }); + + expect(hook.onPasteCells( + null, + { unitId: 'test', subUnitId: 'sheet2', range: { rows: [2], cols: [3] } }, + null, + { copyType: COPY_TYPE.COPY } + )).toEqual({ redos: [], undos: [] }); + + controller.dispose(); + }); }); diff --git a/packages/sheets-thread-comment-ui/src/controllers/__tests__/sheets-thread-comment-permission.controller.spec.ts b/packages/sheets-thread-comment-ui/src/controllers/__tests__/sheets-thread-comment-permission.controller.spec.ts index cf8090e40f..38d877e5ae 100644 --- a/packages/sheets-thread-comment-ui/src/controllers/__tests__/sheets-thread-comment-permission.controller.spec.ts +++ b/packages/sheets-thread-comment-ui/src/controllers/__tests__/sheets-thread-comment-permission.controller.spec.ts @@ -14,32 +14,57 @@ * limitations under the License. */ -import { AddCommentCommand, DeleteCommentCommand, UpdateCommentCommand } from '@univerjs/thread-comment'; +import { ICommandService, Injector, LocaleService } from '@univerjs/core'; +import { SheetPermissionCheckController } from '@univerjs/sheets'; +import { SheetsThreadCommentModel } from '@univerjs/sheets-thread-comment'; +import { + AddCommentCommand, + DeleteCommentCommand, + serializeThreadCommentAnchor, + ThreadCommentAnchorKind, + UpdateCommentCommand, +} from '@univerjs/thread-comment'; import { describe, expect, it, vi } from 'vitest'; import { ShowAddSheetCommentModalOperation } from '../../commands/operations/comment.operation'; import { SheetsThreadCommentPermissionController } from '../sheets-thread-comment-permission.controller'; +type BeforeCommandHandler = (command: { id: string; params?: unknown }) => void; + +function createTestBed(permissionCheck: object, commentModel: object = { getComment: vi.fn() }) { + let beforeCommandHandler: BeforeCommandHandler | undefined; + const injector = new Injector([ + [ICommandService, { useValue: { + beforeCommandExecuted: vi.fn((handler: BeforeCommandHandler) => { + beforeCommandHandler = handler; + return { dispose: vi.fn() }; + }), + } }], + [LocaleService, { useValue: { t: (key: string) => key } }], + [SheetPermissionCheckController, { useValue: permissionCheck }], + [SheetsThreadCommentModel, { useValue: commentModel }], + [SheetsThreadCommentPermissionController], + ]); + + return { + controller: injector.get(SheetsThreadCommentPermissionController), + getBeforeCommandHandler: () => beforeCommandHandler, + }; +} + describe('SheetsThreadCommentPermissionController', () => { it('blocks comment panel, add, update and delete actions when comment permissions are denied', () => { - let beforeCommandHandler: ((command: { id: string; params?: unknown }) => void) | undefined; const permissionCheck = { permissionCheckWithoutRange: vi.fn(() => false), permissionCheckWithRanges: vi.fn(() => false), blockExecuteWithoutPermission: vi.fn(), }; - const controller = new SheetsThreadCommentPermissionController( - { t: (key: string) => key } as never, - { - beforeCommandExecuted: vi.fn((handler) => { - beforeCommandHandler = handler; - return { dispose: vi.fn() }; - }), - } as never, - permissionCheck as never, + const { controller, getBeforeCommandHandler } = createTestBed( + permissionCheck, { getComment: vi.fn(() => ({ id: 'comment-1', ref: 'C4' })), - } as never + } ); + const beforeCommandHandler = getBeforeCommandHandler(); beforeCommandHandler?.({ id: ShowAddSheetCommentModalOperation.id }); beforeCommandHandler?.({ @@ -85,4 +110,42 @@ describe('SheetsThreadCommentPermissionController', () => { controller.dispose(); }); + + it('rechecks drawing comment permissions without creating an invalid cell range', () => { + const permissionCheck = { + permissionCheckWithoutRange: vi.fn() + .mockReturnValueOnce(true) + .mockReturnValueOnce(false), + permissionCheckWithRanges: vi.fn(), + blockExecuteWithoutPermission: vi.fn(), + }; + const { controller, getBeforeCommandHandler } = createTestBed(permissionCheck); + const beforeCommandHandler = getBeforeCommandHandler(); + const command = { + id: AddCommentCommand.id, + params: { + unitId: 'unit-1', + subUnitId: 'sheet-1', + comment: { + ref: serializeThreadCommentAnchor({ + kind: ThreadCommentAnchorKind.SHEET_DRAWING, + elementId: 'drawing-1', + }), + }, + }, + }; + + beforeCommandHandler?.(command); + beforeCommandHandler?.(command); + + expect(permissionCheck.permissionCheckWithoutRange).toHaveBeenCalledTimes(2); + expect(permissionCheck.permissionCheckWithoutRange).toHaveBeenLastCalledWith( + expect.any(Object), + 'unit-1', + 'sheet-1' + ); + expect(permissionCheck.permissionCheckWithRanges).not.toHaveBeenCalled(); + expect(permissionCheck.blockExecuteWithoutPermission).toHaveBeenCalledTimes(1); + controller.dispose(); + }); }); diff --git a/packages/sheets-thread-comment-ui/src/controllers/__tests__/sheets-thread-comment-popup.controller.spec.ts b/packages/sheets-thread-comment-ui/src/controllers/__tests__/sheets-thread-comment-popup.controller.spec.ts new file mode 100644 index 0000000000..89104a041c --- /dev/null +++ b/packages/sheets-thread-comment-ui/src/controllers/__tests__/sheets-thread-comment-popup.controller.spec.ts @@ -0,0 +1,54 @@ +/** + * 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 { describe, expect, it } from 'vitest'; +import { resolveFocusedSheetComment } from '../sheets-thread-comment-popup.controller'; + +describe('resolveFocusedSheetComment', () => { + const activeComment = { + unitId: 'book-1', + subUnitId: 'sheet-1', + commentId: 'active-comment', + }; + + it('prefers a hovered comment from the current sheet', () => { + const hoveredComment = { + unitId: 'book-1', + subUnitId: 'sheet-1', + commentId: 'hovered-comment', + }; + + expect(resolveFocusedSheetComment(activeComment, hoveredComment, 'book-1', 'sheet-1')) + .toEqual(hoveredComment); + }); + + it('keeps the active highlight when hover belongs to another sheet or workbook', () => { + expect(resolveFocusedSheetComment(activeComment, { + unitId: 'book-1', + subUnitId: 'sheet-2', + commentId: 'foreign-hover', + }, 'book-1', 'sheet-1')).toEqual(activeComment); + expect(resolveFocusedSheetComment(activeComment, { + unitId: 'book-2', + subUnitId: 'sheet-1', + commentId: 'foreign-hover', + }, 'book-1', 'sheet-1')).toEqual(activeComment); + }); + + it('does not draw a stale highlight outside the current sheet context', () => { + expect(resolveFocusedSheetComment(activeComment, undefined, 'book-1', 'sheet-2')).toBeUndefined(); + }); +}); diff --git a/packages/sheets-thread-comment-ui/src/controllers/components.controller.ts b/packages/sheets-thread-comment-ui/src/controllers/components.controller.ts index 58b34370d2..e8f2051c32 100644 --- a/packages/sheets-thread-comment-ui/src/controllers/components.controller.ts +++ b/packages/sheets-thread-comment-ui/src/controllers/components.controller.ts @@ -15,7 +15,7 @@ */ import { Disposable, Inject } from '@univerjs/core'; -import { CommentIcon } from '@univerjs/icons'; +import { CommentIcon, InsertCommentDoubleIcon } from '@univerjs/icons'; import { ComponentManager, IconManager } from '@univerjs/ui'; import { SHEETS_THREAD_COMMENT_MODAL, SHEETS_THREAD_COMMENT_PANEL } from '../types/const'; import { SheetsThreadCommentCell } from '../views/SheetsThreadCommentCell'; @@ -46,6 +46,7 @@ export class ComponentsController extends Disposable { private _registerIcons(): void { this.disposeWithMe(this._iconManager.register({ CommentIcon, + InsertCommentDoubleIcon, })); } } diff --git a/packages/sheets-thread-comment-ui/src/controllers/render-controllers/__tests__/drawing.render-controller.spec.ts b/packages/sheets-thread-comment-ui/src/controllers/render-controllers/__tests__/drawing.render-controller.spec.ts new file mode 100644 index 0000000000..23200ed3ec --- /dev/null +++ b/packages/sheets-thread-comment-ui/src/controllers/render-controllers/__tests__/drawing.render-controller.spec.ts @@ -0,0 +1,104 @@ +/** + * 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 { 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 { SheetsThreadCommentDrawingRenderController } from '../drawing.render-controller'; + +describe('SheetsThreadCommentDrawingRenderController', () => { + it('aggregates drawing roots, follows transforms, and retains the remaining root after deletion', () => { + const worksheet = { getSheetId: () => 'sheet-1', getZoomRatio: () => 2 }; + const activeSheet$ = new BehaviorSubject(worksheet); + const workbook = { activeSheet$, getActiveSheet: () => worksheet }; + const drawingKey = getDrawingShapeKeyByDrawingSearch({ + unitId: 'book-1', + subUnitId: 'sheet-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: 'book-1', + unit: workbook, + scene, + engine: { onTransformChange$: { subscribeEvent: vi.fn(() => ({ dispose: vi.fn() })) } }, + }; + const commentUpdate$ = new Subject<{ unitId: string }>(); + const ref = serializeThreadCommentAnchor({ + kind: ThreadCommentAnchorKind.SHEET_DRAWING, + pageId: 'sheet-1', + elementId: 'shape-1', + }); + const commentModel = { + commentUpdate$, + query: vi.fn(() => roots.map((id) => ({ root: { id, ref } }))), + getComment: vi.fn((_unitId: string, _subUnitId: string, commentId: string) => ({ id: commentId, ref })), + }; + const activeCommentId$ = new BehaviorSubject({ + unitId: 'book-1', + subUnitId: 'sheet-1', + commentId: 'first-comment', + }); + const hoveredCommentId$ = new BehaviorSubject(undefined); + const panelService = { + activeCommentId: activeCommentId$.value, + hoveredCommentId: hoveredCommentId$.value, + activeCommentId$, + hoveredCommentId$, + setActiveComment: vi.fn(), + }; + const drawingManager = { add$: new Subject(), update$: new Subject(), remove$: new Subject() }; + const themeService = { + currentTheme$: new Subject(), + getColorFromTheme: vi.fn((token: string) => token), + }; + const controller = new SheetsThreadCommentDrawingRenderController( + context as never, + { executeCommand: vi.fn(() => Promise.resolve(true)) } as never, + drawingManager as never, + commentModel as never, + panelService as never, + themeService as never + ); + const overlay = scene.addObject.mock.calls[0][0]; + + expect(overlay.isHit(new Vector2(60, 101))).toBe(true); + expect(overlay.hitCommentId).toBe('newest-comment'); + + roots = ['first-comment']; + commentUpdate$.next({ unitId: 'book-1' }); + expect(overlay.isHit(new Vector2(60, 101))).toBe(true); + expect(overlay.hitCommentId).toBe('first-comment'); + + bounds = { left: 120, top: 140, width: 100, height: 70 }; + drawingManager.update$.next([{ unitId: 'book-1' }]); + expect(overlay.isHit(new Vector2(60, 101))).toBe(false); + expect(overlay.isHit(new Vector2(170, 211))).toBe(true); + + roots = []; + commentUpdate$.next({ unitId: 'book-1' }); + expect(overlay.isHit(new Vector2(170, 211))).toBe(false); + controller.dispose(); + }); +}); diff --git a/packages/sheets-thread-comment-ui/src/controllers/render-controllers/drawing.render-controller.ts b/packages/sheets-thread-comment-ui/src/controllers/render-controllers/drawing.render-controller.ts new file mode 100644 index 0000000000..84f734dd87 --- /dev/null +++ b/packages/sheets-thread-comment-ui/src/controllers/render-controllers/drawing.render-controller.ts @@ -0,0 +1,161 @@ +/** + * 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 { EventState, Workbook } from '@univerjs/core'; +import type { IMouseEvent, IPointerEvent, IRenderContext, IRenderModule } from '@univerjs/engine-render'; +import type { IThreadCommentCanvasOutline, IThreadCommentCanvasUnderline } from '@univerjs/thread-comment-ui'; +import { ICommandService, Inject, RxDisposable, ThemeService, toDisposable } from '@univerjs/core'; +import { getDrawingShapeKeyByDrawingSearch, IDrawingManagerService } from '@univerjs/drawing'; +import { deserializeThreadCommentAnchor, ThreadCommentAnchorKind, ThreadCommentModel } from '@univerjs/thread-comment'; +import { ThreadCommentCanvasOverlay, ThreadCommentPanelService } from '@univerjs/thread-comment-ui'; +import { OpenSheetCommentPanelOperation } from '../../commands/operations/comment.operation'; + +const SHEET_COMMENT_DRAWING_OVERLAY_KEY = 'sheet-thread-comment-drawing-overlay'; +const SHEET_COMMENT_DRAWING_OVERLAY_LAYER_INDEX = 10_100; + +export class SheetsThreadCommentDrawingRenderController extends RxDisposable implements IRenderModule { + private readonly _overlay: ThreadCommentCanvasOverlay; + + constructor( + private readonly _context: IRenderContext, + @ICommandService private readonly _commandService: ICommandService, + @IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService, + @Inject(ThreadCommentModel) private readonly _commentModel: ThreadCommentModel, + @Inject(ThreadCommentPanelService) private readonly _panelService: ThreadCommentPanelService, + @Inject(ThemeService) private readonly _themeService: ThemeService + ) { + super(); + this._overlay = new ThreadCommentCanvasOverlay(SHEET_COMMENT_DRAWING_OVERLAY_KEY, { + ...this._getColors(), + zoomRatio: 1, + markers: [], + underlines: [], + }); + this._context.scene.addObject(this._overlay, SHEET_COMMENT_DRAWING_OVERLAY_LAYER_INDEX); + this.disposeWithMe(toDisposable(this._overlay.onPointerDown$.subscribeEvent( + (_event: IPointerEvent | IMouseEvent, state: EventState) => this._onOverlayPointerDown(state) + ))); + this.disposeWithMe(toDisposable( + this._context.engine.onTransformChange$.subscribeEvent(() => this._syncOverlay()) + )); + this.disposeWithMe(this._context.unit.activeSheet$.subscribe(() => this._syncOverlay())); + this.disposeWithMe(this._commentModel.commentUpdate$.subscribe((update) => { + if (update.unitId === this._context.unitId) { + this._syncOverlay(); + } + })); + [this._panelService.activeCommentId$, this._panelService.hoveredCommentId$] + .forEach((observable) => this.disposeWithMe(observable.subscribe(() => this._syncOverlay()))); + [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._syncOverlay(); + } + }))); + this.disposeWithMe(this._themeService.currentTheme$.subscribe(() => this._syncOverlay())); + this._syncOverlay(); + } + + private _onOverlayPointerDown(state: EventState): void { + const commentId = this._overlay.hitCommentId; + const subUnitId = this._context.unit.getActiveSheet()?.getSheetId(); + if (!commentId || !subUnitId) { + return; + } + state.stopPropagation(); + this._panelService.setActiveComment({ + unitId: this._context.unitId, + subUnitId, + commentId, + trigger: 'sheet-canvas', + }); + this._commandService.executeCommand(OpenSheetCommentPanelOperation.id).catch(() => undefined); + } + + private _syncOverlay(): void { + const worksheet = this._context.unit.getActiveSheet(); + const subUnitId = worksheet?.getSheetId(); + if (!worksheet || !subUnitId) { + this._overlay.updateState({ markers: [], underlines: [], focusedCommentIds: [], focusOutlines: [] }); + return; + } + const underlines = new Map(); + this._commentModel.query({ + unitIds: [this._context.unitId], + subUnitIds: [subUnitId], + anchorKinds: [ThreadCommentAnchorKind.SHEET_DRAWING], + resolved: false, + }).forEach(({ root }) => { + const anchor = deserializeThreadCommentAnchor(root.ref); + if (anchor?.kind !== ThreadCommentAnchorKind.SHEET_DRAWING) { + return; + } + const outline = this._getDrawingOutline(subUnitId, anchor.elementId); + if (outline) { + underlines.set(anchor.elementId, { + commentId: root.id, + left: outline.left, + top: outline.top + outline.height + 2 / worksheet.getZoomRatio(), + width: outline.width, + }); + } + }); + const focusedCommentIds: string[] = []; + const focusOutlines = new Map(); + [this._panelService.activeCommentId, this._panelService.hoveredCommentId].forEach((target) => { + if (!target || target.unitId !== this._context.unitId || target.subUnitId !== subUnitId) { + return; + } + const comment = this._commentModel.getComment(target.unitId, target.subUnitId, target.commentId); + const anchor = comment && deserializeThreadCommentAnchor(comment.ref); + if (anchor?.kind !== ThreadCommentAnchorKind.SHEET_DRAWING) { + return; + } + focusedCommentIds.push(target.commentId); + const outline = this._getDrawingOutline(subUnitId, anchor.elementId); + if (outline) { + focusOutlines.set(anchor.elementId, outline); + } + }); + this._overlay.updateState({ + ...this._getColors(), + zoomRatio: worksheet.getZoomRatio(), + 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 _getColors(): { accentColor: string; foregroundColor: string; outlineColor: string } { + return { + accentColor: this._themeService.getColorFromTheme('yellow.400'), + foregroundColor: this._themeService.getColorFromTheme('gray.900'), + outlineColor: this._themeService.getColorFromTheme('white'), + }; + } +} diff --git a/packages/sheets-thread-comment-ui/src/controllers/sheets-thread-comment-permission.controller.ts b/packages/sheets-thread-comment-ui/src/controllers/sheets-thread-comment-permission.controller.ts index 5167faa2cd..53adcd3a39 100644 --- a/packages/sheets-thread-comment-ui/src/controllers/sheets-thread-comment-permission.controller.ts +++ b/packages/sheets-thread-comment-ui/src/controllers/sheets-thread-comment-permission.controller.ts @@ -36,7 +36,9 @@ import { AddCommentCommand, DeleteCommentCommand, DeleteCommentTreeCommand, + deserializeThreadCommentAnchor, ResolveCommentCommand, + ThreadCommentAnchorKind, UpdateCommentCommand, } from '@univerjs/thread-comment'; import { ShowAddSheetCommentModalOperation, ToggleSheetCommentPanelOperation } from '../commands/operations/comment.operation'; @@ -69,15 +71,7 @@ export class SheetsThreadCommentPermissionController extends Disposable { } else if (id === AddCommentCommand.id) { const params = command.params as IAddCommentCommandParams; const { unitId, subUnitId, comment } = params; - const location = singleReferenceToGrid(comment.ref); - const { row, column } = location; - - const permission = this._sheetPermissionCheckController.permissionCheckWithRanges({ - workbookTypes: [WorkbookCommentPermission], - worksheetTypes: [WorksheetViewPermission], - rangeTypes: [RangeProtectionPermissionViewPoint], - }, [{ startRow: row, startColumn: column, endRow: row, endColumn: column }], unitId, subUnitId); - if (!permission) { + if (!this._hasCommentPermission(unitId, subUnitId, comment.ref)) { this._sheetPermissionCheckController.blockExecuteWithoutPermission(this._localeService.t('sheets-thread-comment-ui.permission.commentErr')); } } else if (id === UpdateCommentCommand.id) { @@ -86,39 +80,43 @@ export class SheetsThreadCommentPermissionController extends Disposable { const { commentId } = payload; const comment = this._sheetsThreadCommentModel.getComment(unitId, subUnitId, commentId); - if (comment) { - const location = singleReferenceToGrid(comment.ref); - const { row, column } = location; - - const permission = this._sheetPermissionCheckController.permissionCheckWithRanges({ - workbookTypes: [WorkbookCommentPermission], - worksheetTypes: [WorksheetViewPermission], - rangeTypes: [RangeProtectionPermissionViewPoint], - }, [{ startRow: row, startColumn: column, endRow: row, endColumn: column }], unitId, subUnitId); - if (!permission) { - this._sheetPermissionCheckController.blockExecuteWithoutPermission(this._localeService.t('sheets-thread-comment-ui.permission.commentErr')); - } + if (comment && !this._hasCommentPermission(unitId, subUnitId, comment.ref)) { + this._sheetPermissionCheckController.blockExecuteWithoutPermission(this._localeService.t('sheets-thread-comment-ui.permission.commentErr')); } } else if (id === ResolveCommentCommand.id || id === DeleteCommentCommand.id || id === DeleteCommentTreeCommand.id) { const params = command.params as IResolveCommentCommandParams | IDeleteCommentCommandParams | IDeleteCommentTreeCommandParams; const { unitId, subUnitId, commentId } = params; const comment = this._sheetsThreadCommentModel.getComment(unitId, subUnitId, commentId); - if (comment) { - const location = singleReferenceToGrid(comment.ref); - const { row, column } = location; - - const permission = this._sheetPermissionCheckController.permissionCheckWithRanges({ - workbookTypes: [WorkbookCommentPermission], - worksheetTypes: [WorksheetViewPermission], - rangeTypes: [RangeProtectionPermissionViewPoint], - }, [{ startRow: row, startColumn: column, endRow: row, endColumn: column }], unitId, subUnitId); - if (!permission) { - this._sheetPermissionCheckController.blockExecuteWithoutPermission(this._localeService.t('sheets-thread-comment-ui.permission.commentErr')); - } + if (comment && !this._hasCommentPermission(unitId, subUnitId, comment.ref)) { + this._sheetPermissionCheckController.blockExecuteWithoutPermission(this._localeService.t('sheets-thread-comment-ui.permission.commentErr')); } } }) ); } + + private _hasCommentPermission(unitId: string, subUnitId: string, ref: string): boolean { + const permissionTypes = { + workbookTypes: [WorkbookCommentPermission], + worksheetTypes: [WorksheetViewPermission], + }; + const anchor = deserializeThreadCommentAnchor(ref); + if (anchor?.kind === ThreadCommentAnchorKind.SHEET_DRAWING) { + return this._sheetPermissionCheckController.permissionCheckWithoutRange( + permissionTypes, + unitId, + subUnitId + ); + } + + const { row, column } = singleReferenceToGrid(ref); + if (!Number.isFinite(row) || !Number.isFinite(column)) { + return false; + } + return this._sheetPermissionCheckController.permissionCheckWithRanges({ + ...permissionTypes, + rangeTypes: [RangeProtectionPermissionViewPoint], + }, [{ startRow: row, startColumn: column, endRow: row, endColumn: column }], unitId, subUnitId); + } } diff --git a/packages/sheets-thread-comment-ui/src/controllers/sheets-thread-comment-popup.controller.ts b/packages/sheets-thread-comment-ui/src/controllers/sheets-thread-comment-popup.controller.ts index e4a9eaf7c3..7c78dc4d92 100644 --- a/packages/sheets-thread-comment-ui/src/controllers/sheets-thread-comment-popup.controller.ts +++ b/packages/sheets-thread-comment-ui/src/controllers/sheets-thread-comment-popup.controller.ts @@ -17,15 +17,16 @@ import type { Nullable, Workbook } from '@univerjs/core'; import type { ISelectionWithStyle } from '@univerjs/sheets'; import type { IDeleteCommentMutationParams } from '@univerjs/thread-comment'; +import type { ActiveCommentInfo } from '@univerjs/thread-comment-ui'; import { Disposable, ICommandService, Inject, IUniverInstanceService, RANGE_TYPE, Rectangle, UniverInstanceType } from '@univerjs/core'; import { singleReferenceToGrid } from '@univerjs/engine-formula'; import { IRenderManagerService } from '@univerjs/engine-render'; import { RangeProtectionPermissionViewPoint, SetWorksheetActiveOperation, SheetPermissionCheckController, SheetsSelectionsService, WorkbookCommentPermission, WorksheetViewPermission } from '@univerjs/sheets'; import { SheetsThreadCommentModel } from '@univerjs/sheets-thread-comment'; import { IEditorBridgeService, IMarkSelectionService, ScrollToRangeOperation, SheetSkeletonManagerService } from '@univerjs/sheets-ui'; -import { DeleteCommentMutation } from '@univerjs/thread-comment'; +import { DeleteCommentMutation, deserializeThreadCommentAnchor, ThreadCommentAnchorKind } from '@univerjs/thread-comment'; import { SetActiveCommentOperation, ThreadCommentPanelService } from '@univerjs/thread-comment-ui'; -import { debounceTime } from 'rxjs'; +import { combineLatest, debounceTime } from 'rxjs'; import { SheetsThreadCommentPopupService } from '../services/sheets-thread-comment-popup.service'; interface ISelectionShapeInfo { @@ -35,6 +36,20 @@ interface ISelectionShapeInfo { commentId: string; } +export function resolveFocusedSheetComment( + activeComment: ActiveCommentInfo, + hoveredComment: ActiveCommentInfo, + unitId: string | undefined, + subUnitId: string | undefined +): Exclude | undefined { + for (const comment of [hoveredComment, activeComment]) { + if (comment && comment.unitId === unitId && comment.subUnitId === subUnitId) { + return comment; + } + } + return undefined; +} + export class SheetsThreadCommentPopupController extends Disposable { private _isSwitchToCommenting = false; private _selectionShapeInfo: Nullable = null; @@ -149,6 +164,10 @@ export class SheetsThreadCommentPopupController extends Disposable { if (!comment || comment.resolved) { return; } + const anchor = deserializeThreadCommentAnchor(comment.ref); + if (anchor?.kind === ThreadCommentAnchorKind.SHEET_DRAWING) { + return; + } const currentUnit = this._univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET); if (!currentUnit) { @@ -209,8 +228,20 @@ export class SheetsThreadCommentPopupController extends Disposable { } private _initMarkSelection() { - this.disposeWithMe(this._threadCommentPanelService.activeCommentId$.pipe(debounceTime(100)).subscribe((activeComment) => { - if (!activeComment) { + this.disposeWithMe(combineLatest([ + this._threadCommentPanelService.activeCommentId$, + this._threadCommentPanelService.hoveredCommentId$, + ]).pipe(debounceTime(100)).subscribe(([activeComment, hoveredComment]) => { + const currentUnit = this._univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET); + const currentUnitId = currentUnit?.getUnitId(); + const currentSheetId = currentUnit?.getActiveSheet()?.getSheetId(); + const focusedComment = resolveFocusedSheetComment( + activeComment, + hoveredComment, + currentUnitId, + currentSheetId + ); + if (!focusedComment) { if (this._selectionShapeInfo) { this._markSelectionService.removeShape(this._selectionShapeInfo.shapeId); this._selectionShapeInfo = null; @@ -218,7 +249,7 @@ export class SheetsThreadCommentPopupController extends Disposable { return; } - const { unitId, subUnitId, commentId } = activeComment; + const { unitId, subUnitId, commentId } = focusedComment; if (this._selectionShapeInfo) { this._markSelectionService.removeShape(this._selectionShapeInfo.shapeId); this._selectionShapeInfo = null; @@ -266,7 +297,7 @@ export class SheetsThreadCommentPopupController extends Disposable { } this._selectionShapeInfo = { - ...activeComment, + ...focusedComment, shapeId, }; })); diff --git a/packages/sheets-thread-comment-ui/src/locale/ar-SA.ts b/packages/sheets-thread-comment-ui/src/locale/ar-SA.ts index 3f17e95d45..a41614647a 100644 --- a/packages/sheets-thread-comment-ui/src/locale/ar-SA.ts +++ b/packages/sheets-thread-comment-ui/src/locale/ar-SA.ts @@ -26,6 +26,7 @@ const locale: typeof enUS = { }, menu: { addComment: 'إضافة تعليق', + openComments: 'فتح التعليقات', commentManagement: 'إدارة التعليقات', }, }, diff --git a/packages/sheets-thread-comment-ui/src/locale/ca-ES.ts b/packages/sheets-thread-comment-ui/src/locale/ca-ES.ts index 9f31cdc5ce..ce4b6d9429 100644 --- a/packages/sheets-thread-comment-ui/src/locale/ca-ES.ts +++ b/packages/sheets-thread-comment-ui/src/locale/ca-ES.ts @@ -26,6 +26,7 @@ const locale: typeof enUS = { }, menu: { addComment: 'Afegeix un comentari', + openComments: 'Obre els comentaris', commentManagement: 'Gestió de comentaris', }, }, diff --git a/packages/sheets-thread-comment-ui/src/locale/de-DE.ts b/packages/sheets-thread-comment-ui/src/locale/de-DE.ts index 5262889f58..f4f65655eb 100644 --- a/packages/sheets-thread-comment-ui/src/locale/de-DE.ts +++ b/packages/sheets-thread-comment-ui/src/locale/de-DE.ts @@ -26,6 +26,7 @@ const locale: typeof enUS = { }, menu: { addComment: 'Kommentar hinzufügen', + openComments: 'Kommentare öffnen', commentManagement: 'Kommentarverwaltung', }, }, diff --git a/packages/sheets-thread-comment-ui/src/locale/en-US.ts b/packages/sheets-thread-comment-ui/src/locale/en-US.ts index 6978c0cfd4..49c49c5c4b 100644 --- a/packages/sheets-thread-comment-ui/src/locale/en-US.ts +++ b/packages/sheets-thread-comment-ui/src/locale/en-US.ts @@ -24,6 +24,7 @@ const locale = { }, menu: { addComment: 'Add Comment', + openComments: 'Open Comments', commentManagement: 'Comment Management', }, }, diff --git a/packages/sheets-thread-comment-ui/src/locale/es-ES.ts b/packages/sheets-thread-comment-ui/src/locale/es-ES.ts index c9520a5d48..fec7fda18c 100644 --- a/packages/sheets-thread-comment-ui/src/locale/es-ES.ts +++ b/packages/sheets-thread-comment-ui/src/locale/es-ES.ts @@ -26,6 +26,7 @@ const locale: typeof enUS = { }, menu: { addComment: 'Añadir comentario', + openComments: 'Abrir comentarios', commentManagement: 'Gestión de comentarios', }, }, diff --git a/packages/sheets-thread-comment-ui/src/locale/fa-IR.ts b/packages/sheets-thread-comment-ui/src/locale/fa-IR.ts index 7514c96649..a6852f53c3 100644 --- a/packages/sheets-thread-comment-ui/src/locale/fa-IR.ts +++ b/packages/sheets-thread-comment-ui/src/locale/fa-IR.ts @@ -26,6 +26,7 @@ const locale: typeof enUS = { }, menu: { addComment: 'افزودن نظر', + openComments: 'باز کردن نظرات', commentManagement: 'مدیریت نظر', }, }, diff --git a/packages/sheets-thread-comment-ui/src/locale/fr-FR.ts b/packages/sheets-thread-comment-ui/src/locale/fr-FR.ts index 1d1749041e..6caaa40484 100644 --- a/packages/sheets-thread-comment-ui/src/locale/fr-FR.ts +++ b/packages/sheets-thread-comment-ui/src/locale/fr-FR.ts @@ -26,6 +26,7 @@ const locale: typeof enUS = { }, menu: { addComment: 'Ajouter un commentaire', + openComments: 'Ouvrir les commentaires', commentManagement: 'Gestion des commentaires', }, }, diff --git a/packages/sheets-thread-comment-ui/src/locale/id-ID.ts b/packages/sheets-thread-comment-ui/src/locale/id-ID.ts index 419c88ba19..71383586cb 100644 --- a/packages/sheets-thread-comment-ui/src/locale/id-ID.ts +++ b/packages/sheets-thread-comment-ui/src/locale/id-ID.ts @@ -26,6 +26,7 @@ const locale: typeof enUS = { }, menu: { addComment: 'Tambah Komentar', + openComments: 'Buka Komentar', commentManagement: 'Manajemen Komentar', }, }, diff --git a/packages/sheets-thread-comment-ui/src/locale/it-IT.ts b/packages/sheets-thread-comment-ui/src/locale/it-IT.ts index 99438ae275..3e0788eaf9 100644 --- a/packages/sheets-thread-comment-ui/src/locale/it-IT.ts +++ b/packages/sheets-thread-comment-ui/src/locale/it-IT.ts @@ -26,6 +26,7 @@ const locale: typeof enUS = { }, menu: { addComment: 'Aggiungi commento', + openComments: 'Apri commenti', commentManagement: 'Gestione commenti', }, }, diff --git a/packages/sheets-thread-comment-ui/src/locale/ja-JP.ts b/packages/sheets-thread-comment-ui/src/locale/ja-JP.ts index 2919ed0e8d..40b0295614 100644 --- a/packages/sheets-thread-comment-ui/src/locale/ja-JP.ts +++ b/packages/sheets-thread-comment-ui/src/locale/ja-JP.ts @@ -26,6 +26,7 @@ const locale: typeof enUS = { }, menu: { addComment: 'コメントを追加', + openComments: 'コメントを開く', commentManagement: 'コメント管理', }, }, diff --git a/packages/sheets-thread-comment-ui/src/locale/ko-KR.ts b/packages/sheets-thread-comment-ui/src/locale/ko-KR.ts index d49394967a..3de4bc4a96 100644 --- a/packages/sheets-thread-comment-ui/src/locale/ko-KR.ts +++ b/packages/sheets-thread-comment-ui/src/locale/ko-KR.ts @@ -26,6 +26,7 @@ const locale: typeof enUS = { }, menu: { addComment: '댓글 추가', + openComments: '댓글 열기', commentManagement: '댓글 관리', }, }, diff --git a/packages/sheets-thread-comment-ui/src/locale/pl-PL.ts b/packages/sheets-thread-comment-ui/src/locale/pl-PL.ts index d7da9623b5..9d849857a5 100644 --- a/packages/sheets-thread-comment-ui/src/locale/pl-PL.ts +++ b/packages/sheets-thread-comment-ui/src/locale/pl-PL.ts @@ -26,6 +26,7 @@ const locale: typeof enUS = { }, menu: { addComment: 'Dodaj komentarz', + openComments: 'Otwórz komentarze', commentManagement: 'Zarządzanie komentarzami', }, }, diff --git a/packages/sheets-thread-comment-ui/src/locale/pt-BR.ts b/packages/sheets-thread-comment-ui/src/locale/pt-BR.ts index 40a04b7ba2..b657ae09d1 100644 --- a/packages/sheets-thread-comment-ui/src/locale/pt-BR.ts +++ b/packages/sheets-thread-comment-ui/src/locale/pt-BR.ts @@ -26,6 +26,7 @@ const locale: typeof enUS = { }, menu: { addComment: 'Adicionar Comentário', + openComments: 'Abrir comentários', commentManagement: 'Gerenciamento de Comentários', }, }, diff --git a/packages/sheets-thread-comment-ui/src/locale/ru-RU.ts b/packages/sheets-thread-comment-ui/src/locale/ru-RU.ts index 04c5e68c6e..7b9528de1b 100644 --- a/packages/sheets-thread-comment-ui/src/locale/ru-RU.ts +++ b/packages/sheets-thread-comment-ui/src/locale/ru-RU.ts @@ -26,6 +26,7 @@ const locale: typeof enUS = { }, menu: { addComment: 'Добавить комментарий', + openComments: 'Открыть комментарии', commentManagement: 'Управление комментариями', }, }, diff --git a/packages/sheets-thread-comment-ui/src/locale/sk-SK.ts b/packages/sheets-thread-comment-ui/src/locale/sk-SK.ts index 92fc5fa882..f9abca9693 100644 --- a/packages/sheets-thread-comment-ui/src/locale/sk-SK.ts +++ b/packages/sheets-thread-comment-ui/src/locale/sk-SK.ts @@ -26,6 +26,7 @@ const locale: typeof enUS = { }, menu: { addComment: 'Pridať komentár', + openComments: 'Otvoriť komentáre', commentManagement: 'Správa komentárov', }, }, diff --git a/packages/sheets-thread-comment-ui/src/locale/vi-VN.ts b/packages/sheets-thread-comment-ui/src/locale/vi-VN.ts index 5e90ed9602..e6e13ff853 100644 --- a/packages/sheets-thread-comment-ui/src/locale/vi-VN.ts +++ b/packages/sheets-thread-comment-ui/src/locale/vi-VN.ts @@ -26,6 +26,7 @@ const locale: typeof enUS = { }, menu: { addComment: 'Thêm bình luận', + openComments: 'Mở bình luận', commentManagement: 'Quản lý bình luận', }, }, diff --git a/packages/sheets-thread-comment-ui/src/locale/zh-CN.ts b/packages/sheets-thread-comment-ui/src/locale/zh-CN.ts index b10833a874..5a9bd07344 100644 --- a/packages/sheets-thread-comment-ui/src/locale/zh-CN.ts +++ b/packages/sheets-thread-comment-ui/src/locale/zh-CN.ts @@ -26,6 +26,7 @@ const locale: typeof enUS = { }, menu: { addComment: '添加评论', + openComments: '打开评论', commentManagement: '评论管理', }, }, diff --git a/packages/sheets-thread-comment-ui/src/locale/zh-HK.ts b/packages/sheets-thread-comment-ui/src/locale/zh-HK.ts index 734aa56628..eabef33e21 100644 --- a/packages/sheets-thread-comment-ui/src/locale/zh-HK.ts +++ b/packages/sheets-thread-comment-ui/src/locale/zh-HK.ts @@ -26,6 +26,7 @@ const locale: typeof enUS = { }, menu: { addComment: '新增評論', + openComments: '開啟評論', commentManagement: '評論管理', }, }, diff --git a/packages/sheets-thread-comment-ui/src/locale/zh-TW.ts b/packages/sheets-thread-comment-ui/src/locale/zh-TW.ts index 734aa56628..eabef33e21 100644 --- a/packages/sheets-thread-comment-ui/src/locale/zh-TW.ts +++ b/packages/sheets-thread-comment-ui/src/locale/zh-TW.ts @@ -26,6 +26,7 @@ const locale: typeof enUS = { }, menu: { addComment: '新增評論', + openComments: '開啟評論', commentManagement: '評論管理', }, }, diff --git a/packages/sheets-thread-comment-ui/src/menu/menu.ts b/packages/sheets-thread-comment-ui/src/menu/menu.ts index 0b1ee281af..d9f02cb36c 100644 --- a/packages/sheets-thread-comment-ui/src/menu/menu.ts +++ b/packages/sheets-thread-comment-ui/src/menu/menu.ts @@ -20,13 +20,22 @@ import { UniverInstanceType } from '@univerjs/core'; import { RangeProtectionPermissionViewPoint, WorkbookCommentPermission, WorksheetViewPermission } from '@univerjs/sheets'; import { getCurrentRangeDisable$, whenSheetEditorFocused } from '@univerjs/sheets-ui'; import { getMenuHiddenObservable, KeyCode, MenuItemType, MetaKeys } from '@univerjs/ui'; -import { ShowAddSheetCommentModalOperation, ToggleSheetCommentPanelOperation } from '../commands/operations/comment.operation'; +import { AddSheetDrawingCommentOperation, ShowAddSheetCommentModalOperation, ToggleSheetCommentPanelOperation } from '../commands/operations/comment.operation'; + +export const drawingCommentMenuFactory = (accessor: IAccessor) => ({ + id: AddSheetDrawingCommentOperation.id, + type: MenuItemType.BUTTON, + icon: 'InsertCommentDoubleIcon', + title: 'sheets-thread-comment-ui.menu.addComment', + tooltip: 'sheets-thread-comment-ui.menu.addComment', + hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET), +}); export const threadCommentMenuFactory = (accessor: IAccessor) => { return { id: ShowAddSheetCommentModalOperation.id, type: MenuItemType.BUTTON, - icon: 'CommentIcon', + icon: 'InsertCommentDoubleIcon', title: 'sheets-thread-comment-ui.menu.addComment', hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET), disabled$: getCurrentRangeDisable$(accessor, { @@ -42,7 +51,8 @@ export const threadPanelMenuFactory = (accessor: IAccessor) => { id: ToggleSheetCommentPanelOperation.id, type: MenuItemType.BUTTON, icon: 'CommentIcon', - tooltip: 'sheets-thread-comment-ui.menu.commentManagement', + title: 'sheets-thread-comment-ui.menu.openComments', + tooltip: 'sheets-thread-comment-ui.menu.openComments', disabled$: getCurrentRangeDisable$(accessor, { workbookTypes: [WorkbookCommentPermission], worksheetTypes: [WorksheetViewPermission], diff --git a/packages/sheets-thread-comment-ui/src/menu/schema.ts b/packages/sheets-thread-comment-ui/src/menu/schema.ts index e5578238e5..31408e3072 100644 --- a/packages/sheets-thread-comment-ui/src/menu/schema.ts +++ b/packages/sheets-thread-comment-ui/src/menu/schema.ts @@ -15,17 +15,22 @@ */ import type { MenuSchemaType } from '@univerjs/ui'; -import { ContextMenuGroup, ContextMenuPosition, RibbonInsertGroup } from '@univerjs/ui'; -import { ShowAddSheetCommentModalOperation, ToggleSheetCommentPanelOperation } from '../commands/operations/comment.operation'; -import { threadCommentMenuFactory, threadPanelMenuFactory } from './menu'; +import { ContextMenuGroup, ContextMenuPosition, FloatingObjectToolbarPosition, RibbonInsertGroup } from '@univerjs/ui'; +import { AddSheetDrawingCommentOperation, ShowAddSheetCommentModalOperation, ToggleSheetCommentPanelOperation } from '../commands/operations/comment.operation'; +import { drawingCommentMenuFactory, threadCommentMenuFactory, threadPanelMenuFactory } from './menu'; export const menuSchema: MenuSchemaType = { [RibbonInsertGroup.MEDIA]: { [ToggleSheetCommentPanelOperation.id]: { order: 2, - gridLayout: { row: 1, column: 3, rowSpan: 2, showLabel: true }, + gridLayout: { row: 1, column: 3, showLabel: true }, menuItemFactory: threadPanelMenuFactory, }, + [ShowAddSheetCommentModalOperation.id]: { + order: 2.1, + gridLayout: { row: 2, column: 3, showLabel: true }, + menuItemFactory: threadCommentMenuFactory, + }, }, [ContextMenuPosition.MAIN_AREA]: { [ContextMenuGroup.OTHERS]: { @@ -35,4 +40,18 @@ export const menuSchema: MenuSchemaType = { }, }, }, + [FloatingObjectToolbarPosition.SHEET]: { + [AddSheetDrawingCommentOperation.id]: { + order: 10, + menuItemFactory: drawingCommentMenuFactory, + }, + }, + [ContextMenuPosition.DRAWING]: { + [ContextMenuGroup.OTHERS]: { + [AddSheetDrawingCommentOperation.id]: { + order: 0, + menuItemFactory: drawingCommentMenuFactory, + }, + }, + }, }; diff --git a/packages/sheets-thread-comment-ui/src/plugin.ts b/packages/sheets-thread-comment-ui/src/plugin.ts index 9e69d16108..252bb209d2 100644 --- a/packages/sheets-thread-comment-ui/src/plugin.ts +++ b/packages/sheets-thread-comment-ui/src/plugin.ts @@ -17,16 +17,18 @@ import type { Dependency } from '@univerjs/core'; import type { IUniverSheetsThreadCommentUIConfig } from './config/config'; import { DependentOn, ICommandService, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core'; -import { UniverRenderEnginePlugin } from '@univerjs/engine-render'; +import { UniverDrawingPlugin } from '@univerjs/drawing'; +import { IRenderManagerService, UniverRenderEnginePlugin } from '@univerjs/engine-render'; import { UniverSheetsPlugin } from '@univerjs/sheets'; import { UniverSheetsThreadCommentPlugin } from '@univerjs/sheets-thread-comment'; import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui'; import { UniverThreadCommentPlugin } from '@univerjs/thread-comment'; import { UniverThreadCommentUIPlugin } from '@univerjs/thread-comment-ui'; import pkg from '../package.json'; -import { ShowAddSheetCommentModalOperation, ToggleSheetCommentPanelOperation } from './commands/operations/comment.operation'; +import { AddSheetDrawingCommentOperation, OpenSheetCommentPanelOperation, ShowAddSheetCommentModalOperation, ToggleSheetCommentPanelOperation } from './commands/operations/comment.operation'; import { defaultPluginConfig, SHEETS_THREAD_COMMENT_UI_PLUGIN_CONFIG_KEY } from './config/config'; import { ComponentsController } from './controllers/components.controller'; +import { SheetsThreadCommentDrawingRenderController } from './controllers/render-controllers/drawing.render-controller'; import { SheetsThreadCommentRenderController } from './controllers/render-controllers/render.controller'; import { SheetsThreadCommentCopyPasteController } from './controllers/sheets-thread-comment-copy-paste.controller'; import { SheetsThreadCommentHoverController } from './controllers/sheets-thread-comment-hover.controller'; @@ -39,6 +41,7 @@ import { PLUGIN_NAME } from './types/const'; @DependentOn( UniverRenderEnginePlugin, UniverThreadCommentPlugin, + UniverDrawingPlugin, UniverSheetsPlugin, UniverThreadCommentUIPlugin, UniverSheetsThreadCommentPlugin, @@ -54,6 +57,7 @@ export class UniverSheetsThreadCommentUIPlugin extends Plugin { private readonly _config: Partial = defaultPluginConfig, @Inject(Injector) protected override _injector: Injector, @Inject(ICommandService) protected _commandService: ICommandService, + @IRenderManagerService private readonly _renderManagerService: IRenderManagerService, @IConfigService private readonly _configService: IConfigService ) { super(); @@ -86,6 +90,8 @@ export class UniverSheetsThreadCommentUIPlugin extends Plugin { }); [ + AddSheetDrawingCommentOperation, + OpenSheetCommentPanelOperation, ShowAddSheetCommentModalOperation, ToggleSheetCommentPanelOperation, ].forEach((command) => { @@ -100,6 +106,10 @@ export class UniverSheetsThreadCommentUIPlugin extends Plugin { } override onRendered(): void { + this._renderManagerService.registerRenderModule( + UniverInstanceType.UNIVER_SHEET, + [SheetsThreadCommentDrawingRenderController] + ); this._injector.get(SheetsThreadCommentCopyPasteController); this._injector.get(SheetsThreadCommentHoverController); this._injector.get(SheetsThreadCommentPopupController); diff --git a/packages/sheets-thread-comment-ui/src/views/SheetsThreadCommentPanel.tsx b/packages/sheets-thread-comment-ui/src/views/SheetsThreadCommentPanel.tsx index 12f0ee4325..5b1be18e5a 100644 --- a/packages/sheets-thread-comment-ui/src/views/SheetsThreadCommentPanel.tsx +++ b/packages/sheets-thread-comment-ui/src/views/SheetsThreadCommentPanel.tsx @@ -16,10 +16,11 @@ import type { Workbook } from '@univerjs/core'; import type { IThreadComment } from '@univerjs/thread-comment'; -import { ICommandService, IUniverInstanceService, UniverInstanceType } from '@univerjs/core'; +import { ICommandService, IUniverInstanceService, UniverInstanceType, UserManagerService } from '@univerjs/core'; import { singleReferenceToGrid } from '@univerjs/engine-formula'; import { IMarkSelectionService } from '@univerjs/sheets-ui'; -import { ThreadCommentPanel, ThreadCommentPanelService } from '@univerjs/thread-comment-ui'; +import { deserializeThreadCommentAnchor, serializeThreadCommentAnchor, ThreadCommentAnchorKind } from '@univerjs/thread-comment'; +import { ThreadCommentDraftService, ThreadCommentPanel, ThreadCommentPanelService } from '@univerjs/thread-comment-ui'; import { useDependency, useObservable } from '@univerjs/ui'; import { useCallback, useEffect, useMemo, useRef } from 'react'; import { map } from 'rxjs'; @@ -33,6 +34,21 @@ export const SheetsThreadCommentPanel = () => { const workbook = univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_SHEET)!; const unitId = workbook.getUnitId(); const commandService = useDependency(ICommandService); + const draftService = useDependency(ThreadCommentDraftService); + const userManagerService = useDependency(UserManagerService); + const draft = useObservable(draftService.draft$, draftService.draft); + const tempDrawingComment = draft?.anchor.kind === ThreadCommentAnchorKind.SHEET_DRAWING && draft.unitId === unitId + ? { + id: '', + threadId: '', + unitId: draft.unitId, + subUnitId: draft.subUnitId, + ref: serializeThreadCommentAnchor(draft.anchor), + dT: '', + personId: userManagerService.getCurrentUser().userID, + text: { dataStream: '\r\n' }, + } + : null; const subUnitId$ = useMemo(() => workbook.activeSheet$.pipe(map((i) => i?.getSheetId())), [workbook.activeSheet$]); const subUnitId = useObservable(subUnitId$, workbook.getActiveSheet()?.getSheetId()); const hoverShapeId = useRef(null); @@ -48,6 +64,10 @@ export const SheetsThreadCommentPanel = () => { const sort = (comments: IThreadComment[]) => { return comments.map((comment) => { + const anchor = deserializeThreadCommentAnchor(comment.ref); + if (anchor?.kind === ThreadCommentAnchorKind.SHEET_DRAWING) { + return { ...comment, p: [sheetIndex[comment.subUnitId] ?? 0, Number.MAX_SAFE_INTEGER, 0] }; + } const ref = singleReferenceToGrid(comment.ref); const p = [sheetIndex[comment.subUnitId] ?? 0, ref.row, ref.column]; return { ...comment, p }; @@ -70,6 +90,9 @@ export const SheetsThreadCommentPanel = () => { }, [workbook]); const showShape = useCallback((comment: IThreadComment) => { + if (deserializeThreadCommentAnchor(comment.ref)) { + return null; + } if (comment.unitId === unitId && comment.subUnitId === subUnitId && !comment.resolved) { const { row, column } = singleReferenceToGrid(comment.ref); const worksheet = workbook.getSheetBySheetId(comment.subUnitId); @@ -157,6 +180,12 @@ export const SheetsThreadCommentPanel = () => { handleLeave(); return true; }} + tempComment={tempDrawingComment} + onTempCommentClose={() => draftService.cancel()} + formatRef={(comment) => { + const anchor = deserializeThreadCommentAnchor(comment.ref); + return anchor?.kind === ThreadCommentAnchorKind.SHEET_DRAWING ? `#${anchor.elementId}` : comment.ref; + }} /> ); }; diff --git a/packages/sheets-thread-comment-ui/src/views/__tests__/SheetsThreadCommentPanel.spec.tsx b/packages/sheets-thread-comment-ui/src/views/__tests__/SheetsThreadCommentPanel.spec.tsx index e32a1b487e..101d701190 100644 --- a/packages/sheets-thread-comment-ui/src/views/__tests__/SheetsThreadCommentPanel.spec.tsx +++ b/packages/sheets-thread-comment-ui/src/views/__tests__/SheetsThreadCommentPanel.spec.tsx @@ -56,7 +56,7 @@ import { ThreadCommentDataSourceService, ThreadCommentModel, } from '@univerjs/thread-comment'; -import { SetActiveCommentOperation, ThreadCommentPanelService } from '@univerjs/thread-comment-ui'; +import { SetActiveCommentOperation, ThreadCommentDraftService, ThreadCommentPanelService } from '@univerjs/thread-comment-ui'; import threadCommentEnUS from '@univerjs/thread-comment-ui/locale/en-US'; import { IShortcutService, ISidebarService, RediContext } from '@univerjs/ui'; import { act } from 'react'; @@ -486,6 +486,7 @@ function createTestBed(testWorkbookData: IWorkbookData = workbookData) { injector.add([SheetsSelectionsService]); injector.add([SheetsThreadCommentPopupService]); injector.add([ISidebarService, { useClass: TestSidebarService as never }]); + injector.add([ThreadCommentDraftService]); injector.add([ThreadCommentPanelService]); univer.createUnit(UniverInstanceType.UNIVER_SHEET, testWorkbookData); @@ -700,7 +701,7 @@ describe('SheetsThreadCommentPanel', () => { expect(container.textContent).not.toContain('Sheet one B2'); }); - it('highlights only unresolved comments on the current sheet and closes the cell popup when resolved', () => { + it('highlights only unresolved comments on the current sheet and closes the cell popup when resolved', async () => { const testBed = createTestBed(); univer = testBed.univer; testBed.threadCommentModel.addComment(unitId, sheet1, createComment('current-sheet-thread', sheet1, 'B2', 'Current sheet B2')); @@ -750,8 +751,9 @@ describe('SheetsThreadCommentPanel', () => { const resolveButton = currentThread!.querySelector('.univer-flex-shrink-0 .univer-cursor-pointer'); expect(resolveButton).toBeInstanceOf(HTMLElement); - act(() => { + await act(async () => { resolveButton!.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); }); expect(testBed.popupService.activePopup).toBeNull(); diff --git a/packages/sheets-thread-comment/src/controllers/sheets-thread-comment-ref-range.controller.ts b/packages/sheets-thread-comment/src/controllers/sheets-thread-comment-ref-range.controller.ts index 3225150761..768ef35305 100644 --- a/packages/sheets-thread-comment/src/controllers/sheets-thread-comment-ref-range.controller.ts +++ b/packages/sheets-thread-comment/src/controllers/sheets-thread-comment-ref-range.controller.ts @@ -21,7 +21,7 @@ import type { ISheetThreadComment } from '../types/interfaces/i-sheet-thread-com import { Disposable, ICommandService, Inject, sequenceExecuteAsync, toDisposable } from '@univerjs/core'; import { serializeRange, singleReferenceToGrid } from '@univerjs/engine-formula'; import { handleCommonRangeChangeWithEffectRefCommandsSkipNoInterests, RefRangeService, SheetsSelectionsService } from '@univerjs/sheets'; -import { AddCommentMutation, DeleteCommentMutation, ThreadCommentModel, UpdateCommentRefMutation } from '@univerjs/thread-comment'; +import { AddCommentMutation, DeleteCommentMutation, deserializeThreadCommentAnchor, ThreadCommentModel, UpdateCommentRefMutation } from '@univerjs/thread-comment'; import { SheetsThreadCommentModel } from '../models/sheets-thread-comment.model'; export class SheetsThreadCommentRefRangeController extends Disposable { @@ -163,6 +163,9 @@ export class SheetsThreadCommentRefRangeController extends Disposable { for (const data of datas) { for (const thread of data.threads) { const { unitId, subUnitId, root } = thread; + if (deserializeThreadCommentAnchor(root.ref)) { + continue; + } const pos = singleReferenceToGrid(root.ref); const sheetComment = { ...root, diff --git a/packages/sheets-thread-comment/src/facade/__tests__/f-range.spec.ts b/packages/sheets-thread-comment/src/facade/__tests__/f-range.spec.ts index 8c74c09c7d..1766fce9f1 100644 --- a/packages/sheets-thread-comment/src/facade/__tests__/f-range.spec.ts +++ b/packages/sheets-thread-comment/src/facade/__tests__/f-range.spec.ts @@ -67,6 +67,13 @@ describe('FRangeSheetsThreadCommentMixin', () => { getCurrentUser = () => ({ userID: 'current-user' }); } + const worksheet = { + getSheetId: () => 'sheet-1', + getRowCount: () => 100, + getColumnCount: () => 100, + }; + const workbook = { getUnitId: () => 'book-1', getSheetBySheetId: () => worksheet }; + class TestUniverInstanceService { getUnit = (_unitId: string, type: UniverInstanceType) => (type === UniverInstanceType.UNIVER_SHEET ? workbook : null); } @@ -87,12 +94,6 @@ describe('FRangeSheetsThreadCommentMixin', () => { injector.add([FormulaDataModel, { useClass: TestFormulaDataModel as never }]); injector.add([ILogService, { useClass: TestLogService as never }]); - const workbook = { getUnitId: () => 'book-1', getSheetBySheetId: () => worksheet }; - const worksheet = { - getSheetId: () => 'sheet-1', - getRowCount: () => 100, - getColumnCount: () => 100, - }; const range = injector.createInstance( FRangeSheetsThreadCommentMixin, workbook as never, @@ -115,6 +116,18 @@ describe('FRangeSheetsThreadCommentMixin', () => { }), })); + await expect(range.addCommentAsync('Agent review', { + id: 'agent-comment', + personId: 'agent-user', + })).resolves.toBe(true); + expect(commandService.executeCommand).toHaveBeenCalledWith(AddCommentCommand.id, expect.objectContaining({ + comment: expect.objectContaining({ + id: 'agent-comment', + personId: 'agent-user', + text: expect.objectContaining({ dataStream: 'Agent review\r\n' }), + }), + })); + await expect(range.clearCommentAsync()).resolves.toBe(true); expect(commandService.executeCommand).toHaveBeenCalledWith(DeleteCommentTreeCommand.id, { unitId: 'book-1', diff --git a/packages/sheets-thread-comment/src/facade/f-range.ts b/packages/sheets-thread-comment/src/facade/f-range.ts index 218592456a..325820d672 100644 --- a/packages/sheets-thread-comment/src/facade/f-range.ts +++ b/packages/sheets-thread-comment/src/facade/f-range.ts @@ -14,14 +14,32 @@ * limitations under the License. */ -import type { IDocumentBody, Nullable } from '@univerjs/core'; -import type { IThreadComment } from '@univerjs/thread-comment'; -import { generateRandomId, ICommandService, Range, Tools, UserManagerService } from '@univerjs/core'; +import * as UniverCore from '@univerjs/core'; import { SheetsThreadCommentModel } from '@univerjs/sheets-thread-comment'; import { FRange } from '@univerjs/sheets/facade'; -import { AddCommentCommand, DeleteCommentTreeCommand, getDT } from '@univerjs/thread-comment'; +import * as ThreadComment from '@univerjs/thread-comment'; import { FTheadCommentBuilder, FThreadComment } from './f-thread-comment'; +export type ISheetCellCommentCreateOptions = Pick< + ThreadComment.ICreateThreadCommentOptions, + 'attachments' | 'dateTime' | 'id' | 'personId' | 'threadId' +>; + +interface IRangeCommentDependencies { + commandService: UniverCore.ICommandService; + model: SheetsThreadCommentModel; + userManagerService: UniverCore.UserManagerService; +} + +function firstNonEmpty(...values: Array): string { + for (const value of values) { + if (value) { + return value; + } + } + return UniverCore.generateRandomId(); +} + /** * @ignore */ @@ -37,7 +55,7 @@ export interface IFRangeSheetsThreadCommentMixin { * const comment = range.getComment(); * ``` */ - getComment(): Nullable; + getComment(): UniverCore.Nullable; /** * Get the comments in the current range. @@ -58,9 +76,16 @@ export interface IFRangeSheetsThreadCommentMixin { /** * Add a comment to the start cell in the current range. * @param content The content of the comment. + * @param options Optional stable IDs, author, attachments, and creation time. * @returns Whether the comment is added successfully. + * @throws {TypeError} If the content is empty. * @example * ```ts + * await univerAPI.getActiveWorkbook() + * .getActiveSheet() + * .getRange('A1') + * .addCommentAsync('Verify this value.', { id: 'review-a1' }); + * * // Create a new comment * const richText = univerAPI.newRichText().insertText('hello univer'); * const commentBuilder = univerAPI.newTheadComment() @@ -76,12 +101,21 @@ export interface IFRangeSheetsThreadCommentMixin { * console.log(result); * ``` */ - addCommentAsync(content: IDocumentBody | FTheadCommentBuilder): Promise; + addCommentAsync( + content: ThreadComment.ThreadCommentContent | FTheadCommentBuilder, + options?: ISheetCellCommentCreateOptions + ): Promise; - /** - * Clear the comment of the start cell in the current range. - * @returns Whether the comment is cleared successfully. - */ + /** + * Clear the comment of the start cell in the current range. + * @returns Whether the comment is cleared successfully. + * @example + * ```ts + * const range = univerAPI.getActiveWorkbook().getActiveSheet().getRange('A1'); + * const success = await range.clearCommentAsync(); + * console.log(success); + * ``` + */ clearCommentAsync(): Promise; /** @@ -102,81 +136,96 @@ export interface IFRangeSheetsThreadCommentMixin { * @ignore */ export class FRangeSheetsThreadCommentMixin extends FRange implements IFRangeSheetsThreadCommentMixin { - override getComment(): Nullable { - const injector = this._injector; - const sheetsTheadCommentModel = injector.get(SheetsThreadCommentModel); - const unitId = this._workbook.getUnitId(); - const sheetId = this._worksheet.getSheetId(); - const commentId = sheetsTheadCommentModel.getByLocation(unitId, sheetId, this._range.startRow, this._range.startColumn); - if (!commentId) { - return null; - } + declare private _dependencies: IRangeCommentDependencies; - const comment = sheetsTheadCommentModel.getComment(unitId, sheetId, commentId); - if (comment) { - return this._injector.createInstance(FThreadComment, comment); - } - - return null; + override _initialize(injector: UniverCore.Injector): void { + let dependencies: IRangeCommentDependencies | undefined; + Object.defineProperty(this, '_dependencies', { + get: () => dependencies ??= { + commandService: injector.get(UniverCore.ICommandService), + model: injector.get(SheetsThreadCommentModel), + userManagerService: injector.get(UniverCore.UserManagerService), + }, + }); } - override getComments(): FThreadComment[] { - const injector = this._injector; - const sheetsTheadCommentModel = injector.get(SheetsThreadCommentModel); + private _getCommentDataInRange(): ThreadComment.IThreadComment[] { + const model = this._dependencies.model; const unitId = this._workbook.getUnitId(); const sheetId = this._worksheet.getSheetId(); - const comments: FThreadComment[] = []; - Range.foreach(this._range, (row, col) => { - const commentId = sheetsTheadCommentModel.getByLocation(unitId, sheetId, row, col); - if (commentId) { - const comment = sheetsTheadCommentModel.getComment(unitId, sheetId, commentId); - if (comment) { - comments.push(this._injector.createInstance(FThreadComment, comment)); - } + const comments: ThreadComment.IThreadComment[] = []; + UniverCore.Range.foreach(this._range, (row, col) => { + const commentId = model.getByLocation(unitId, sheetId, row, col); + const comment = commentId ? model.getComment(unitId, sheetId, commentId) : null; + if (comment) { + comments.push(comment); } }); - return comments; } - override addCommentAsync(content: IDocumentBody | FTheadCommentBuilder): Promise { - const injector = this._injector; - const currentComment = this.getComment()?.getCommentData(); - const commentService = injector.get(ICommandService); - const userService = injector.get(UserManagerService); + private _getStartCellCommentData(): ThreadComment.IThreadComment | null { + const model = this._dependencies.model; const unitId = this._workbook.getUnitId(); const sheetId = this._worksheet.getSheetId(); - const refStr = `${Tools.chatAtABC(this._range.startColumn)}${this._range.startRow + 1}`; - const currentUser = userService.getCurrentUser(); - const commentData: Partial = content instanceof FTheadCommentBuilder ? content.build() : { text: content }; + const commentId = model.getByLocation(unitId, sheetId, this._range.startRow, this._range.startColumn); + return commentId ? model.getComment(unitId, sheetId, commentId) ?? null : null; + } - return commentService.executeCommand(AddCommentCommand.id, { + override getComment(): UniverCore.Nullable { + const comment = this._getStartCellCommentData(); + return comment ? this._injector.createInstance(FThreadComment, comment) : null; + } + + override getComments(): FThreadComment[] { + return this._getCommentDataInRange().map((comment) => this._injector.createInstance(FThreadComment, comment)); + } + + override addCommentAsync( + content: ThreadComment.ThreadCommentContent | FTheadCommentBuilder, + options: ISheetCellCommentCreateOptions = {} + ): Promise { + const { commandService, userManagerService } = this._dependencies; + const currentComment = this._getStartCellCommentData(); + const unitId = this._workbook.getUnitId(); + const sheetId = this._worksheet.getSheetId(); + const refStr = `${UniverCore.Tools.chatAtABC(this._range.startColumn)}${this._range.startRow + 1}`; + const currentUser = userManagerService.getCurrentUser(); + let commentData: ThreadComment.IThreadComment | null = null; + let text: UniverCore.IDocumentBody; + if (content instanceof FTheadCommentBuilder) { + commentData = content.build(); + text = commentData.text; + } else { + text = ThreadComment.normalizeThreadCommentContent(content); + } + + return commandService.executeCommand(ThreadComment.AddCommentCommand.id, { unitId, subUnitId: sheetId, comment: { - text: commentData.text, - dT: commentData.dT || getDT(), - attachments: [], - id: commentData.id || generateRandomId(), + text, + dT: options.dateTime ? ThreadComment.getDT(options.dateTime) : commentData?.dT || ThreadComment.getDT(), + attachments: options.attachments ?? commentData?.attachments ?? [], + id: firstNonEmpty(options.id, commentData?.id), ref: refStr, - personId: commentData.personId || currentUser.userID, + personId: firstNonEmpty(options.personId, commentData?.personId, currentUser.userID), parentId: currentComment?.id, unitId, subUnitId: sheetId, - threadId: currentComment?.threadId || generateRandomId(), + threadId: firstNonEmpty(currentComment?.threadId, options.threadId, commentData?.threadId), }, }); } override clearCommentAsync(): Promise { - const injector = this._injector; - const currentComment = this.getComment()?.getCommentData(); - const commentService = injector.get(ICommandService); + const { commandService } = this._dependencies; + const currentComment = this._getStartCellCommentData(); const unitId = this._workbook.getUnitId(); const sheetId = this._worksheet.getSheetId(); if (currentComment) { - return commentService.executeCommand(DeleteCommentTreeCommand.id, { + return commandService.executeCommand(ThreadComment.DeleteCommentTreeCommand.id, { unitId, subUnitId: sheetId, threadId: currentComment.threadId, @@ -187,16 +236,19 @@ export class FRangeSheetsThreadCommentMixin extends FRange implements IFRangeShe return Promise.resolve(true); } - override clearCommentsAsync(): Promise { - const comments = this.getComments(); - const promises = comments.map((comment) => comment.deleteAsync()); - - return Promise.all(promises).then(() => true); + override async clearCommentsAsync(): Promise { + const { commandService } = this._dependencies; + const unitId = this._workbook.getUnitId(); + const subUnitId = this._worksheet.getSheetId(); + const results = await Promise.all(this._getCommentDataInRange().map((comment) => commandService.executeCommand( + ThreadComment.DeleteCommentTreeCommand.id, + { unitId, subUnitId, threadId: comment.threadId, commentId: comment.id } + ))); + return results.every(Boolean); } } FRange.extend(FRangeSheetsThreadCommentMixin); declare module '@univerjs/sheets/facade' { - // eslint-disable-next-line ts/naming-convention interface FRange extends IFRangeSheetsThreadCommentMixin { } } diff --git a/packages/sheets-thread-comment/src/models/sheets-thread-comment.model.ts b/packages/sheets-thread-comment/src/models/sheets-thread-comment.model.ts index 44401a365f..76e9407c4e 100644 --- a/packages/sheets-thread-comment/src/models/sheets-thread-comment.model.ts +++ b/packages/sheets-thread-comment/src/models/sheets-thread-comment.model.ts @@ -17,7 +17,7 @@ import type { CommentUpdate, IThreadComment } from '@univerjs/thread-comment'; import { Disposable, Inject, IUniverInstanceService, ObjectMatrix, UniverInstanceType } from '@univerjs/core'; import { singleReferenceToGrid } from '@univerjs/engine-formula'; -import { ThreadCommentModel } from '@univerjs/thread-comment'; +import { deserializeThreadCommentAnchor, ThreadCommentModel } from '@univerjs/thread-comment'; import { Subject } from 'rxjs'; export type SheetCommentUpdate = CommentUpdate & { @@ -118,6 +118,9 @@ export class SheetsThreadCommentModel extends Disposable { } private _addComment(unitId: string, subUnitId: string, comment: IThreadComment) { + if (deserializeThreadCommentAnchor(comment.ref)) { + return; + } const location = singleReferenceToGrid(comment.ref); const parentId = comment.parentId; const { row, column } = location; @@ -163,6 +166,9 @@ export class SheetsThreadCommentModel extends Disposable { } case 'delete': { const { isRoot, comment } = update.payload; + if (deserializeThreadCommentAnchor(comment.ref)) { + return; + } if (isRoot) { const location = singleReferenceToGrid(comment.ref); const { row, column } = location; @@ -180,6 +186,9 @@ export class SheetsThreadCommentModel extends Disposable { if (!comment) { return; } + if (deserializeThreadCommentAnchor(comment.ref)) { + return; + } const location = singleReferenceToGrid(comment.ref); this._commentUpdate$.next({ ...update, @@ -188,6 +197,9 @@ export class SheetsThreadCommentModel extends Disposable { break; } case 'updateRef': { + if (deserializeThreadCommentAnchor(update.payload.ref)) { + return; + } const location = singleReferenceToGrid(update.payload.ref); const { commentId } = update.payload; const currentLoc = locationMap.get(commentId); diff --git a/packages/thread-comment-ui/package.json b/packages/thread-comment-ui/package.json index 3c5b5dbc8d..3e8aff673d 100644 --- a/packages/thread-comment-ui/package.json +++ b/packages/thread-comment-ui/package.json @@ -77,13 +77,13 @@ "@univerjs/core": "workspace:*", "@univerjs/design": "workspace:*", "@univerjs/docs-ui": "workspace:*", + "@univerjs/engine-render": "workspace:*", "@univerjs/icons": "1.38.0", "@univerjs/thread-comment": "workspace:*", "@univerjs/ui": "workspace:*" }, "devDependencies": { "@univerjs-infra/shared": "workspace:*", - "@univerjs/engine-render": "workspace:*", "postcss": "^8.5.26", "react": "18.3.1", "rxjs": "^7.8.2", diff --git a/packages/thread-comment-ui/src/index.ts b/packages/thread-comment-ui/src/index.ts index dee9e9b650..a1ceb38e73 100644 --- a/packages/thread-comment-ui/src/index.ts +++ b/packages/thread-comment-ui/src/index.ts @@ -20,6 +20,13 @@ export { SetActiveCommentOperation } from './commands/operations/comment.operati export type { ISetActiveCommentOperationParams } from './commands/operations/comment.operations'; export type { IUniverThreadCommentUIConfig } from './config/config'; export { UniverThreadCommentUIPlugin } from './plugin'; +export { ThreadCommentCanvasOverlay } from './render-objects/thread-comment-canvas-overlay'; +export type { + IThreadCommentCanvasOutline, + IThreadCommentCanvasUnderline, +} from './render-objects/thread-comment-canvas-overlay'; +export { ThreadCommentDraftService } from './services/thread-comment-draft.service'; +export type { IThreadCommentDraft } from './services/thread-comment-draft.service'; export { ThreadCommentPanelService } from './services/thread-comment-panel.service'; export type { ActiveCommentInfo } from './services/thread-comment-panel.service'; export { ThreadCommentPanel } from './views/ThreadCommentPanel'; diff --git a/packages/thread-comment-ui/src/locale/ar-SA.ts b/packages/thread-comment-ui/src/locale/ar-SA.ts index d1be0c4a14..e34bb42566 100644 --- a/packages/thread-comment-ui/src/locale/ar-SA.ts +++ b/packages/thread-comment-ui/src/locale/ar-SA.ts @@ -33,6 +33,7 @@ const locale: typeof enUS = { item: { edit: 'تحرير', delete: 'حذف هذا التعليق', + more: 'المزيد', }, filter: { sheet: { diff --git a/packages/thread-comment-ui/src/locale/ca-ES.ts b/packages/thread-comment-ui/src/locale/ca-ES.ts index 6d0bf2217f..44a5b0d97b 100644 --- a/packages/thread-comment-ui/src/locale/ca-ES.ts +++ b/packages/thread-comment-ui/src/locale/ca-ES.ts @@ -33,6 +33,7 @@ const locale: typeof enUS = { item: { edit: 'Edita', delete: 'Elimina aquest comentari', + more: 'Més', }, filter: { sheet: { diff --git a/packages/thread-comment-ui/src/locale/de-DE.ts b/packages/thread-comment-ui/src/locale/de-DE.ts index 89236afda4..57cf80ccf0 100644 --- a/packages/thread-comment-ui/src/locale/de-DE.ts +++ b/packages/thread-comment-ui/src/locale/de-DE.ts @@ -33,6 +33,7 @@ const locale: typeof enUS = { item: { edit: 'Bearbeiten', delete: 'Diesen Kommentar löschen', + more: 'Mehr', }, filter: { sheet: { diff --git a/packages/thread-comment-ui/src/locale/en-US.ts b/packages/thread-comment-ui/src/locale/en-US.ts index 2c9b6e1af9..baa858f0e6 100644 --- a/packages/thread-comment-ui/src/locale/en-US.ts +++ b/packages/thread-comment-ui/src/locale/en-US.ts @@ -31,6 +31,7 @@ const locale = { item: { edit: 'Edit', delete: 'Delete This Comment', + more: 'More', }, filter: { sheet: { diff --git a/packages/thread-comment-ui/src/locale/es-ES.ts b/packages/thread-comment-ui/src/locale/es-ES.ts index b5f99741f8..72ed80f9db 100644 --- a/packages/thread-comment-ui/src/locale/es-ES.ts +++ b/packages/thread-comment-ui/src/locale/es-ES.ts @@ -33,6 +33,7 @@ const locale: typeof enUS = { item: { edit: 'Editar', delete: 'Eliminar este comentario', + more: 'Más', }, filter: { sheet: { diff --git a/packages/thread-comment-ui/src/locale/fa-IR.ts b/packages/thread-comment-ui/src/locale/fa-IR.ts index 6f23fbd6b5..75c5d40c78 100644 --- a/packages/thread-comment-ui/src/locale/fa-IR.ts +++ b/packages/thread-comment-ui/src/locale/fa-IR.ts @@ -33,6 +33,7 @@ const locale: typeof enUS = { item: { edit: 'ویرایش', delete: 'حذف این نظر', + more: 'بیشتر', }, filter: { sheet: { diff --git a/packages/thread-comment-ui/src/locale/fr-FR.ts b/packages/thread-comment-ui/src/locale/fr-FR.ts index e71aeadf61..2512290310 100644 --- a/packages/thread-comment-ui/src/locale/fr-FR.ts +++ b/packages/thread-comment-ui/src/locale/fr-FR.ts @@ -33,6 +33,7 @@ const locale: typeof enUS = { item: { edit: 'Modifier', delete: 'Supprimer ce commentaire', + more: 'Plus', }, filter: { sheet: { diff --git a/packages/thread-comment-ui/src/locale/id-ID.ts b/packages/thread-comment-ui/src/locale/id-ID.ts index 7e60d1f3dd..14093fe6a9 100644 --- a/packages/thread-comment-ui/src/locale/id-ID.ts +++ b/packages/thread-comment-ui/src/locale/id-ID.ts @@ -33,6 +33,7 @@ const locale: typeof enUS = { item: { edit: 'Edit', delete: 'Hapus Komentar Ini', + more: 'Lebih Banyak', }, filter: { sheet: { diff --git a/packages/thread-comment-ui/src/locale/it-IT.ts b/packages/thread-comment-ui/src/locale/it-IT.ts index 572882e11c..2dc5d3faf2 100644 --- a/packages/thread-comment-ui/src/locale/it-IT.ts +++ b/packages/thread-comment-ui/src/locale/it-IT.ts @@ -33,6 +33,7 @@ const locale: typeof enUS = { item: { edit: 'Modifica', delete: 'Elimina questo commento', + more: 'Altro', }, filter: { sheet: { diff --git a/packages/thread-comment-ui/src/locale/ja-JP.ts b/packages/thread-comment-ui/src/locale/ja-JP.ts index e6e0717202..c8af2fff35 100644 --- a/packages/thread-comment-ui/src/locale/ja-JP.ts +++ b/packages/thread-comment-ui/src/locale/ja-JP.ts @@ -33,6 +33,7 @@ const locale: typeof enUS = { item: { edit: '編集', delete: 'コメントを削除', + more: 'もっと見る', }, filter: { sheet: { diff --git a/packages/thread-comment-ui/src/locale/ko-KR.ts b/packages/thread-comment-ui/src/locale/ko-KR.ts index efcdc93722..b0893d4c10 100644 --- a/packages/thread-comment-ui/src/locale/ko-KR.ts +++ b/packages/thread-comment-ui/src/locale/ko-KR.ts @@ -33,6 +33,7 @@ const locale: typeof enUS = { item: { edit: '편집', delete: '댓글 삭제', + more: '더 보기', }, filter: { sheet: { diff --git a/packages/thread-comment-ui/src/locale/pl-PL.ts b/packages/thread-comment-ui/src/locale/pl-PL.ts index e4bac11bcf..deffe04605 100644 --- a/packages/thread-comment-ui/src/locale/pl-PL.ts +++ b/packages/thread-comment-ui/src/locale/pl-PL.ts @@ -33,6 +33,7 @@ const locale: typeof enUS = { item: { edit: 'Edytuj', delete: 'Usuń ten komentarz', + more: 'Więcej', }, filter: { sheet: { diff --git a/packages/thread-comment-ui/src/locale/pt-BR.ts b/packages/thread-comment-ui/src/locale/pt-BR.ts index a291c899e2..f151d4efef 100644 --- a/packages/thread-comment-ui/src/locale/pt-BR.ts +++ b/packages/thread-comment-ui/src/locale/pt-BR.ts @@ -33,6 +33,7 @@ const locale: typeof enUS = { item: { edit: 'Editar', delete: 'Excluir Este Comentário', + more: 'Mais', }, filter: { sheet: { diff --git a/packages/thread-comment-ui/src/locale/ru-RU.ts b/packages/thread-comment-ui/src/locale/ru-RU.ts index 7757df727e..8b05abb544 100644 --- a/packages/thread-comment-ui/src/locale/ru-RU.ts +++ b/packages/thread-comment-ui/src/locale/ru-RU.ts @@ -33,6 +33,7 @@ const locale: typeof enUS = { item: { edit: 'Редактировать', delete: 'Удалить', + more: 'Больше', }, filter: { sheet: { diff --git a/packages/thread-comment-ui/src/locale/sk-SK.ts b/packages/thread-comment-ui/src/locale/sk-SK.ts index 489b2aa7c4..b5474219e8 100644 --- a/packages/thread-comment-ui/src/locale/sk-SK.ts +++ b/packages/thread-comment-ui/src/locale/sk-SK.ts @@ -33,6 +33,7 @@ const locale: typeof enUS = { item: { edit: 'Upraviť', delete: 'Odstrániť tento komentár', + more: 'Viac', }, filter: { sheet: { diff --git a/packages/thread-comment-ui/src/locale/vi-VN.ts b/packages/thread-comment-ui/src/locale/vi-VN.ts index bc1a81e299..7f0f54f1fc 100644 --- a/packages/thread-comment-ui/src/locale/vi-VN.ts +++ b/packages/thread-comment-ui/src/locale/vi-VN.ts @@ -33,6 +33,7 @@ const locale: typeof enUS = { item: { edit: 'Chỉnh sửa', delete: 'Xóa', + more: 'Thêm', }, filter: { sheet: { diff --git a/packages/thread-comment-ui/src/locale/zh-CN.ts b/packages/thread-comment-ui/src/locale/zh-CN.ts index 2c781961a4..17ad672230 100644 --- a/packages/thread-comment-ui/src/locale/zh-CN.ts +++ b/packages/thread-comment-ui/src/locale/zh-CN.ts @@ -33,6 +33,7 @@ const locale: typeof enUS = { item: { edit: '编辑', delete: '删除', + more: '更多', }, filter: { sheet: { diff --git a/packages/thread-comment-ui/src/locale/zh-HK.ts b/packages/thread-comment-ui/src/locale/zh-HK.ts index 2617be7780..e6cffc436c 100644 --- a/packages/thread-comment-ui/src/locale/zh-HK.ts +++ b/packages/thread-comment-ui/src/locale/zh-HK.ts @@ -33,6 +33,7 @@ const locale: typeof enUS = { item: { edit: '編輯', delete: '刪除', + more: '更多', }, filter: { sheet: { diff --git a/packages/thread-comment-ui/src/locale/zh-TW.ts b/packages/thread-comment-ui/src/locale/zh-TW.ts index 2617be7780..e6cffc436c 100644 --- a/packages/thread-comment-ui/src/locale/zh-TW.ts +++ b/packages/thread-comment-ui/src/locale/zh-TW.ts @@ -33,6 +33,7 @@ const locale: typeof enUS = { item: { edit: '編輯', delete: '刪除', + more: '更多', }, filter: { sheet: { diff --git a/packages/thread-comment-ui/src/plugin.ts b/packages/thread-comment-ui/src/plugin.ts index 26affc0333..ed86c210d1 100644 --- a/packages/thread-comment-ui/src/plugin.ts +++ b/packages/thread-comment-ui/src/plugin.ts @@ -32,6 +32,7 @@ import { UniverThreadCommentPlugin } from '@univerjs/thread-comment'; import pkg from '../package.json'; import { SetActiveCommentOperation } from './commands/operations/comment.operations'; import { defaultPluginConfig, THREAD_COMMENT_UI_PLUGIN_CONFIG_KEY } from './config/config'; +import { ThreadCommentDraftService } from './services/thread-comment-draft.service'; import { ThreadCommentPanelService } from './services/thread-comment-panel.service'; import { PLUGIN_NAME } from './types/const'; @@ -64,6 +65,7 @@ export class UniverThreadCommentUIPlugin extends Plugin { override onStarting(): void { (mergeOverrideWithDependencies([ + [ThreadCommentDraftService], [ThreadCommentPanelService], ], this._config?.overrides) as Dependency[]).forEach((dep) => { this._injector.add(dep); diff --git a/packages/thread-comment-ui/src/render-objects/__tests__/thread-comment-canvas-overlay.spec.ts b/packages/thread-comment-ui/src/render-objects/__tests__/thread-comment-canvas-overlay.spec.ts new file mode 100644 index 0000000000..9cbef1b481 --- /dev/null +++ b/packages/thread-comment-ui/src/render-objects/__tests__/thread-comment-canvas-overlay.spec.ts @@ -0,0 +1,207 @@ +/** + * 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 { Vector2 } from '@univerjs/engine-render'; +import { describe, expect, it, vi } from 'vitest'; +import { groupThreadCommentCanvasMarkers, ThreadCommentCanvasOverlay } from '../thread-comment-canvas-overlay'; + +describe('ThreadCommentCanvasOverlay', () => { + it('groups comments at the same canvas anchor and keeps the newest one clickable', () => { + expect(groupThreadCommentCanvasMarkers([ + { commentId: 'first-comment', x: 100, y: 100 }, + { commentId: 'other-position', x: 120, y: 100 }, + { commentId: 'newest-comment', x: 100, y: 100 }, + ])).toEqual([ + { + commentId: 'newest-comment', + commentIds: ['first-comment', 'newest-comment'], + count: 2, + x: 100, + y: 100, + }, + { + commentId: 'other-position', + commentIds: ['other-position'], + count: 1, + x: 120, + y: 100, + }, + ]); + }); + + it('decrements and removes an anchor marker as root comments resolve or delete', () => { + const comments = [ + { commentId: 'first-comment', x: 100, y: 100 }, + { commentId: 'second-comment', x: 100, y: 100 }, + ]; + + expect(groupThreadCommentCanvasMarkers(comments)[0]).toEqual(expect.objectContaining({ count: 2 })); + expect(groupThreadCommentCanvasMarkers(comments.slice(1))[0]).toEqual(expect.objectContaining({ count: 1 })); + expect(groupThreadCommentCanvasMarkers([])).toEqual([]); + }); + + it('repaints pointer previews immediately without debounce', () => { + const overlay = new ThreadCommentCanvasOverlay('test-comment-preview', { + accentColor: 'yellow', + foregroundColor: 'black', + outlineColor: 'white', + zoomRatio: 1, + markers: [], + underlines: [], + }); + const immediate = vi.spyOn(overlay, 'makeDirtyNoDebounce'); + const debounced = vi.spyOn(overlay, 'makeDirty'); + + overlay.updateState({ previewMarker: { x: 10, y: 20 }, previewUnderline: null }); + + expect(immediate).toHaveBeenCalledWith(true); + expect(debounced).not.toHaveBeenCalled(); + }); + + it('hits saved position markers and element underlines at the current zoom', () => { + const overlay = new ThreadCommentCanvasOverlay('test-comment-overlay', { + accentColor: 'yellow', + foregroundColor: 'black', + outlineColor: 'white', + zoomRatio: 2, + markers: [{ commentId: 'position-comment', x: 100, y: 100 }], + underlines: [{ commentId: 'element-comment', left: 20, top: 40, width: 60 }], + previewMarker: { x: 200, y: 200 }, + }); + + expect(overlay.isHit(new Vector2(105, 100))).toBe(true); + expect(overlay.hitCommentId).toBe('position-comment'); + expect(overlay.isHit(new Vector2(50, 41))).toBe(true); + expect(overlay.hitCommentId).toBe('element-comment'); + expect(overlay.isHit(new Vector2(200, 200))).toBe(false); + expect(overlay.hitCommentId).toBeNull(); + }); + + it('clears a stale hit when its comment is removed or resolved', () => { + const overlay = new ThreadCommentCanvasOverlay('test-comment-overlay', { + accentColor: 'yellow', + foregroundColor: 'black', + outlineColor: 'white', + zoomRatio: 1, + markers: [{ commentId: 'removed-comment', x: 100, y: 100 }], + underlines: [], + }); + + expect(overlay.isHit(new Vector2(100, 100))).toBe(true); + expect(overlay.hitCommentId).toBe('removed-comment'); + + overlay.updateState({ markers: [] }); + + expect(overlay.hitCommentId).toBeNull(); + expect(overlay.isHit(new Vector2(100, 100))).toBe(false); + }); + + it('uses the topmost saved marker for overlapping comments', () => { + const overlay = new ThreadCommentCanvasOverlay('test-comment-overlay', { + accentColor: 'yellow', + foregroundColor: 'black', + outlineColor: 'white', + zoomRatio: 0.5, + markers: [ + { commentId: 'first-comment', x: 100, y: 100 }, + { commentId: 'top-comment', x: 100, y: 100 }, + ], + underlines: [{ commentId: 'element-comment', left: 80, top: 100, width: 40 }], + }); + + expect(overlay.isHit(new Vector2(100, 100))).toBe(true); + expect(overlay.hitCommentId).toBe('top-comment'); + }); + + it('keeps an aggregated marker focused for every comment at the same anchor', () => { + const overlay = new ThreadCommentCanvasOverlay('test-comment-group', { + accentColor: 'yellow', + foregroundColor: 'black', + outlineColor: 'white', + zoomRatio: 1, + markers: [{ + commentId: 'newest-comment', + commentIds: ['older-comment', 'newest-comment'], + count: 2, + x: 100, + y: 100, + }], + underlines: [], + focusedCommentIds: ['older-comment'], + }); + const renderMarker = vi.fn(); + Reflect.set(overlay, '_renderMarker', renderMarker); + + overlay.render(undefined as never, undefined as never); + + expect(renderMarker).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ commentId: 'newest-comment', count: 2 }), + 1, + 1 + ); + expect(overlay.isHit(new Vector2(100, 100))).toBe(true); + expect(overlay.hitCommentId).toBe('newest-comment'); + }); + + it('reconciles underline hit geometry after an object transform and restore', () => { + const overlay = new ThreadCommentCanvasOverlay('test-comment-overlay', { + accentColor: 'yellow', + foregroundColor: 'black', + outlineColor: 'white', + zoomRatio: 1.25, + markers: [], + underlines: [{ commentId: 'element-comment', left: 20, top: 40, width: 60 }], + }); + + expect(overlay.isHit(new Vector2(50, 41))).toBe(true); + + overlay.updateState({ + underlines: [{ commentId: 'element-comment', left: 140, top: 180, width: 120 }], + }); + expect(overlay.isHit(new Vector2(50, 41))).toBe(false); + expect(overlay.isHit(new Vector2(200, 181))).toBe(true); + + overlay.updateState({ underlines: [] }); + expect(overlay.isHit(new Vector2(200, 181))).toBe(false); + overlay.updateState({ + underlines: [{ commentId: 'element-comment', left: 140, top: 180, width: 120 }], + }); + expect(overlay.isHit(new Vector2(200, 181))).toBe(true); + }); + + it('renders outlines for both selected and hovered object comments', () => { + const overlay = new ThreadCommentCanvasOverlay('test-comment-focus', { + accentColor: 'yellow', + foregroundColor: 'black', + outlineColor: 'white', + zoomRatio: 1, + markers: [], + underlines: [], + focusedCommentIds: ['selected-comment', 'hovered-comment'], + focusOutlines: [ + { left: 10, top: 20, width: 100, height: 60 }, + { left: 140, top: 80, width: 120, height: 80 }, + ], + }); + const renderOutline = vi.fn(); + Reflect.set(overlay, '_renderOutline', renderOutline); + + Reflect.apply(overlay.render, overlay, [undefined, undefined]); + + expect(renderOutline).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/thread-comment-ui/src/render-objects/thread-comment-canvas-overlay.ts b/packages/thread-comment-ui/src/render-objects/thread-comment-canvas-overlay.ts new file mode 100644 index 0000000000..d5fda4ba3c --- /dev/null +++ b/packages/thread-comment-ui/src/render-objects/thread-comment-canvas-overlay.ts @@ -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 type { IViewportInfo, UniverRenderingContext, Vector2 } from '@univerjs/engine-render'; +import { BaseObject, DEFAULT_FONTFACE_PLANE } from '@univerjs/engine-render'; + +export interface IThreadCommentCanvasMarker { + commentId: string; + commentIds?: readonly string[]; + count?: number; + x: number; + y: number; +} + +export interface IThreadCommentCanvasUnderline { + commentId: string; + left: number; + top: number; + width: number; +} + +export interface IThreadCommentCanvasOutline { + left: number; + top: number; + width: number; + height: number; +} + +export interface IThreadCommentCanvasOverlayState { + accentColor: string; + foregroundColor: string; + outlineColor: string; + zoomRatio: number; + markers: readonly IThreadCommentCanvasMarker[]; + underlines: readonly IThreadCommentCanvasUnderline[]; + focusedCommentIds?: readonly string[]; + focusOutlines?: readonly IThreadCommentCanvasOutline[]; + previewMarker?: Omit | null; + previewUnderline?: Omit | null; +} + +const MARKER_SIZE = 24; +const MARKER_IDLE_OPACITY = 0.58; +const MARKER_PREVIEW_OPACITY = 0.42; +const UNDERLINE_WIDTH = 2; +const UNDERLINE_HIT_PADDING = 4; +const OUTLINE_PADDING = 2; + +export function groupThreadCommentCanvasMarkers( + markers: readonly IThreadCommentCanvasMarker[] +): IThreadCommentCanvasMarker[] { + const groups = new Map(); + markers.forEach((marker) => { + const key = `${marker.x}\0${marker.y}`; + const current = groups.get(key); + if (current) { + current.commentId = marker.commentId; + current.commentIds.push(marker.commentId); + current.count += 1; + return; + } + groups.set(key, { + ...marker, + commentIds: [marker.commentId], + count: 1, + }); + }); + return Array.from(groups.values()); +} + +export class ThreadCommentCanvasOverlay extends BaseObject { + private _state: IThreadCommentCanvasOverlayState; + private _hoveredCommentId: string | null = null; + private _hitCommentId: string | null = null; + + constructor(key: string, state: IThreadCommentCanvasOverlayState) { + super(key); + this.evented = true; + this._state = state; + this.show(); + } + + get hitCommentId(): string | null { + return this._hitCommentId; + } + + updateState(state: Partial): void { + this._state = { ...this._state, ...state }; + if (this._hitCommentId && !this._containsComment(this._hitCommentId)) { + this._hitCommentId = null; + } + if (this._hoveredCommentId && !this._containsComment(this._hoveredCommentId)) { + this._hoveredCommentId = null; + } + if ('previewMarker' in state || 'previewUnderline' in state) { + this.makeDirtyNoDebounce(true); + } else { + this.makeDirty(true); + } + } + + clearHover(): void { + if (this._hoveredCommentId) { + this._hoveredCommentId = null; + this.makeDirty(true); + } + this._hitCommentId = null; + } + + override isHit(coord: Vector2): boolean { + const zoomRatio = this._getZoomRatio(); + const halfSize = MARKER_SIZE / zoomRatio / 2; + let marker: IThreadCommentCanvasMarker | undefined; + for (let index = this._state.markers.length - 1; index >= 0; index -= 1) { + const item = this._state.markers[index]; + if (!item) { + continue; + } + if ( + coord.x >= item.x - halfSize + && coord.x <= item.x + halfSize + && coord.y >= item.y - halfSize + && coord.y <= item.y + halfSize + ) { + marker = item; + break; + } + } + + let underline: IThreadCommentCanvasUnderline | undefined; + if (!marker) { + for (let index = this._state.underlines.length - 1; index >= 0; index -= 1) { + const item = this._state.underlines[index]; + if (!item) { + continue; + } + if ( + coord.x >= item.left + && coord.x <= item.left + item.width + && Math.abs(coord.y - item.top) <= UNDERLINE_HIT_PADDING / zoomRatio + ) { + underline = item; + break; + } + } + } + const commentId = marker?.commentId ?? underline?.commentId ?? null; + + this._hitCommentId = commentId; + if (commentId !== this._hoveredCommentId) { + this._hoveredCommentId = commentId; + this.makeDirty(true); + } + return commentId !== null; + } + + override render(ctx: UniverRenderingContext, _bounds: IViewportInfo): this { + const zoomRatio = this._getZoomRatio(); + + this._state.underlines.forEach((underline) => this._renderUnderline(ctx, underline, zoomRatio, 1)); + this._state.focusOutlines?.forEach((outline) => this._renderOutline(ctx, outline, zoomRatio)); + if (this._state.previewUnderline) { + this._renderUnderline(ctx, this._state.previewUnderline, zoomRatio, MARKER_PREVIEW_OPACITY); + } + this._state.markers.forEach((marker) => this._renderMarker( + ctx, + marker, + zoomRatio, + this._markerContainsComment(marker, this._hoveredCommentId) + || this._state.focusedCommentIds?.some((commentId) => this._markerContainsComment(marker, commentId)) + ? 1 + : MARKER_IDLE_OPACITY + )); + if (this._state.previewMarker) { + this._renderMarker(ctx, this._state.previewMarker, zoomRatio, MARKER_PREVIEW_OPACITY); + } + + this.makeDirty(false); + return this; + } + + private _getZoomRatio(): number { + return Number.isFinite(this._state.zoomRatio) && this._state.zoomRatio > 0 + ? this._state.zoomRatio + : 1; + } + + private _containsComment(commentId: string): boolean { + return this._state.markers.some((marker) => this._markerContainsComment(marker, commentId)) + || this._state.underlines.some((underline) => underline.commentId === commentId); + } + + private _markerContainsComment(marker: IThreadCommentCanvasMarker, commentId: string | null): boolean { + return commentId != null + && (marker.commentId === commentId || marker.commentIds?.includes(commentId) === true); + } + + private _renderUnderline( + ctx: UniverRenderingContext, + underline: Omit, + zoomRatio: number, + opacity: number + ): void { + ctx.save(); + ctx.globalAlpha = opacity; + ctx.strokeStyle = this._state.accentColor; + ctx.lineWidth = UNDERLINE_WIDTH / zoomRatio; + ctx.beginPath(); + ctx.moveTo(underline.left, underline.top); + ctx.lineTo(underline.left + underline.width, underline.top); + ctx.stroke(); + ctx.restore(); + } + + private _renderOutline( + ctx: UniverRenderingContext, + outline: IThreadCommentCanvasOutline, + zoomRatio: number + ): void { + const padding = OUTLINE_PADDING / zoomRatio; + ctx.save(); + ctx.strokeStyle = this._state.accentColor; + ctx.lineWidth = UNDERLINE_WIDTH / zoomRatio; + ctx.strokeRect( + outline.left - padding, + outline.top - padding, + outline.width + padding * 2, + outline.height + padding * 2 + ); + ctx.restore(); + } + + private _renderMarker( + ctx: UniverRenderingContext, + marker: Omit, + zoomRatio: number, + opacity: number + ): void { + const size = MARKER_SIZE / zoomRatio; + const left = marker.x - size / 2; + const top = marker.y - size / 2; + const radius = 4 / zoomRatio; + const tail = 4 / zoomRatio; + + ctx.save(); + ctx.globalAlpha = opacity; + ctx.fillStyle = this._state.accentColor; + ctx.strokeStyle = this._state.outlineColor; + ctx.lineWidth = 1.5 / zoomRatio; + ctx.beginPath(); + ctx.moveTo(left + radius, top); + ctx.lineTo(left + size - radius, top); + ctx.quadraticCurveTo(left + size, top, left + size, top + radius); + ctx.lineTo(left + size, top + size - tail - radius); + ctx.quadraticCurveTo(left + size, top + size - tail, left + size - radius, top + size - tail); + ctx.lineTo(left + size / 2 + tail, top + size - tail); + ctx.lineTo(left + size / 2, top + size); + ctx.lineTo(left + size / 2 - tail, top + size - tail); + ctx.lineTo(left + radius, top + size - tail); + ctx.quadraticCurveTo(left, top + size - tail, left, top + size - tail - radius); + ctx.lineTo(left, top + radius); + ctx.quadraticCurveTo(left, top, left + radius, top); + ctx.closePath(); + ctx.fill(); + ctx.stroke(); + + ctx.fillStyle = this._state.foregroundColor; + if ((marker.count ?? 1) > 1) { + ctx.font = `600 ${11 / zoomRatio}px ${DEFAULT_FONTFACE_PLANE}`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(String(marker.count), marker.x, top + size * 0.42); + } else { + const dotRadius = 1.2 / zoomRatio; + const dotY = top + size * 0.43; + [0.32, 0.5, 0.68].forEach((ratio) => { + ctx.beginPath(); + ctx.arc(left + size * ratio, dotY, dotRadius, 0, Math.PI * 2); + ctx.fill(); + }); + } + ctx.restore(); + } +} diff --git a/packages/thread-comment-ui/src/services/__tests__/thread-comment-draft.service.spec.ts b/packages/thread-comment-ui/src/services/__tests__/thread-comment-draft.service.spec.ts new file mode 100644 index 0000000000..ea9bf9504a --- /dev/null +++ b/packages/thread-comment-ui/src/services/__tests__/thread-comment-draft.service.spec.ts @@ -0,0 +1,92 @@ +/** + * 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, IUniverInstanceService, UniverInstanceType, UserManagerService } from '@univerjs/core'; +import { ThreadCommentAnchorKind } from '@univerjs/thread-comment'; +import { BehaviorSubject, Subject } from 'rxjs'; +import { describe, expect, it } from 'vitest'; +import { ThreadCommentDraftService } from '../thread-comment-draft.service'; + +describe('ThreadCommentDraftService', () => { + it('moves from placement mode to an anchored draft and can cancel', () => { + const injector = new Injector(); + const unitDisposed$ = new Subject<{ getUnitId: () => string }>(); + const focused$ = new BehaviorSubject('board-1'); + const currentUser$ = new BehaviorSubject({ userID: 'user-1', name: 'User 1' }); + injector.add([IUniverInstanceService, { useValue: { focused$, unitDisposed$ } as never }]); + injector.add([UserManagerService, { + useValue: { + currentUser$, + getCurrentUser: () => currentUser$.value, + } as never, + }]); + injector.add([ThreadCommentDraftService]); + const service = injector.get(ThreadCommentDraftService); + service.startPlacement(UniverInstanceType.UNIVER_BOARD, 'board-1'); + expect(service.placementType).toBe(UniverInstanceType.UNIVER_BOARD); + + service.startPlacement(UniverInstanceType.UNIVER_BOARD, 'board-1'); + expect(service.placementType).toBeNull(); + + service.startPlacement(UniverInstanceType.UNIVER_BOARD, 'board-1'); + unitDisposed$.next({ getUnitId: () => 'other-board' }); + expect(service.placementType).toBe(UniverInstanceType.UNIVER_BOARD); + focused$.next('board-2'); + expect(service.placementType).toBeNull(); + + focused$.next('board-1'); + service.startPlacement(UniverInstanceType.UNIVER_BOARD, 'board-1'); + unitDisposed$.next({ getUnitId: () => 'board-1' }); + expect(service.placementType).toBeNull(); + + service.startPlacement(UniverInstanceType.UNIVER_BOARD, 'board-1'); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(service.placementType).toBeNull(); + + service.place({ + unitId: 'board-1', + subUnitId: 'page-1', + anchor: { kind: ThreadCommentAnchorKind.BOARD_POSITION, x: 10, y: 20 }, + }); + expect(service.placementType).toBeNull(); + expect(service.draft?.anchor).toEqual({ kind: ThreadCommentAnchorKind.BOARD_POSITION, x: 10, y: 20 }); + + unitDisposed$.next({ getUnitId: () => 'other-board' }); + expect(service.draft).not.toBeNull(); + unitDisposed$.next({ getUnitId: () => 'board-1' }); + expect(service.draft).toBeNull(); + + service.place({ + unitId: 'board-2', + subUnitId: 'page-1', + anchor: { kind: ThreadCommentAnchorKind.BOARD_POSITION, x: 1, y: 2 }, + }); + currentUser$.next({ userID: 'user-2', name: 'User 2' }); + expect(service.draft).toBeNull(); + + service.place({ + unitId: 'board-2', + subUnitId: 'page-1', + anchor: { kind: ThreadCommentAnchorKind.BOARD_POSITION, x: 3, y: 4 }, + }); + window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + expect(service.draft).toBeNull(); + + service.cancel(); + expect(service.draft).toBeNull(); + injector.dispose(); + }); +}); diff --git a/packages/thread-comment-ui/src/services/__tests__/thread-comment-panel.service.spec.ts b/packages/thread-comment-ui/src/services/__tests__/thread-comment-panel.service.spec.ts index 08b803c102..ee76b0b539 100644 --- a/packages/thread-comment-ui/src/services/__tests__/thread-comment-panel.service.spec.ts +++ b/packages/thread-comment-ui/src/services/__tests__/thread-comment-panel.service.spec.ts @@ -15,7 +15,7 @@ */ import type { IWorkbookData } from '@univerjs/core'; -import { IUniverInstanceService, LocaleType, Univer, UniverInstanceType } from '@univerjs/core'; +import { LocaleType, Univer, UniverInstanceType } from '@univerjs/core'; import { DesktopSidebarService, ISidebarService } from '@univerjs/ui'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { ThreadCommentPanelService } from '../thread-comment-panel.service'; @@ -40,7 +40,6 @@ describe('ThreadCommentPanelService', () => { let univer: Univer; let service: ThreadCommentPanelService; let sidebarService: ISidebarService; - let univerInstanceService: IUniverInstanceService; beforeEach(() => { univer = new Univer(); @@ -49,9 +48,6 @@ describe('ThreadCommentPanelService', () => { injector.add([ThreadCommentPanelService]); univer.createUnit(UniverInstanceType.UNIVER_SHEET, workbookData); - univerInstanceService = injector.get(IUniverInstanceService); - univerInstanceService.focusUnit(workbookData.id); - service = injector.get(ThreadCommentPanelService); sidebarService = injector.get(ISidebarService); }); @@ -65,10 +61,12 @@ describe('ThreadCommentPanelService', () => { service.panelVisible$.subscribe((visible) => visibleStates.push(visible)); service.setPanelVisible(true); + service.setHoveredComment({ unitId: 'book-1', subUnitId: 'sheet-1', commentId: 'c-1' }); sidebarService.open({}); sidebarService.close(); expect(service.panelVisible).toBe(false); + expect(service.hoveredCommentId).toBeUndefined(); expect(visibleStates).toEqual([false, true, false]); }); @@ -82,13 +80,28 @@ describe('ThreadCommentPanelService', () => { expect(activeComments.at(-1)).toEqual({ unitId: 'book-1', subUnitId: 'sheet-1', commentId: 'c-1', trigger: 'cell' }); }); - it('closes the sidebar when the current sheet is removed', () => { - sidebarService.open({}); - service.setPanelVisible(true); + it('publishes the thread comment hovered in the panel', () => { + const hoveredComments: unknown[] = []; + service.hoveredCommentId$.subscribe((comment) => hoveredComments.push(comment)); - univerInstanceService.disposeUnit(workbookData.id); + service.setHoveredComment({ + unitId: 'book-1', + subUnitId: 'sheet-1', + commentId: 'c-1', + trigger: 'panel-hover', + }); + service.setHoveredComment(undefined); - expect(sidebarService.visible).toBe(false); - expect(service.panelVisible).toBe(false); + expect(service.hoveredCommentId).toBeUndefined(); + expect(hoveredComments).toEqual([ + undefined, + { + unitId: 'book-1', + subUnitId: 'sheet-1', + commentId: 'c-1', + trigger: 'panel-hover', + }, + undefined, + ]); }); }); diff --git a/packages/thread-comment-ui/src/services/thread-comment-draft.service.ts b/packages/thread-comment-ui/src/services/thread-comment-draft.service.ts new file mode 100644 index 0000000000..db9ec5c0a8 --- /dev/null +++ b/packages/thread-comment-ui/src/services/thread-comment-draft.service.ts @@ -0,0 +1,106 @@ +/** + * 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 { UniverInstanceType } from '@univerjs/core'; +import type { IThreadCommentAnchor } from '@univerjs/thread-comment'; +import { Disposable, Inject, IUniverInstanceService, toDisposable, UserManagerService } from '@univerjs/core'; +import { BehaviorSubject } from 'rxjs'; + +export interface IThreadCommentDraft { + unitId: string; + subUnitId: string; + anchor: IThreadCommentAnchor; +} + +export class ThreadCommentDraftService extends Disposable { + private readonly _placementType$ = new BehaviorSubject(null); + private readonly _draft$ = new BehaviorSubject(null); + private _placementUnitId: string | undefined; + private _ownerUserId: string | undefined; + + readonly placementType$ = this._placementType$.asObservable(); + readonly draft$ = this._draft$.asObservable(); + + constructor( + @IUniverInstanceService instanceService: IUniverInstanceService, + @Inject(UserManagerService) private readonly _userManagerService: UserManagerService + ) { + super(); + this.disposeWithMe(instanceService.unitDisposed$.subscribe((unit) => { + if (this.draft?.unitId === unit.getUnitId() || this._placementUnitId === unit.getUnitId()) { + this.cancel(); + } + })); + this.disposeWithMe(instanceService.focused$.subscribe((unitId) => { + if (unitId && this._placementUnitId && unitId !== this._placementUnitId) { + this.cancel(); + } + })); + this.disposeWithMe(this._userManagerService.currentUser$.subscribe((user) => { + if (this._ownerUserId && user?.userID !== this._ownerUserId) { + this.cancel(); + } + })); + if (typeof window !== 'undefined') { + const cancelPlacement = (event: KeyboardEvent) => { + if (event.key === 'Escape' && (this.placementType || this.draft)) { + this.cancel(); + } + }; + window.addEventListener('keydown', cancelPlacement); + this.disposeWithMe(toDisposable(() => window.removeEventListener('keydown', cancelPlacement))); + } + } + + get placementType(): UniverInstanceType | null { + return this._placementType$.value; + } + + get draft(): IThreadCommentDraft | null { + return this._draft$.value; + } + + startPlacement(type: UniverInstanceType, unitId: string): void { + if (this.placementType === type && this._placementUnitId === unitId) { + this.cancel(); + return; + } + this._ownerUserId = this._userManagerService.getCurrentUser().userID; + this._placementUnitId = unitId; + this._draft$.next(null); + this._placementType$.next(type); + } + + place(draft: IThreadCommentDraft): void { + this._ownerUserId = this._userManagerService.getCurrentUser().userID; + this._placementUnitId = undefined; + this._placementType$.next(null); + this._draft$.next(draft); + } + + cancel(): void { + this._ownerUserId = undefined; + this._placementUnitId = undefined; + this._placementType$.next(null); + this._draft$.next(null); + } + + override dispose(): void { + this._placementType$.complete(); + this._draft$.complete(); + super.dispose(); + } +} diff --git a/packages/thread-comment-ui/src/services/thread-comment-panel.service.ts b/packages/thread-comment-ui/src/services/thread-comment-panel.service.ts index 6bf2d44ce8..3b1af3ec9d 100644 --- a/packages/thread-comment-ui/src/services/thread-comment-panel.service.ts +++ b/packages/thread-comment-ui/src/services/thread-comment-panel.service.ts @@ -15,9 +15,9 @@ */ import type { Nullable } from '@univerjs/core'; -import { Disposable, Inject, IUniverInstanceService, UniverInstanceType } from '@univerjs/core'; +import { Disposable, Inject } from '@univerjs/core'; import { ISidebarService } from '@univerjs/ui'; -import { BehaviorSubject, filter } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; export type ActiveCommentInfo = Nullable<{ unitId: string; subUnitId: string; commentId: string; trigger?: string }>; @@ -28,18 +28,22 @@ export class ThreadCommentPanelService extends Disposable { private _activeCommentId: ActiveCommentInfo; private _activeCommentId$ = new BehaviorSubject(undefined); + private _hoveredCommentId: ActiveCommentInfo; + private _hoveredCommentId$ = new BehaviorSubject(undefined); + panelVisible$ = this._panelVisible$.asObservable(); activeCommentId$ = this._activeCommentId$.asObservable(); + hoveredCommentId$ = this._hoveredCommentId$.asObservable(); constructor( - @Inject(ISidebarService) private readonly _sidebarService: ISidebarService, - @IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService + @Inject(ISidebarService) private readonly _sidebarService: ISidebarService ) { super(); this._init(); this.disposeWithMe(() => { this._activeCommentId$.complete(); + this._hoveredCommentId$.complete(); this._panelVisible$.complete(); }); } @@ -52,13 +56,6 @@ export class ThreadCommentPanelService extends Disposable { } }) ); - - this.disposeWithMe( - this._univerInstanceService.getCurrentTypeOfUnit$(UniverInstanceType.UNIVER_SHEET) - .pipe(filter((sheet) => !sheet)).subscribe(() => { - this._sidebarService.close(); - }) - ); } get panelVisible() { @@ -69,13 +66,25 @@ export class ThreadCommentPanelService extends Disposable { return this._activeCommentId; } + get hoveredCommentId() { + return this._hoveredCommentId; + } + setPanelVisible(visible: boolean) { this._panelVisible = visible; this._panelVisible$.next(visible); + if (!visible) { + this.setHoveredComment(undefined); + } } setActiveComment(commentInfo: ActiveCommentInfo) { this._activeCommentId = commentInfo; this._activeCommentId$.next(commentInfo); } + + setHoveredComment(commentInfo: ActiveCommentInfo) { + this._hoveredCommentId = commentInfo; + this._hoveredCommentId$.next(commentInfo); + } } diff --git a/packages/thread-comment-ui/src/views/ThreadCommentEditor.tsx b/packages/thread-comment-ui/src/views/ThreadCommentEditor.tsx index 309cbf5150..52f49a7f40 100644 --- a/packages/thread-comment-ui/src/views/ThreadCommentEditor.tsx +++ b/packages/thread-comment-ui/src/views/ThreadCommentEditor.tsx @@ -27,7 +27,7 @@ import { UniverInstanceType, } from '@univerjs/core'; import { Button, clsx } from '@univerjs/design'; -import { BreakLineCommand, IEditorService, RichTextEditor } from '@univerjs/docs-ui'; +import { BreakLineCommand, DeleteLeftCommand, DeleteRightCommand, IEditorService, RichTextEditor } from '@univerjs/docs-ui'; import { KeyCode, useDependency } from '@univerjs/ui'; import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; import { SetActiveCommentOperation } from '../commands/operations/comment.operations'; @@ -36,7 +36,7 @@ import { focusThreadCommentEditor } from './thread-comment-editor/util'; export interface IThreadCommentEditorProps { id?: string; comment?: Pick; - onSave?: (comment: Pick) => void; + onSave?: (comment: Pick) => boolean | void | Promise; onCancel?: () => void; autoFocus?: boolean; unitId: string; @@ -62,8 +62,11 @@ export const ThreadCommentEditor = forwardRef(null); + const editorRef = useRef(null); const rootEditorId = type === UniverInstanceType.UNIVER_DOC ? DOCS_NORMAL_EDITOR_UNIT_ID_KEY : unitId; const [canSubmit, setCanSubmit] = useState(() => ( BuildTextUtils.transform.getPlainText(comment?.text?.dataStream ?? '') @@ -71,25 +74,46 @@ export const ThreadCommentEditor = forwardRef ( { - keyCodes: [{ keyCode: KeyCode.ENTER }], + keyCodes: [ + { keyCode: KeyCode.ENTER }, + { keyCode: KeyCode.BACKSPACE }, + { keyCode: KeyCode.DELETE }, + ], handler: (keyCode) => { - if (keyCode === KeyCode.ENTER) { - commandService.executeCommand( - BreakLineCommand.id - ); + let commandId: string; + switch (keyCode) { + case KeyCode.ENTER: + commandId = BreakLineCommand.id; + break; + case KeyCode.BACKSPACE: + commandId = DeleteLeftCommand.id; + break; + case KeyCode.DELETE: + commandId = DeleteRightCommand.id; + break; + default: + return; } + + focusThreadCommentEditor(editorService, editorId, editorRef.current); + commandService.executeCommand(commandId).then(() => { + if (keyCode !== KeyCode.ENTER && mountedRef.current) { + const dataStream = editorRef.current?.getDocumentData().body?.dataStream ?? ''; + setCanSubmit(BuildTextUtils.transform.getPlainText(dataStream)); + } + }); }, } - ), [commandService]); + ), [commandService, editorId, editorService]); useImperativeHandle(ref, () => ({ reply(text) { - if (!editor.current) { + if (!editorRef.current) { return; } - focusThreadCommentEditor(editorService, editorId, editor.current); + focusThreadCommentEditor(editorService, editorId, editorRef.current); const documentData = getSnapshot(text); - editor.current?.setDocumentData(documentData, [{ + editorRef.current?.setDocumentData(documentData, [{ startOffset: documentData.body!.dataStream.length - 2, endOffset: documentData.body!.dataStream.length - 2, collapsed: true, @@ -99,34 +123,61 @@ export const ThreadCommentEditor = forwardRef { - const currentEditor = editor.current; - if (currentEditor) { - const newText = Tools.deepClone(currentEditor.getDocumentData().body); + const handleSave = async () => { + const currentEditor = editorRef.current; + if (!currentEditor || submittingRef.current) { + return; + } + + const newText = Tools.deepClone(currentEditor.getDocumentData().body); + submittingRef.current = true; + setSubmitting(true); + try { + const success = await onSave?.({ + ...comment, + text: newText!, + }); + if (success === false) { + return; + } + if (!mountedRef.current) { + return; + } + currentEditor.blur(); currentEditor.replaceText('', false); currentEditor.setSelectionRanges([], false); setCanSubmit(''); setEditing(false); - onSave?.({ - ...comment, - text: newText!, - }); + } catch { + return; + } finally { + submittingRef.current = false; + if (mountedRef.current) { + setSubmitting(false); + } } }; const handleEditorMouseDown = () => { - focusThreadCommentEditor(editorService, editorId, editor.current); + focusThreadCommentEditor(editorService, editorId, editorRef.current); setEditing(true); }; + useEffect(() => { + mountedRef.current = true; + return () => { + mountedRef.current = false; + }; + }, []); + useEffect(() => { if (!autoFocus) { return; } const timer = setTimeout(() => { - focusThreadCommentEditor(editorService, editorId, editor.current); + focusThreadCommentEditor(editorService, editorId, editorRef.current); }); return () => clearTimeout(timer); @@ -138,7 +189,7 @@ export const ThreadCommentEditor = forwardRef { - const currentEditor = editor.current; + const currentEditor = editorRef.current; currentEditor?.blur(); currentEditor?.replaceText('', false); currentEditor?.setSelectionRanges([], false); @@ -177,7 +228,7 @@ export const ThreadCommentEditor = forwardRef {localeService.t(id ? 'thread-comment-ui.editor.save' : 'thread-comment-ui.editor.reply')} diff --git a/packages/thread-comment-ui/src/views/ThreadCommentPanel.tsx b/packages/thread-comment-ui/src/views/ThreadCommentPanel.tsx index 6d8ec94e26..8d891b9883 100644 --- a/packages/thread-comment-ui/src/views/ThreadCommentPanel.tsx +++ b/packages/thread-comment-ui/src/views/ThreadCommentPanel.tsx @@ -28,7 +28,7 @@ import { useDependency, useObservable } from '@univerjs/ui'; import { useEffect, useMemo, useRef, useState } from 'react'; import { SetActiveCommentOperation } from '../commands/operations/comment.operations'; import { ThreadCommentPanelService } from '../services/thread-comment-panel.service'; -import { getThreadCommentPanelItemKey } from './thread-comment-panel/util'; +import { getThreadCommentPanelItemKey, isSameThreadCommentTarget, shouldClearThreadCommentTarget } from './thread-comment-panel/util'; import { ThreadCommentTree, ThreadCommentTreeLocation } from './ThreadCommentTree'; export interface IThreadCommentPanelProps { @@ -45,7 +45,10 @@ export interface IThreadCommentPanelProps { tempComment?: Nullable; onAddComment?: IThreadCommentTreeProps['onAddComment']; onDeleteComment?: IThreadCommentTreeProps['onDeleteComment']; + onAfterDeleteComment?: IThreadCommentTreeProps['onAfterDeleteComment']; showComments?: string[]; + formatRef?: (comment: IThreadComment) => string; + onTempCommentClose?: () => void; } interface IThreadCommentWithUsers extends IThreadComment { @@ -67,25 +70,37 @@ export const ThreadCommentPanel = (props: IThreadCommentPanelProps) => { tempComment, onAddComment, onDeleteComment, + onAfterDeleteComment, showComments, + formatRef, + onTempCommentClose, } = props; const [unit, setUnit] = useState('all'); const [status, setStatus] = useState('all'); const localeService = useDependency(LocaleService); const userService = useDependency(UserManagerService); const threadCommentModel = useDependency(ThreadCommentModel); - const [unitComments, setUnitComments] = useState(() => threadCommentModel.getUnit(unitId)); const panelService = useDependency(ThreadCommentPanelService); const activeCommentId = useObservable(panelService.activeCommentId$); - const update = useObservable(threadCommentModel.commentUpdate$); + useObservable(threadCommentModel.commentUpdate$); + const unitComments = threadCommentModel.getUnit(unitId); + const scopedTempComment = tempComment?.unitId === unitId ? tempComment : null; + const activeExists = activeCommentId + ? Boolean( + threadCommentModel.getComment(activeCommentId.unitId, activeCommentId.subUnitId, activeCommentId.commentId) || + (scopedTempComment && isSameThreadCommentTarget(activeCommentId, scopedTempComment)) + ) + : false; + const shouldClearActiveTarget = shouldClearThreadCommentTarget(activeCommentId, unitId, activeExists); const commandService = useDependency(ICommandService); const subUnitId = useObservable(subUnitId$); - const shouldScroll = useRef(true); + const shouldScrollRef = useRef(true); const location = ThreadCommentTreeLocation.PANEL; const currentUser = useObservable(userService.currentUser$); const comments = useMemo(() => { - const allComments = - (unit === 'all' ? unitComments : unitComments.filter((i) => i.subUnitId === subUnitId) ?? []); + const allComments = unit === 'all' && type !== UniverInstanceType.UNIVER_SLIDE + ? unitComments + : unitComments.filter((i) => i.subUnitId === subUnitId); const sort = sortComments ?? ((a) => a); const res: IThreadCommentWithUsers[] = allComments.map((i) => ({ ...i.root, children: i.children ?? [], users: i.relativeUsers })); @@ -100,7 +115,7 @@ export const ThreadCommentPanel = (props: IThreadCommentPanelProps) => { } else { return sort(res) as IThreadCommentWithUsers[]; } - }, [showComments, unit, unitComments, sortComments, subUnitId]); + }, [showComments, unit, unitComments, sortComments, subUnitId, type]); const commentsSorted = useMemo(() => [ ...comments.filter((comment) => !comment.resolved), @@ -126,8 +141,8 @@ export const ThreadCommentPanel = (props: IThreadCommentPanelProps) => { return commentsSorted; }, [commentsSorted, currentUser?.userID, status]); - const renderComments = tempComment - ? [tempComment, ...statuedComments] + const renderComments = scopedTempComment + ? [scopedTempComment, ...statuedComments] : statuedComments; const unSolvedComments = renderComments.filter((comment) => !comment.resolved); @@ -141,25 +156,61 @@ export const ThreadCommentPanel = (props: IThreadCommentPanelProps) => { }; useEffect(() => { - if (unitId) { - setUnitComments( - threadCommentModel.getUnit(unitId) - ); + if (shouldClearActiveTarget) { + panelService.setActiveComment(undefined); } - }, [unitId, threadCommentModel, update]); + }, [panelService, shouldClearActiveTarget]); + + useEffect(() => { + if (tempComment && (tempComment.unitId !== unitId || (subUnitId && tempComment.subUnitId !== subUnitId))) { + onTempCommentClose?.(); + } + }, [onTempCommentClose, subUnitId, tempComment, unitId]); useEffect(() => { if (!activeCommentId) { return; } - if (!shouldScroll.current) { - shouldScroll.current = true; + + const handlePointerDown = (event: PointerEvent) => { + const activeComment = panelService.activeCommentId; + const target = event.target; + if (event.button !== 0 || !activeComment || !(target instanceof Element)) { + return; + } + + const activeElement = document.getElementById( + `${location}-${activeComment.unitId}-${activeComment.subUnitId}-${activeComment.commentId}` + ); + if ( + activeElement?.contains(target) || + target.closest('button, input, textarea, select, a, [contenteditable="true"], [role="menu"], [role="menuitem"], [role="option"], [role="listbox"], [role="combobox"], [role="separator"]') + ) { + return; + } + + commandService.executeCommand(SetActiveCommentOperation.id); + if (scopedTempComment && isSameThreadCommentTarget(activeComment, scopedTempComment)) { + onTempCommentClose?.(); + } + }; + + document.addEventListener('pointerdown', handlePointerDown, true); + return () => document.removeEventListener('pointerdown', handlePointerDown, true); + }, [activeCommentId, commandService, location, onTempCommentClose, panelService, scopedTempComment]); + + useEffect(() => { + if (!activeCommentId) { + return; + } + if (!shouldScrollRef.current) { + shouldScrollRef.current = true; return; } const { unitId, subUnitId, commentId } = activeCommentId; const id = `${location}-${unitId}-${subUnitId}-${commentId}`; document.getElementById(id)?.scrollIntoView({ block: 'center' }); - }, [activeCommentId]); + }, [activeCommentId, location]); const renderComment = (section: ThreadCommentPanelSection) => (comment: IThreadComment, index: number) => ( { unitId={comment.unitId} subUnitId={comment.subUnitId} refStr={comment.ref} + displayRef={formatRef?.(comment)} type={type} - showEdit={activeCommentId?.commentId === comment.id} - showHighlight={activeCommentId?.commentId === comment.id} + showEdit={!comment.id || isSameThreadCommentTarget(activeCommentId, comment)} + showHighlight={isSameThreadCommentTarget(activeCommentId, comment)} onClick={() => { - shouldScroll.current = false; + shouldScrollRef.current = false; if (!comment.resolved) { commandService.executeCommand( SetActiveCommentOperation.id, @@ -190,11 +242,26 @@ export const ThreadCommentPanel = (props: IThreadCommentPanelProps) => { commandService.executeCommand(SetActiveCommentOperation.id); } }} - onMouseEnter={() => onItemEnter?.(comment)} - onMouseLeave={() => onItemLeave?.(comment)} + onMouseEnter={() => { + panelService.setHoveredComment({ + unitId: comment.unitId, + subUnitId: comment.subUnitId, + commentId: comment.id, + trigger: 'panel-hover', + }); + onItemEnter?.(comment); + }} + onMouseLeave={() => { + if (isSameThreadCommentTarget(panelService.hoveredCommentId, comment)) { + panelService.setHoveredComment(undefined); + } + onItemLeave?.(comment); + }} onAddComment={onAddComment} onDeleteComment={onDeleteComment} + onAfterDeleteComment={onAfterDeleteComment} onResolve={(resolved: boolean) => onResolve?.(comment.id, resolved)} + onClose={!comment.id ? onTempCommentClose : undefined} /> ); diff --git a/packages/thread-comment-ui/src/views/ThreadCommentTree.tsx b/packages/thread-comment-ui/src/views/ThreadCommentTree.tsx index 560c9cf610..0bfbdc372c 100644 --- a/packages/thread-comment-ui/src/views/ThreadCommentTree.tsx +++ b/packages/thread-comment-ui/src/views/ThreadCommentTree.tsx @@ -59,6 +59,7 @@ export interface IThreadCommentTreeProps { subUnitId: string; type: UniverInstanceType; refStr?: string; + displayRef?: string; showEdit?: boolean; onClick?: () => void; showHighlight?: boolean; @@ -68,8 +69,9 @@ export interface IThreadCommentTreeProps { autoFocus?: boolean; onMouseEnter?: () => void; onMouseLeave?: () => void; - onAddComment?: (comment: IThreadComment) => boolean; + onAddComment?: (comment: IThreadComment) => boolean | void | Promise; onDeleteComment?: (comment: IThreadComment) => boolean; + onAfterDeleteComment?: (comment: IThreadComment) => void | Promise; onResolve?: (resolved: boolean) => void; style?: React.CSSProperties; } @@ -85,8 +87,8 @@ export interface IThreadCommentItemProps { onReply: (user: IUser | undefined) => void; isRoot?: boolean; onClose?: () => void; - onAddComment?: (comment: IThreadComment) => boolean; onDeleteComment?: (comment: IThreadComment) => boolean; + onAfterDeleteComment?: (comment: IThreadComment) => void | Promise; type: UniverInstanceType; threadCommentEditorId: string; } @@ -120,6 +122,7 @@ const ThreadCommentItem = (props: IThreadCommentItemProps) => { isRoot, onClose, onDeleteComment, + onAfterDeleteComment, type, threadCommentEditorId, } = props; @@ -138,21 +141,32 @@ const ThreadCommentItem = (props: IThreadCommentItemProps) => { const direction = useObservable(localeService.direction$, localeService.getDirection()); const dateText = isMock ? null : formatCommentDateTime(item.dT, currentRegion); - const handleDeleteItem = () => { + const handleDeleteItem = async () => { if (onDeleteComment?.(item) === false) { return; } - commandService.executeCommand( - isRoot ? DeleteCommentTreeCommand.id : DeleteCommentCommand.id, - { - unitId, - subUnitId, - commentId: item.id, + try { + const success = await commandService.executeCommand( + isRoot ? DeleteCommentTreeCommand.id : DeleteCommentCommand.id, + { + unitId, + subUnitId, + commentId: item.id, + } + ); + if (success) { + try { + await onAfterDeleteComment?.(item); + } catch { + // The comment is already deleted; auxiliary anchor cleanup must not reopen it. + } + if (isRoot) { + onClose?.(); + } } - ); - if (isRoot) { - onClose?.(); + } catch { + // Keep the thread visible so the user can retry after the remote failure. } }; @@ -187,11 +201,13 @@ const ThreadCommentItem = (props: IThreadCommentItemProps) => { : ( showReply && user ? ( -
('thread-comment-ui.editor.reply')} className={` univer-ml-1 univer-inline-flex univer-size-6 univer-cursor-pointer univer-items-center univer-justify-center univer-rounded-sm - univer-text-base + univer-border-0 univer-bg-transparent univer-p-0 univer-text-base hover:univer-bg-gray-50 rtl:univer-ml-0 rtl:univer-mr-1 dark:hover:!univer-bg-gray-800 @@ -199,7 +215,7 @@ const ThreadCommentItem = (props: IThreadCommentItemProps) => { onClick={() => onReply(user)} > -
+ ) : null )} @@ -213,43 +229,50 @@ const ThreadCommentItem = (props: IThreadCommentItemProps) => { univer-m-0 univer-box-border univer-grid univer-list-none univer-p-1.5 univer-text-sm rtl:univer-text-right - [&_a]:univer-block [&_a]:univer-cursor-pointer - [&_a]:univer-rounded [&_a]:univer-px-2 [&_a]:univer-py-1.5 - [&_a]:univer-transition-colors + [&_button]:univer-block [&_button]:univer-w-full + [&_button]:univer-cursor-pointer [&_button]:univer-rounded + [&_button]:univer-border-0 [&_button]:univer-bg-transparent + [&_button]:univer-px-2 [&_button]:univer-py-1.5 + [&_button]:univer-text-left [&_button]:univer-transition-colors + rtl:[&_button]:univer-text-right `} >
  • - onEditingChange?.(true)} > {localeService.t('thread-comment-ui.item.edit')} - +
  • - {localeService.t('thread-comment-ui.item.delete')} - +
  • )} > -
    ('thread-comment-ui.item.more')} className={` univer-ml-1 univer-inline-flex univer-size-6 univer-cursor-pointer univer-items-center univer-justify-center univer-rounded-sm - univer-text-base + univer-border-0 univer-bg-transparent univer-p-0 univer-text-base hover:univer-bg-gray-50 rtl:univer-ml-0 rtl:univer-mr-1 dark:hover:!univer-bg-gray-800 `} > -
    + ) : null} @@ -279,9 +302,8 @@ const ThreadCommentItem = (props: IThreadCommentItemProps) => { unitId={unitId} subUnitId={subUnitId} editorId={threadCommentEditorId} - onSave={({ text, attachments }) => { - onEditingChange?.(false); - commandService.executeCommand( + onSave={async ({ text, attachments }) => { + const success = await commandService.executeCommand( UpdateCommentCommand.id, { unitId, @@ -293,6 +315,10 @@ const ThreadCommentItem = (props: IThreadCommentItemProps) => { }, } as IUpdateCommentCommandParams ); + if (success) { + onEditingChange?.(false); + } + return success; }} /> ) @@ -342,6 +368,7 @@ export const ThreadCommentTree = (props: IThreadCommentTreeProps) => { unitId, subUnitId, refStr, + displayRef, showEdit = true, onClick, showHighlight, @@ -353,6 +380,7 @@ export const ThreadCommentTree = (props: IThreadCommentTreeProps) => { onMouseLeave, onAddComment, onDeleteComment, + onAfterDeleteComment, onResolve, type, style, @@ -374,6 +402,7 @@ export const ThreadCommentTree = (props: IThreadCommentTreeProps) => { [id, subUnitId, threadCommentModel, unitId, updte$] ); const commandService = useDependency(ICommandService); + const localeService = useDependency(LocaleService); const userManagerService = useDependency(UserManagerService); const resolved = comments?.root.resolved; const currentUser = useObservable(userManagerService.currentUser$); @@ -399,44 +428,56 @@ export const ThreadCommentTree = (props: IThreadCommentTreeProps) => { ]; const scrollerRef = useRef(null); const handleMouseLeave = useEvent(onMouseLeave); - const handleResolve: React.MouseEventHandler = (e) => { + const handleResolve: React.MouseEventHandler = async (e) => { e.stopPropagation(); - if (!resolved) { - commandService.executeCommand(SetActiveCommentOperation.id); - } else { - commandService.executeCommand(SetActiveCommentOperation.id, { + try { + const success = await commandService.executeCommand(ResolveCommentCommand.id, { unitId, subUnitId, commentId: id, + resolved: !resolved, }); + if (!success) { + return; + } + + await commandService.executeCommand(SetActiveCommentOperation.id, resolved + ? { unitId, subUnitId, commentId: id } + : undefined); + onResolve?.(!resolved); + } catch { + // Keep the current resolved state when the remote write fails. } - - commandService.executeCommand(ResolveCommentCommand.id, { - unitId, - subUnitId, - commentId: id, - resolved: !resolved, - }); - - onResolve?.(!resolved); }; - const handleDeleteRoot: React.MouseEventHandler = (e) => { + const handleDeleteRoot: React.MouseEventHandler = async (e) => { e.stopPropagation(); - commandService.executeCommand(SetActiveCommentOperation.id); - if (comments?.root && (onDeleteComment?.(comments.root) === false)) { + const root = comments?.root; + if (!root || onDeleteComment?.(root) === false) { return; } - commandService.executeCommand( - DeleteCommentTreeCommand.id, - { - unitId, - subUnitId, - commentId: id, + try { + const success = await commandService.executeCommand( + DeleteCommentTreeCommand.id, + { + unitId, + subUnitId, + commentId: id, + } + ); + if (success) { + try { + await onAfterDeleteComment?.(root); + } catch { + // The comment is already deleted; auxiliary anchor cleanup must not reopen it. + } + await commandService.executeCommand(SetActiveCommentOperation.id); + onClose?.(); } - ); - onClose?.(); + } catch { + // Keep the thread visible so the user can retry after the remote failure. + } }; useEffect(() => { @@ -445,7 +486,7 @@ export const ThreadCommentTree = (props: IThreadCommentTreeProps) => { const subUnitName = getSubUnitName(comments?.root.subUnitId ?? subUnitId); const editorVisible = showEdit && !editingId && !resolved; - const title = `${refStr || comments?.root.ref || ''}${subUnitName ? ' · ' : ''}${subUnitName}`; + const title = `${displayRef ?? refStr ?? comments?.root.ref ?? ''}${subUnitName ? ' · ' : ''}${subUnitName}`; const threadCommentEditorId = getThreadCommentEditorId({ location, unitId, @@ -508,10 +549,15 @@ export const ThreadCommentTree = (props: IThreadCommentTreeProps) => { {!!comments && (
    -
    (resolved + ? 'thread-comment-ui.filter.status.unsolved' + : 'thread-comment-ui.filter.status.resolved')} className={clsx(` univer-ml-1 univer-inline-flex univer-size-6 univer-cursor-pointer univer-items-center - univer-justify-center univer-rounded-[3px] univer-text-base + univer-justify-center univer-rounded-[3px] univer-border-0 univer-bg-transparent + univer-p-0 univer-text-base hover:univer-bg-gray-50 rtl:univer-ml-0 rtl:univer-mr-1 dark:hover:!univer-bg-gray-800 @@ -521,13 +567,16 @@ export const ThreadCommentTree = (props: IThreadCommentTreeProps) => { onClick={handleResolve} > {resolved ? : } -
    + {currentUser?.userID === comments.root.personId ? ( -
    ('thread-comment-ui.item.delete')} className={` univer-ml-1 univer-inline-flex univer-size-6 univer-cursor-pointer - univer-items-center univer-justify-center univer-rounded-[3px] univer-text-base + univer-items-center univer-justify-center univer-rounded-[3px] univer-border-0 + univer-bg-transparent univer-p-0 univer-text-base hover:univer-bg-gray-50 rtl:univer-ml-0 rtl:univer-mr-1 dark:hover:!univer-bg-gray-800 @@ -535,7 +584,7 @@ export const ThreadCommentTree = (props: IThreadCommentTreeProps) => { onClick={handleDeleteRoot} > -
    + ) : null}
    @@ -589,8 +638,8 @@ export const ThreadCommentTree = (props: IThreadCommentTreeProps) => { ])); }); }} - onAddComment={onAddComment} onDeleteComment={onDeleteComment} + onAfterDeleteComment={onAfterDeleteComment} /> ) )} @@ -618,11 +667,14 @@ export const ThreadCommentTree = (props: IThreadCommentTreeProps) => { threadId: comments?.root.threadId ?? '', }; - if (onAddComment?.(comment) === false) { - return; + if (await onAddComment?.(comment) === false) { + if (!comments) { + onClose?.(); + } + return true; } - await commandService.executeCommand( + const success = await commandService.executeCommand( AddCommentCommand.id, { unitId, @@ -630,9 +682,13 @@ export const ThreadCommentTree = (props: IThreadCommentTreeProps) => { comment, } as IAddCommentCommandParams ); - if (scrollerRef.current) { + if (success && scrollerRef.current) { scrollerRef.current.scrollTop = scrollerRef.current.scrollHeight; } + if (success && !comments) { + onClose?.(); + } + return success; }} autoFocus={autoFocus || (!comments)} onCancel={() => { diff --git a/packages/thread-comment-ui/src/views/__tests__/ThreadCommentEditor.spec.tsx b/packages/thread-comment-ui/src/views/__tests__/ThreadCommentEditor.spec.tsx index 1437237430..cd86f2213b 100644 --- a/packages/thread-comment-ui/src/views/__tests__/ThreadCommentEditor.spec.tsx +++ b/packages/thread-comment-ui/src/views/__tests__/ThreadCommentEditor.spec.tsx @@ -22,7 +22,7 @@ import type { IDocumentData, Nullable, } from '@univerjs/core'; -import type { ISuccinctDocRangeParam } from '@univerjs/engine-render'; +import type { ISuccinctDocRangeParam, ITextRangeWithStyle } from '@univerjs/engine-render'; import type { IShortcutItem } from '@univerjs/ui'; import type { FormEvent } from 'react'; import type { Root } from 'react-dom/client'; @@ -46,7 +46,7 @@ import { UniverInstanceType, } from '@univerjs/core'; import { IEditorService } from '@univerjs/docs-ui'; -import { IShortcutService, ISidebarService, RediContext } from '@univerjs/ui'; +import { IShortcutService, ISidebarService, KeyCode, RediContext } from '@univerjs/ui'; import { act, createRef } from 'react'; import { createRoot } from 'react-dom/client'; import { BehaviorSubject, Subject } from 'rxjs'; @@ -60,7 +60,6 @@ import { ThreadCommentEditor } from '../ThreadCommentEditor'; const EDITOR_ID = 'thread-comment-editor'; const REPLY_LABEL = 'thread-comment-ui.editor.reply'; const CANCEL_LABEL = 'thread-comment-ui.editor.cancel'; -const PLACEHOLDER_LABEL = 'thread-comment-ui.editor.placeholder'; const IRenderManagerService = createIdentifier('engine-render.render-manager.service'); interface IEditorRecord { @@ -78,6 +77,9 @@ class TestState { static submitCount = 0; static cancelCount = 0; static shortcuts: IShortcutItem[] = []; + static editorCommandIds: string[] = []; + static focusRender: (() => void) | undefined; + static blurRender: (() => void) | undefined; static reset() { this.records = new Map(); @@ -85,6 +87,9 @@ class TestState { this.submitCount = 0; this.cancelCount = 0; this.shortcuts = []; + this.editorCommandIds = []; + this.focusRender = undefined; + this.blurRender = undefined; } } @@ -131,12 +136,14 @@ class TestEditor { focus(): void { this._record.focused = true; this._record.order.push('editor.focus'); + TestState.focusRender?.(); this.focus$.next(undefined); } blur(): void { this._record.focused = false; this._record.order.push('editor.blur'); + TestState.blurRender?.(); this.blur$.next(undefined); } @@ -261,15 +268,46 @@ class TestEditorService { } } +class TestDocSelectionRenderService { + readonly textSelectionInner$ = new BehaviorSubject({ + textRanges: [] as ITextRangeWithStyle[], + }); + + isFocusing = false; + + focus(): void { + this.isFocusing = true; + this.textSelectionInner$.next({ + textRanges: [{ startOffset: 0, endOffset: 0, collapsed: true }], + }); + } + + blur(): void { + this.isFocusing = false; + this.textSelectionInner$.next({ textRanges: [] }); + } + + getSkeleton() { + return { getActualSize: () => ({ actualWidth: 0, actualHeight: 0 }) }; + } +} + class TestRenderManagerService { readonly created$ = new Subject(); readonly disposed$ = new Subject(); readonly defaultEngine = {}; + private readonly _renderService = new TestDocSelectionRenderService(); + private readonly _renderer = { with: () => this._renderService }; + + constructor() { + TestState.focusRender = () => this._renderService.focus(); + TestState.blurRender = () => this._renderService.blur(); + } addRender(): void {} createRender() { return undefined; } removeRender(): void {} - getRenderUnitById() { return undefined; } + getRenderUnitById() { return this._renderer; } getAllRenderersOfType() { return []; } getRenderAll() { return new Map(); } create(): void {} @@ -333,6 +371,26 @@ const BreakLineCommand: ICommand = { type: CommandType.COMMAND, handler: () => true, }; +const DeleteLeftCommand: ICommand = { + id: 'doc.command.delete-left', + type: CommandType.COMMAND, + handler: () => { + TestState.editorCommandIds.push(DeleteLeftCommand.id); + const record = TestState.records.get(EDITOR_ID); + if (record) { + record.data = { ...record.data, body: { dataStream: '\r\n' } }; + } + return true; + }, +}; +const DeleteRightCommand: ICommand = { + id: 'doc.command.delete-right', + type: CommandType.COMMAND, + handler: () => { + TestState.editorCommandIds.push(DeleteRightCommand.id); + return true; + }, +}; function createEditorTestBed() { const injector = new Injector(); @@ -354,7 +412,8 @@ function createEditorTestBed() { injector.get(ILogService).setLogLevel(LogLevel.SILENT); const commandService = injector.get(ICommandService); - [SetActiveCommentOperation, BreakLineCommand].forEach((command) => commandService.registerCommand(command)); + [SetActiveCommentOperation, BreakLineCommand, DeleteLeftCommand, DeleteRightCommand] + .forEach((command) => commandService.registerCommand(command)); return { injector, @@ -388,15 +447,6 @@ function getButton(container: HTMLElement, text: string): HTMLButtonElement { return button as HTMLButtonElement; } -function getEditorSurface(container: HTMLElement): HTMLElement { - const placeholder = Array.from(container.querySelectorAll('div')).find((item) => item.textContent === PLACEHOLDER_LABEL); - if (!placeholder) { - throw new Error('Editor placeholder was not found.'); - } - - return placeholder.parentElement?.parentElement?.parentElement ?? placeholder as HTMLElement; -} - function body(dataStream: string): IDocumentBody { return { dataStream }; } @@ -472,7 +522,42 @@ describe('ThreadCommentEditor', () => { expect(TestState.records.get(EDITOR_ID)?.preserveHostFocus).toBe(true); }); - it('saves reply content, clears the editor, and does not submit an outer form', () => { + it('routes Backspace and Delete to the comment editor commands while preserving host focus', async () => { + const testBed = createEditorTestBed(); + const editorRef = createRef<{ reply: (text: IDocumentBody) => void }>(); + const rendered = renderEditor( + testBed.injector, + + ); + root = rendered.root; + container = rendered.container; + + act(() => editorRef.current?.reply(body('draft reply\r\n'))); + + const backspace = TestState.shortcuts.find((shortcut) => shortcut.binding === KeyCode.BACKSPACE)!; + const deleteRight = TestState.shortcuts.find((shortcut) => shortcut.binding === KeyCode.DELETE)!; + expect(backspace).toBeDefined(); + expect(deleteRight).toBeDefined(); + + await act(async () => { + await testBed.commandService.executeCommand(backspace.id, backspace.staticParameters); + await testBed.commandService.executeCommand(deleteRight.id, deleteRight.staticParameters); + await Promise.resolve(); + }); + + expect(TestState.editorCommandIds).toEqual([DeleteLeftCommand.id, DeleteRightCommand.id]); + expect(TestState.records.get(EDITOR_ID)?.preserveHostFocus).toBe(true); + expect(getButton(container, REPLY_LABEL).disabled).toBe(true); + }); + + it('saves reply content, clears the editor, and does not submit an outer form', async () => { const testBed = createEditorTestBed(); const editorRef = createRef<{ reply: (text: IDocumentBody) => void }>(); const rendered = renderEditor( @@ -506,7 +591,7 @@ describe('ThreadCommentEditor', () => { expect(getButton(container, REPLY_LABEL).disabled).toBe(false); - act(() => { + await act(async () => { getButton(container!, REPLY_LABEL).dispatchEvent(new MouseEvent('click', { bubbles: true })); }); @@ -516,8 +601,92 @@ describe('ThreadCommentEditor', () => { const record = TestState.records.get(EDITOR_ID)!; expect(record.data.body?.dataStream).toBe('\r\n'); expect(record.selections).toEqual([]); + expect(record.order.indexOf('onSave')).toBeLessThan(record.order.indexOf('editor.blur')); expect(record.order.indexOf('editor.blur')).toBeLessThan(record.order.indexOf('editor.replaceText')); - expect(record.order.indexOf('editor.replaceText')).toBeLessThan(record.order.indexOf('onSave')); + }); + + it('preserves a reply draft and prevents duplicate submits when permission is revoked during save', async () => { + const testBed = createEditorTestBed(); + const editorRef = createRef<{ reply: (text: IDocumentBody) => void }>(); + let saveCount = 0; + let failSave: ((error: Error) => void) | undefined; + const rendered = renderEditor( + testBed.injector, + { + saveCount += 1; + return new Promise((_resolve, reject) => { + failSave = reject; + }); + }} + subUnitId="subUnit" + type={UniverInstanceType.UNIVER_SHEET} + unitId="unit" + /> + ); + root = rendered.root; + container = rendered.container; + + act(() => { + editorRef.current?.reply(body('retry this reply\r\n')); + }); + + await act(async () => { + const replyButton = getButton(container!, REPLY_LABEL); + replyButton.dispatchEvent(new MouseEvent('click', { bubbles: true })); + replyButton.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + + expect(saveCount).toBe(1); + expect(getButton(container, REPLY_LABEL).disabled).toBe(true); + + await act(async () => { + failSave?.(new Error('forbidden')); + }); + + expect(TestState.records.get(EDITOR_ID)?.data.body?.dataStream).toBe('retry this reply\r\n'); + expect(getButton(container, REPLY_LABEL).disabled).toBe(false); + }); + + it('does not mutate a disposed editor when a pending save finishes', async () => { + const testBed = createEditorTestBed(); + const editorRef = createRef<{ reply: (text: IDocumentBody) => void }>(); + let finishSave: ((success: boolean) => void) | undefined; + const rendered = renderEditor( + testBed.injector, + new Promise((resolve) => { + finishSave = resolve; + })} + subUnitId="subUnit" + type={UniverInstanceType.UNIVER_SHEET} + unitId="unit" + /> + ); + root = rendered.root; + container = rendered.container; + + act(() => editorRef.current?.reply(body('pending reply\r\n'))); + await act(async () => { + getButton(container!, REPLY_LABEL).dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + const record = TestState.records.get(EDITOR_ID)!; + + act(() => root!.unmount()); + root = undefined; + await act(async () => { + finishSave?.(true); + await Promise.resolve(); + }); + + expect(record.order).not.toContain('editor.blur'); + expect(record.data.body?.dataStream).toBe('pending reply\r\n'); }); it('cancels editing by clearing content and resetting the active comment', () => { diff --git a/packages/thread-comment-ui/src/views/__tests__/ThreadCommentPanel.spec.tsx b/packages/thread-comment-ui/src/views/__tests__/ThreadCommentPanel.spec.tsx index c39d6b283b..90f9c7aa68 100644 --- a/packages/thread-comment-ui/src/views/__tests__/ThreadCommentPanel.spec.tsx +++ b/packages/thread-comment-ui/src/views/__tests__/ThreadCommentPanel.spec.tsx @@ -60,7 +60,7 @@ import { IShortcutService, ISidebarService, RediContext } from '@univerjs/ui'; import { act } from 'react'; import { createRoot } from 'react-dom/client'; import { BehaviorSubject, Subject } from 'rxjs'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { SetActiveCommentOperation } from '../../commands/operations/comment.operations'; import { ThreadCommentPanelService } from '../../services/thread-comment-panel.service'; import { transformTextNodes2Document } from '../thread-comment-editor/util'; @@ -489,6 +489,119 @@ describe('ThreadCommentPanel', () => { expect(panelText.indexOf('thread-comment-ui.panel.solved')).toBeLessThan(panelText.indexOf('Resolved comment')); }); + it('keeps an empty-id temporary target active and shows its editor', () => { + const testBed = createPanelTestBed(); + const onTempCommentClose = vi.fn(); + testBed.panelService.setActiveComment({ + unitId: UNIT_ID, + subUnitId: SHEET_ID, + commentId: '', + }); + const rendered = renderPanel( + testBed.injector, + (SHEET_ID)} + type={UniverInstanceType.UNIVER_SHEET} + onAdd={() => undefined} + getSubUnitName={(subUnitId) => subUnitId} + tempComment={createComment({ id: '', threadId: '', ref: '#shape-1' })} + onTempCommentClose={onTempCommentClose} + /> + ); + root = rendered.root; + container = rendered.container; + + expect(container.textContent).toContain('thread-comment-ui.editor.placeholder'); + expect(testBed.panelService.activeCommentId).toEqual({ + unitId: UNIT_ID, + subUnitId: SHEET_ID, + commentId: '', + }); + + act(() => { + container!.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })); + }); + expect(testBed.panelService.activeCommentId).toBeUndefined(); + expect(onTempCommentClose).toHaveBeenCalledTimes(1); + }); + + it('closes a temporary comment when the active subunit changes', () => { + const testBed = createPanelTestBed(); + const activeSubUnit$ = new BehaviorSubject(SHEET_ID); + const onTempCommentClose = vi.fn(); + const rendered = renderPanel( + testBed.injector, + undefined} + getSubUnitName={(subUnitId) => subUnitId} + tempComment={createComment({ id: '', threadId: '', ref: '#shape-1' })} + onTempCommentClose={onTempCommentClose} + /> + ); + root = rendered.root; + container = rendered.container; + + act(() => activeSubUnit$.next('sheet-2')); + + expect(onTempCommentClose).toHaveBeenCalledTimes(1); + }); + + it('shows only the active subunit and updates when it changes', () => { + const testBed = createPanelTestBed(); + const activeSubUnit$ = new BehaviorSubject(SHEET_ID); + addRootComment(testBed.threadCommentModel, createComment({ id: 'sheet-1-thread' })); + addRootComment(testBed.threadCommentModel, createComment({ + id: 'sheet-2-thread', + subUnitId: 'sheet-2', + })); + const rendered = renderPanel( + testBed.injector, + undefined} + getSubUnitName={(subUnitId) => subUnitId} + /> + ); + root = rendered.root; + container = rendered.container; + + expect(container.textContent).toContain('sheet-1-thread'); + expect(container.textContent).not.toContain('sheet-2-thread'); + + act(() => activeSubUnit$.next('sheet-2')); + + expect(container.textContent).not.toContain('sheet-1-thread'); + expect(container.textContent).toContain('sheet-2-thread'); + }); + + it('does not render a temporary comment owned by another unit', () => { + const testBed = createPanelTestBed(); + const onTempCommentClose = vi.fn(); + const rendered = renderPanel( + testBed.injector, + (SHEET_ID)} + type={UniverInstanceType.UNIVER_SHEET} + onAdd={() => undefined} + getSubUnitName={(subUnitId) => subUnitId} + tempComment={createComment({ id: '', threadId: '', unitId: 'other-unit' })} + onTempCommentClose={onTempCommentClose} + /> + ); + root = rendered.root; + container = rendered.container; + + expect(container.textContent).not.toContain('thread-comment-ui.editor.placeholder'); + expect(onTempCommentClose).toHaveBeenCalledTimes(1); + }); + it('sets active comment from unresolved panel items and clears it for resolved items', () => { const testBed = createPanelTestBed(); addRootComment(testBed.threadCommentModel, createComment({ id: 'open-thread', ref: 'A1' })); @@ -515,4 +628,70 @@ describe('ThreadCommentPanel', () => { expect(testBed.panelService.activeCommentId).toBeUndefined(); }); + + it('clears the active comment when the primary pointer hits a blank area', () => { + const testBed = createPanelTestBed(); + addRootComment(testBed.threadCommentModel, createComment({ id: 'open-thread', ref: 'A1' })); + + const rendered = renderDefaultPanel(testBed.injector); + root = rendered.root; + container = rendered.container; + const panelItem = getPanelItem(container, 'open-thread'); + + act(() => { + panelItem.dispatchEvent(new MouseEvent('click', { bubbles: true })); + panelItem.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })); + }); + expect(testBed.panelService.activeCommentId?.commentId).toBe('open-thread'); + + act(() => { + container!.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })); + }); + expect(testBed.panelService.activeCommentId).toBeUndefined(); + + const canvas = document.createElement('canvas'); + document.body.appendChild(canvas); + act(() => { + panelItem.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + act(() => { + canvas.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })); + }); + expect(testBed.panelService.activeCommentId).toBeUndefined(); + }); + + it('clears the active target when a remote update deletes its thread', () => { + const testBed = createPanelTestBed(); + addRootComment(testBed.threadCommentModel, createComment({ id: 'remote-thread', ref: 'A1' })); + const rendered = renderDefaultPanel(testBed.injector); + root = rendered.root; + container = rendered.container; + + act(() => { + getPanelItem(container!, 'remote-thread').dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + expect(testBed.panelService.activeCommentId?.commentId).toBe('remote-thread'); + + act(() => { + testBed.threadCommentModel.deleteThread(UNIT_ID, SHEET_ID, 'remote-thread'); + }); + + expect(testBed.panelService.activeCommentId).toBeUndefined(); + }); + + it('renders hundreds of comment threads within a bounded time', () => { + const testBed = createPanelTestBed(); + for (let index = 0; index < 250; index += 1) { + addRootComment(testBed.threadCommentModel, createComment({ id: `bulk-${index}` })); + } + + const startedAt = performance.now(); + const rendered = renderDefaultPanel(testBed.injector); + const elapsed = performance.now() - startedAt; + root = rendered.root; + container = rendered.container; + + expect(container.querySelectorAll(`[id^="PANEL-${UNIT_ID}-${SHEET_ID}-bulk-"]`)).toHaveLength(250); + expect(elapsed).toBeLessThan(5_000); + }); }); diff --git a/packages/thread-comment-ui/src/views/__tests__/ThreadCommentTree.spec.tsx b/packages/thread-comment-ui/src/views/__tests__/ThreadCommentTree.spec.tsx index a55bde5883..f92ecf7ad1 100644 --- a/packages/thread-comment-ui/src/views/__tests__/ThreadCommentTree.spec.tsx +++ b/packages/thread-comment-ui/src/views/__tests__/ThreadCommentTree.spec.tsx @@ -14,13 +14,14 @@ * limitations under the License. */ -import type { Dependency, IDisposable, IDocumentData, Nullable } from '@univerjs/core'; +import type { Dependency, ICommand, IDisposable, IDocumentData, Nullable } from '@univerjs/core'; import type { ISuccinctDocRangeParam } from '@univerjs/engine-render'; import type { IThreadComment } from '@univerjs/thread-comment'; import type { IShortcutItem } from '@univerjs/ui'; import type { Root } from 'react-dom/client'; import { CommandService, + CommandType, ConfigService, ContextService, createIdentifier, @@ -346,6 +347,12 @@ class TestUniverInstanceService { } } +const TestRichTextEditingMutation: ICommand = { + id: 'doc.mutation.rich-text-editing', + type: CommandType.MUTATION, + handler: () => true, +}; + function createComment(overrides: Partial): IThreadComment { const id = overrides.id ?? 'thread-1'; @@ -406,9 +413,11 @@ function createTreeTestBed() { SetActiveCommentOperation, UpdateCommentCommand, UpdateCommentMutation, + TestRichTextEditingMutation, ].forEach((command) => commandService.registerCommand(command)); return { + commandService, injector, editorService: injector.get(IEditorService), panelService: injector.get(ThreadCommentPanelService), @@ -640,6 +649,49 @@ describe('ThreadCommentTree', () => { expect(container.querySelector('time')).toBeNull(); }); + it('treats HTML-looking comment content as text instead of DOM', () => { + const testBed = createTreeTestBed(); + addRootComment(testBed.threadCommentModel, createComment({ + id: 'untrusted-thread', + text: { dataStream: '\r\n' }, + })); + + const rendered = renderDefaultTree(testBed.injector, 'untrusted-thread'); + root = rendered.root; + container = rendered.container; + + expect(container.querySelector('img[src="x"]')).toBeNull(); + expect(container.querySelector('script')).toBeNull(); + }); + + it('exposes icon-only thread actions as labeled keyboard controls', () => { + const testBed = createTreeTestBed(); + addRootComment(testBed.threadCommentModel, createComment({ id: 'accessible-thread' })); + + const rendered = renderDefaultTree(testBed.injector, 'accessible-thread'); + root = rendered.root; + container = rendered.container; + + expect(container.querySelector('button[aria-label="thread-comment-ui.item.more"]')).not.toBeNull(); + expect(container.querySelector('button[aria-label="thread-comment-ui.filter.status.resolved"]')).not.toBeNull(); + expect(container.querySelector('button[aria-label="thread-comment-ui.item.delete"]')).not.toBeNull(); + }); + + it('keeps comment content visible when its author is no longer in the user directory', () => { + const testBed = createTreeTestBed(); + addRootComment(testBed.threadCommentModel, createComment({ + id: 'removed-author-thread', + personId: 'removed-user', + text: transformTextNodes2Document([{ type: 'text', content: 'kept after user removal' }]), + })); + + const rendered = renderDefaultTree(testBed.injector, 'removed-author-thread'); + root = rendered.root; + container = rendered.container; + + expect(container.textContent).toContain('kept after user removal'); + }); + it('adds a reply through the tree editor and stores it under the root thread', async () => { const testBed = createTreeTestBed(); addRootComment(testBed.threadCommentModel, createComment({ id: 'root-thread', ref: 'A1' })); @@ -677,6 +729,53 @@ describe('ThreadCommentTree', () => { expect(TestState.addedComments[0].parentId).toBe('root-thread'); }); + it('awaits a product-handled root add and closes the temporary thread without adding it twice', async () => { + const testBed = createTreeTestBed(); + let closeCount = 0; + const handledComments: IThreadComment[] = []; + const rendered = renderTree( + testBed.injector, + ''} + location={ThreadCommentTreeLocation.PANEL} + onAddComment={async (comment) => { + await Promise.resolve(); + handledComments.push(comment); + return false; + }} + onClose={() => { + closeCount += 1; + }} + /> + ); + root = rendered.root; + container = rendered.container; + + await act(async () => { + const editorMouseTarget = container!.lastElementChild?.lastElementChild?.firstElementChild?.firstElementChild; + if (!editorMouseTarget) { + throw new Error('Editor mouse target was not found.'); + } + editorMouseTarget.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })); + const editor = getOnlyEditor(testBed); + editor.replaceText('Handled by host'); + await testBed.commandService.executeCommand(TestRichTextEditingMutation.id, { unitId: editor.getEditorId() }); + await waitForFrame(); + }); + await act(async () => { + getButton(container!, REPLY_LABEL).dispatchEvent(new MouseEvent('click', { bubbles: true })); + await waitForTreeRefresh(); + }); + + expect(handledComments).toHaveLength(1); + expect(testBed.threadCommentModel.getUnit(UNIT_ID)).toEqual([]); + expect(closeCount).toBe(1); + }); + it('toggles the resolved state and updates the active comment target', async () => { const testBed = createTreeTestBed(); addRootComment(testBed.threadCommentModel, createComment({ id: 'root-thread', ref: 'A1' })); diff --git a/packages/thread-comment-ui/src/views/thread-comment-panel/__tests__/util.spec.ts b/packages/thread-comment-ui/src/views/thread-comment-panel/__tests__/util.spec.ts index bee90c4705..4374229f93 100644 --- a/packages/thread-comment-ui/src/views/thread-comment-panel/__tests__/util.spec.ts +++ b/packages/thread-comment-ui/src/views/thread-comment-panel/__tests__/util.spec.ts @@ -16,7 +16,7 @@ import type { IThreadComment } from '@univerjs/thread-comment'; import { describe, expect, it } from 'vitest'; -import { getThreadCommentPanelItemKey } from '../util'; +import { getThreadCommentPanelItemKey, isSameThreadCommentTarget, shouldClearThreadCommentTarget } from '../util'; describe('getThreadCommentPanelItemKey', () => { it('returns unique keys for comments with empty ids', () => { @@ -55,3 +55,22 @@ describe('getThreadCommentPanelItemKey', () => { expect(getThreadCommentPanelItemKey(comment, 0, 'unsolved')).toBe('comment-1'); }); }); + +describe('isSameThreadCommentTarget', () => { + const comment = { id: 'comment-1', unitId: 'unit-1', subUnitId: 'sub-unit-1' } as IThreadComment; + + it('requires the unit, subunit, and comment ids to match', () => { + expect(isSameThreadCommentTarget({ unitId: 'unit-1', subUnitId: 'sub-unit-1', commentId: 'comment-1' }, comment)).toBe(true); + expect(isSameThreadCommentTarget({ unitId: 'unit-2', subUnitId: 'sub-unit-1', commentId: 'comment-1' }, comment)).toBe(false); + expect(isSameThreadCommentTarget({ unitId: 'unit-1', subUnitId: 'sub-unit-2', commentId: 'comment-1' }, comment)).toBe(false); + }); +}); + +describe('shouldClearThreadCommentTarget', () => { + it('clears deleted targets and targets from a previously active unit', () => { + expect(shouldClearThreadCommentTarget({ unitId: 'unit-1' }, 'unit-1', true)).toBe(false); + expect(shouldClearThreadCommentTarget({ unitId: 'unit-1' }, 'unit-1', false)).toBe(true); + expect(shouldClearThreadCommentTarget({ unitId: 'unit-1' }, 'unit-2', true)).toBe(true); + expect(shouldClearThreadCommentTarget(undefined, 'unit-1', false)).toBe(false); + }); +}); diff --git a/packages/thread-comment-ui/src/views/thread-comment-panel/util.ts b/packages/thread-comment-ui/src/views/thread-comment-panel/util.ts index 20c5c05804..b6f15b42f1 100644 --- a/packages/thread-comment-ui/src/views/thread-comment-panel/util.ts +++ b/packages/thread-comment-ui/src/views/thread-comment-panel/util.ts @@ -18,6 +18,26 @@ import type { IThreadComment } from '@univerjs/thread-comment'; export type ThreadCommentPanelSection = 'unsolved' | 'solved'; +export function isSameThreadCommentTarget( + active: { unitId: string; subUnitId: string; commentId: string } | null | undefined | void, + comment: Pick +): boolean { + return active?.unitId === comment.unitId && + active.subUnitId === comment.subUnitId && + active.commentId === comment.id; +} + +export function shouldClearThreadCommentTarget( + active: { unitId: string } | null | undefined | void, + currentUnitId: string, + commentExists: boolean +): boolean { + if (!active) { + return false; + } + return active.unitId !== currentUnitId || !commentExists; +} + export function getThreadCommentPanelItemKey( comment: IThreadComment, index: number, diff --git a/packages/thread-comment/README.md b/packages/thread-comment/README.md index d9ec5365f0..ba004163e7 100644 --- a/packages/thread-comment/README.md +++ b/packages/thread-comment/README.md @@ -10,7 +10,7 @@ | Package | UMD global | CSS | Locales | Facade entry | | --- | --- | :---: | :---: | :---: | -| `@univerjs/thread-comment` | `UniverThreadComment` | No | No | No | +| `@univerjs/thread-comment` | `UniverThreadComment` | No | No | Yes | ## Installation @@ -26,10 +26,54 @@ Keep all `@univerjs/*` packages on the same version. ```ts import { UniverThreadCommentPlugin } from '@univerjs/thread-comment'; +import '@univerjs/thread-comment/facade'; univer.registerPlugin(UniverThreadCommentPlugin); + +const openComments = univerAPI.getComments({ + authorIds: ['review-agent'], + anchorKinds: [univerAPI.Enum.ThreadCommentAnchorKind.SLIDE_ELEMENT], + resolved: false, +}); ``` +All commentable targets use stable IDs and a shared query vocabulary: + +| Product target | Product Facade | `ThreadCommentAnchorKind` | +| --- | --- | --- | +| Sheet cell | `FRange.addCommentAsync()` | `SHEET_CELL` | +| Sheet image, chart, or Shape | element `createCommentAsync()` | `SHEET_DRAWING` | +| Document text range | `FDocumentTextRange.createCommentAsync()` | `DOC_TEXT_RANGE` | +| Document image, chart, or Shape | element `createCommentAsync()` | `DOC_DRAWING` | +| Slide Shape, text, image, chart, table, or group | `FPageElement.createCommentAsync()` or `FSlide.createElementCommentAsync()` | `SLIDE_ELEMENT` | +| Slide free position | `FSlide.createPositionCommentAsync()` | `SLIDE_POSITION` | +| Board Shape, text, image, chart, table, connector, mind map, or ink | `FBoard.createElementCommentAsync()` | `BOARD_ELEMENT` | +| Board free position | `FBoard.createPositionCommentAsync()` | `BOARD_POSITION` | +| Base record in Grid, Kanban, Gallery, Calendar, or Gantt | `FBaseTableRecord.createCommentAsync()` | `BASE_RECORD` | + +Product Facades create and locate anchored threads. Use the shared Facade for thread lifecycle operations: + +```ts +const record = univerAPI.getActiveBase()?.getTables()[0]?.getRecords()[0]; +const [thread] = record ? await record.listCommentsAsync() : []; +if (thread) { + await univerAPI.replyCommentAsync({ + unitId: thread.unitId, + subUnitId: thread.subUnitId, + threadId: thread.threadId, + content: 'Verified.', + id: 'review-record-1-reply', + }); + await univerAPI.resolveCommentAsync({ + unitId: thread.unitId, + subUnitId: thread.subUnitId, + commentId: thread.root.id, + }); +} +``` + +`personId` defaults to the current identity in `UserManagerService`. Agent and human identities intentionally share that user-management boundary. + ## Integration Notes Product-specific packages such as `@univerjs/sheets-thread-comment` and `@univerjs/docs-thread-comment-ui` build on this shared layer. diff --git a/packages/thread-comment/package.json b/packages/thread-comment/package.json index 54d9f5cd91..4b27988445 100644 --- a/packages/thread-comment/package.json +++ b/packages/thread-comment/package.json @@ -26,6 +26,7 @@ ], "exports": { ".": "./src/index.ts", + "./facade": "./src/facade/index.ts", "./*": "./src/*" }, "main": "./src/index.ts", @@ -45,6 +46,16 @@ "require": "./lib/cjs/*", "types": "./lib/types/index.d.ts" }, + "./facade": { + "import": "./lib/es/facade.js", + "require": "./lib/cjs/facade.js", + "types": "./lib/types/facade/index.d.ts" + }, + "./lib/facade": { + "import": "./lib/es/facade.js", + "require": "./lib/cjs/facade.js", + "types": "./lib/types/facade/index.d.ts" + }, "./lib/*": "./lib/*" } }, diff --git a/packages/thread-comment/src/commands/commands/__tests__/comment.command.spec.ts b/packages/thread-comment/src/commands/commands/__tests__/comment.command.spec.ts index 1aeb242921..ad25795772 100644 --- a/packages/thread-comment/src/commands/commands/__tests__/comment.command.spec.ts +++ b/packages/thread-comment/src/commands/commands/__tests__/comment.command.spec.ts @@ -26,10 +26,19 @@ import { Univer, UniverInstanceType, } from '@univerjs/core'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { ThreadCommentModel } from '../../../models/thread-comment.model'; import { UniverThreadCommentPlugin } from '../../../plugin'; -import { UpdateCommentRefMutation } from '../../mutations/comment.mutation'; +import { IThreadCommentDataSourceService } from '../../../services/tc-datasource.service'; +import { ThreadCommentFacadeService } from '../../../services/thread-comment-api.service'; +import { ThreadCommentAnchorKind } from '../../../types/comment-anchor'; +import { + AddCommentMutation, + DeleteCommentMutation, + ResolveCommentMutation, + UpdateCommentMutation, + UpdateCommentRefMutation, +} from '../../mutations/comment.mutation'; import { AddCommentCommand, DeleteCommentCommand, @@ -182,6 +191,16 @@ describe('Thread comment commands', () => { resolved: true, }); + const deleteComment = vi.fn(async () => true); + get(IThreadCommentDataSourceService).dataSource = { + addComment: async (comment) => comment, + updateComment: async () => true, + resolveComment: async () => true, + deleteComment, + listComments: async () => [], + saveCommentToSnapshot: (comment) => comment, + }; + await commandService.executeCommand(DeleteCommentTreeCommand.id, { unitId: reply.unitId, subUnitId: reply.subUnitId, @@ -215,6 +234,7 @@ describe('Thread comment commands', () => { }, ]); expect(deletedCommentIds).toEqual(['root-2', 'reply-2']); + expect(deleteComment).toHaveBeenCalledWith('unit-1', 'sheet-1', root.threadId, root.id); }); it('updates comment content and keeps the thread relationship', async () => { @@ -302,6 +322,127 @@ describe('Thread comment commands', () => { expect(threadCommentModel.getComment('unit-1', 'sheet-1', root.id)?.ref).toBe('B2'); }); + it('runs create, update, reply, and delete through the facade', async () => { + const facade = get(ThreadCommentFacadeService); + const location = { unitId: 'unit-1', subUnitId: 'sheet-1' }; + + await facade.createCommentAsync({ + ...location, + id: 'facade-root', + personId: 'owner', + anchor: { kind: ThreadCommentAnchorKind.SLIDE_POSITION, pageId: 'sheet-1', x: 0.25, y: 0.5 }, + content: 'created', + }); + await facade.updateCommentAsync({ ...location, commentId: 'facade-root', content: 'updated' }); + await facade.replyCommentAsync({ + ...location, + threadId: 'facade-root', + id: 'facade-reply', + personId: 'guest', + content: 'reply', + }); + + const thread = facade.getComments({ anchorKinds: [ThreadCommentAnchorKind.SLIDE_POSITION] })[0]; + expect(thread.root.text.dataStream).toBe('updated\r\n'); + expect(thread.children.map((comment) => comment.id)).toEqual(['facade-reply']); + + await facade.deleteCommentAsync({ ...location, commentId: 'facade-reply' }); + expect(facade.getComments()[0].children).toEqual([]); + await facade.deleteCommentAsync({ ...location, commentId: 'facade-root', deleteThread: true }); + expect(facade.getComments()).toEqual([]); + }); + + it('batches facade refreshes by unit and subunit instead of issuing one request per thread', async () => { + const facade = get(ThreadCommentFacadeService); + const roots = [ + createComment({ id: 'batch-root-1', subUnitId: 'sheet-1' }), + createComment({ id: 'batch-root-2', subUnitId: 'sheet-1' }), + createComment({ id: 'batch-root-3', subUnitId: 'sheet-2' }), + ]; + roots.forEach((root) => threadCommentModel.addComment(root.unitId, root.subUnitId, root)); + const listComments = vi.fn(async (_unitId: string, _subUnitId: string, threadIds: string[]) => ( + roots.filter((root) => threadIds.includes(root.threadId)) + )); + get(IThreadCommentDataSourceService).dataSource = { + addComment: async (comment) => comment, + updateComment: async () => true, + resolveComment: async () => true, + deleteComment: async () => true, + listComments, + saveCommentToSnapshot: (comment) => comment, + }; + + await expect(facade.listCommentsAsync()).resolves.toHaveLength(3); + + expect(listComments).toHaveBeenCalledTimes(2); + expect(listComments).toHaveBeenCalledWith('unit-1', 'sheet-1', ['batch-root-1', 'batch-root-2']); + expect(listComments).toHaveBeenCalledWith('unit-1', 'sheet-2', ['batch-root-3']); + }); + + it.each([ + ['sheet drawing', { kind: ThreadCommentAnchorKind.SHEET_DRAWING, elementId: 'sheet-drawing-1' }], + ['doc drawing', { kind: ThreadCommentAnchorKind.DOC_DRAWING, elementId: 'doc-drawing-1' }], + ['slide element', { kind: ThreadCommentAnchorKind.SLIDE_ELEMENT, elementId: 'slide-element-1', pageId: 'page-1' }], + ['slide position', { kind: ThreadCommentAnchorKind.SLIDE_POSITION, x: 0.25, y: 0.5, pageId: 'page-1' }], + ['board element', { kind: ThreadCommentAnchorKind.BOARD_ELEMENT, elementId: 'board-element-1' }], + ['board position', { kind: ThreadCommentAnchorKind.BOARD_POSITION, x: 120, y: 80 }], + ['base record', { kind: ThreadCommentAnchorKind.BASE_RECORD, tableId: 'table-1', recordId: 'record-1' }], + ] as const)('runs the complete facade CRUD chain for a %s anchor', async (_name, anchor) => { + const facade = get(ThreadCommentFacadeService); + const location = { unitId: 'unit-1', subUnitId: 'sheet-1' }; + const rootId = `root-${anchor.kind}`; + const replyId = `reply-${anchor.kind}`; + + await facade.createCommentAsync({ + ...location, + id: rootId, + personId: 'owner', + anchor, + content: 'created', + }); + await facade.updateCommentAsync({ ...location, commentId: rootId, content: 'updated' }); + await facade.replyCommentAsync({ + ...location, + threadId: rootId, + id: replyId, + personId: 'guest', + content: 'reply', + }); + + const thread = facade.getComments({ anchorKinds: [anchor.kind] })[0]; + expect(thread.anchorKind).toBe(anchor.kind); + expect(thread.anchor).toEqual(anchor); + expect(thread.root.text.dataStream).toBe('updated\r\n'); + expect(thread.children.map((comment) => comment.id)).toEqual([replyId]); + + await facade.resolveCommentAsync({ ...location, commentId: rootId }); + expect(facade.getComments({ anchorKinds: [anchor.kind], resolved: true })).toHaveLength(1); + await facade.resolveCommentAsync({ ...location, commentId: rootId, resolved: false }); + expect(facade.getComments({ anchorKinds: [anchor.kind], resolved: false })).toHaveLength(1); + + await facade.deleteCommentAsync({ ...location, commentId: replyId }); + expect(facade.getComments({ anchorKinds: [anchor.kind] })[0].children).toEqual([]); + await facade.deleteCommentAsync({ ...location, commentId: rootId, deleteThread: true }); + expect(facade.getComments({ anchorKinds: [anchor.kind] })).toEqual([]); + }); + + it('exposes a stable anchor kind for legacy sheet comments', async () => { + const root = createComment({ id: 'legacy-sheet-comment', ref: 'A1' }); + await commandService.executeCommand(AddCommentCommand.id, { + unitId: root.unitId, + subUnitId: root.subUnitId, + comment: root, + }); + + const [thread] = get(ThreadCommentFacadeService).getComments({ + anchorKinds: [ThreadCommentAnchorKind.SHEET_CELL], + }); + + expect(thread.anchorKind).toBe(ThreadCommentAnchorKind.SHEET_CELL); + expect(thread.anchor).toBeNull(); + expect(thread.root.ref).toBe('A1'); + }); + it('returns false when updating or deleting a missing comment', async () => { await expect(commandService.executeCommand(UpdateCommentCommand.id, { unitId: 'unit-1', @@ -319,4 +460,293 @@ describe('Thread comment commands', () => { commentId: 'missing-comment', })).resolves.toBe(false); }); + + it('keeps local comments unchanged when remote writes fail', async () => { + const root = createComment({ id: 'network-root' }); + await commandService.executeCommand(AddCommentCommand.id, { + unitId: root.unitId, + subUnitId: root.subUnitId, + comment: root, + }); + + get(IThreadCommentDataSourceService).dataSource = { + addComment: async () => { throw new Error('offline'); }, + updateComment: async () => { throw new Error('forbidden'); }, + resolveComment: async () => false, + deleteComment: async () => false, + listComments: async () => [], + saveCommentToSnapshot: (comment) => comment, + }; + + await expect(commandService.executeCommand(AddCommentCommand.id, { + unitId: root.unitId, + subUnitId: root.subUnitId, + comment: createComment({ id: 'offline-root' }), + })).rejects.toThrow('offline'); + await expect(commandService.executeCommand(UpdateCommentCommand.id, { + unitId: root.unitId, + subUnitId: root.subUnitId, + payload: { commentId: root.id, text: createBody('changed') }, + })).rejects.toThrow('forbidden'); + await expect(commandService.executeCommand(ResolveCommentCommand.id, { + unitId: root.unitId, + subUnitId: root.subUnitId, + commentId: root.id, + resolved: true, + })).resolves.toBe(false); + await expect(commandService.executeCommand(DeleteCommentTreeCommand.id, { + unitId: root.unitId, + subUnitId: root.subUnitId, + commentId: root.id, + })).resolves.toBe(false); + + expect(threadCommentModel.getComment(root.unitId, root.subUnitId, 'offline-root')).toBeUndefined(); + expect(threadCommentModel.getComment(root.unitId, root.subUnitId, root.id)).toMatchObject({ + text: createBody(root.id), + resolved: undefined, + }); + }); + + it('keeps unit and anchor identity local while accepting the server-authoritative author', async () => { + const root = createComment({ id: 'identity-root', ref: 'C7', personId: 'client-user' }); + get(IThreadCommentDataSourceService).dataSource = { + addComment: async (comment) => ({ + ...comment, + unitId: 'spoofed-unit', + subUnitId: 'spoofed-subunit', + ref: 'spoofed-ref', + personId: 'server-user', + }), + updateComment: async () => true, + resolveComment: async () => true, + deleteComment: async () => true, + listComments: async () => [], + saveCommentToSnapshot: (comment) => comment, + }; + + await expect(commandService.executeCommand(AddCommentCommand.id, { + unitId: root.unitId, + subUnitId: root.subUnitId, + comment: root, + })).resolves.toBe(true); + + expect(threadCommentModel.getComment(root.unitId, root.subUnitId, root.id)).toMatchObject({ + unitId: root.unitId, + subUnitId: root.subUnitId, + ref: root.ref, + personId: 'server-user', + }); + expect(threadCommentModel.getUnit('spoofed-unit')).toEqual([]); + }); + + it('reports an edit as failed when the comment is deleted while the remote update is pending', async () => { + const root = createComment({ id: 'concurrent-root' }); + await commandService.executeCommand(AddCommentCommand.id, { + unitId: root.unitId, + subUnitId: root.subUnitId, + comment: root, + }); + let finishUpdate: ((success: boolean) => void) | undefined; + get(IThreadCommentDataSourceService).dataSource = { + addComment: async (comment) => comment, + updateComment: () => new Promise((resolve) => { + finishUpdate = resolve; + }), + resolveComment: async () => true, + deleteComment: async () => true, + listComments: async () => [], + saveCommentToSnapshot: (comment) => comment, + }; + + const update = commandService.executeCommand(UpdateCommentCommand.id, { + unitId: root.unitId, + subUnitId: root.subUnitId, + payload: { commentId: root.id, text: createBody('late edit') }, + }); + await Promise.resolve(); + threadCommentModel.deleteComment(root.unitId, root.subUnitId, root.id); + finishUpdate?.(true); + + await expect(update).resolves.toBe(false); + expect(threadCommentModel.getComment(root.unitId, root.subUnitId, root.id)).toBeUndefined(); + }); + + it('does not leave an orphan reply when its thread is deleted during the remote add', async () => { + const root = createComment({ id: 'reply-race-root' }); + await commandService.executeCommand(AddCommentCommand.id, { + unitId: root.unitId, + subUnitId: root.subUnitId, + comment: root, + }); + const reply = createComment({ + id: 'reply-race-child', + threadId: root.threadId, + parentId: root.id, + ref: '', + }); + let finishAdd: ((comment: IThreadComment) => void) | undefined; + const deletedIds: string[] = []; + get(IThreadCommentDataSourceService).dataSource = { + addComment: () => new Promise((resolve) => { + finishAdd = resolve; + }), + updateComment: async () => true, + resolveComment: async () => true, + deleteComment: async (_unitId, _subUnitId, _threadId, commentId) => { + if (commentId) { + deletedIds.push(commentId); + } + return true; + }, + listComments: async () => [], + saveCommentToSnapshot: (comment) => comment, + }; + + const addReply = commandService.executeCommand(AddCommentCommand.id, { + unitId: reply.unitId, + subUnitId: reply.subUnitId, + comment: reply, + }); + await Promise.resolve(); + threadCommentModel.deleteThread(root.unitId, root.subUnitId, root.threadId); + finishAdd?.(reply); + + await expect(addReply).resolves.toBe(false); + expect(threadCommentModel.getComment(reply.unitId, reply.subUnitId, reply.id)).toBeUndefined(); + expect(deletedIds).toEqual([reply.id]); + }); + + it('keeps the latest edit when overlapping remote writes finish out of order', async () => { + const root = createComment({ id: 'overlapping-edit-root' }); + await commandService.executeCommand(AddCommentCommand.id, { + unitId: root.unitId, + subUnitId: root.subUnitId, + comment: root, + }); + const finishUpdates: Array<(success: boolean) => void> = []; + get(IThreadCommentDataSourceService).dataSource = { + addComment: async (comment) => comment, + updateComment: () => new Promise((resolve) => finishUpdates.push(resolve)), + resolveComment: async () => true, + deleteComment: async () => true, + listComments: async () => [], + saveCommentToSnapshot: (comment) => comment, + }; + + const older = commandService.executeCommand(UpdateCommentCommand.id, { + unitId: root.unitId, + subUnitId: root.subUnitId, + payload: { commentId: root.id, text: createBody('older edit') }, + }); + await Promise.resolve(); + const newer = commandService.executeCommand(UpdateCommentCommand.id, { + unitId: root.unitId, + subUnitId: root.subUnitId, + payload: { commentId: root.id, text: createBody('newer edit') }, + }); + await Promise.resolve(); + + finishUpdates[1](true); + await expect(newer).resolves.toBe(true); + finishUpdates[0](true); + await expect(older).resolves.toBe(false); + expect(threadCommentModel.getComment(root.unitId, root.subUnitId, root.id)?.text).toEqual(createBody('newer edit')); + }); + + it('keeps the latest resolve intent when responses finish out of order', async () => { + const root = createComment({ id: 'overlapping-resolve-root' }); + await commandService.executeCommand(AddCommentCommand.id, { + unitId: root.unitId, + subUnitId: root.subUnitId, + comment: root, + }); + const finishResolves: Array<(success: boolean) => void> = []; + get(IThreadCommentDataSourceService).dataSource = { + addComment: async (comment) => comment, + updateComment: async () => true, + resolveComment: () => new Promise((resolve) => finishResolves.push(resolve)), + deleteComment: async () => true, + listComments: async () => [], + saveCommentToSnapshot: (comment) => comment, + }; + + const older = commandService.executeCommand(ResolveCommentCommand.id, { + unitId: root.unitId, + subUnitId: root.subUnitId, + commentId: root.id, + resolved: true, + }); + await Promise.resolve(); + const newer = commandService.executeCommand(ResolveCommentCommand.id, { + unitId: root.unitId, + subUnitId: root.subUnitId, + commentId: root.id, + resolved: false, + }); + await Promise.resolve(); + + finishResolves[1](true); + await expect(newer).resolves.toBe(true); + finishResolves[0](true); + await expect(older).resolves.toBe(false); + expect(threadCommentModel.getComment(root.unitId, root.subUnitId, root.id)?.resolved).toBe(false); + }); + + it('replays a collaborator add, reply, edit, resolve, and delete chain', async () => { + const remoteRoot = createComment({ id: 'remote-root', text: createBody('remote root') }); + const remoteReply = createComment({ + id: 'remote-reply', + threadId: remoteRoot.threadId, + parentId: remoteRoot.id, + text: createBody('remote reply'), + }); + get(IThreadCommentDataSourceService).dataSource = { + addComment: async (comment) => comment, + updateComment: async () => true, + resolveComment: async () => true, + deleteComment: async () => true, + listComments: async () => [{ ...remoteRoot, children: [remoteReply] }], + saveCommentToSnapshot: (comment) => comment, + }; + + await commandService.executeCommand(AddCommentMutation.id, { + unitId: remoteRoot.unitId, + subUnitId: remoteRoot.subUnitId, + comment: { ...remoteRoot, text: createBody('placeholder') }, + }, { fromChangeset: true }); + await Promise.resolve(); + await Promise.resolve(); + + expect(threadCommentModel.getThread(remoteRoot.unitId, remoteRoot.subUnitId, remoteRoot.threadId)).toMatchObject({ + root: { text: createBody('remote root') }, + children: [{ id: remoteReply.id, text: createBody('remote reply') }], + }); + const query = { unitIds: [remoteRoot.unitId], subUnitIds: [remoteRoot.subUnitId] }; + const facade = get(ThreadCommentFacadeService); + await expect(facade.listCommentsAsync(query)).resolves.toEqual(facade.getComments(query)); + + await commandService.executeCommand(UpdateCommentMutation.id, { + unitId: remoteRoot.unitId, + subUnitId: remoteRoot.subUnitId, + payload: { commentId: remoteRoot.id, text: createBody('remote edit') }, + }, { fromChangeset: true }); + await commandService.executeCommand(ResolveCommentMutation.id, { + unitId: remoteRoot.unitId, + subUnitId: remoteRoot.subUnitId, + commentId: remoteRoot.id, + resolved: true, + }, { fromChangeset: true }); + + expect(threadCommentModel.getComment(remoteRoot.unitId, remoteRoot.subUnitId, remoteRoot.id)).toMatchObject({ + text: createBody('remote edit'), + resolved: true, + }); + + await commandService.executeCommand(DeleteCommentMutation.id, { + unitId: remoteRoot.unitId, + subUnitId: remoteRoot.subUnitId, + commentId: remoteRoot.id, + }, { fromChangeset: true }); + expect(threadCommentModel.getThread(remoteRoot.unitId, remoteRoot.subUnitId, remoteRoot.threadId)).toBeUndefined(); + }); }); diff --git a/packages/thread-comment/src/commands/commands/comment.command.ts b/packages/thread-comment/src/commands/commands/comment.command.ts index 9c2018dc51..21d684961d 100644 --- a/packages/thread-comment/src/commands/commands/comment.command.ts +++ b/packages/thread-comment/src/commands/commands/comment.command.ts @@ -27,6 +27,21 @@ import { UpdateCommentMutation, } from '../mutations/comment.mutation'; +type CommentWriteKind = 'resolve' | 'update'; +const latestWriteVersions = new WeakMap>(); + +function startCommentWrite(comment: IThreadComment, kind: CommentWriteKind): number { + const versions = latestWriteVersions.get(comment) ?? { resolve: 0, update: 0 }; + const version = versions[kind] + 1; + versions[kind] = version; + latestWriteVersions.set(comment, versions); + return version; +} + +function isLatestCommentWrite(comment: IThreadComment, kind: CommentWriteKind, version: number): boolean { + return latestWriteVersions.get(comment)?.[kind] === version; +} + export interface IAddCommentCommandParams { unitId: string; subUnitId: string; @@ -42,10 +57,31 @@ export const AddCommentCommand: ICommand = { } const commandService = accessor.get(ICommandService); const dataSourceService = accessor.get(IThreadCommentDataSourceService); + const threadCommentModel = accessor.get(ThreadCommentModel); const { comment: originComment } = params; - const comment = await dataSourceService.addComment(originComment); - const syncUpdateMutationToColla = dataSourceService.syncUpdateMutationToColla; const isRoot = !originComment.parentId; + if (!isRoot && threadCommentModel.getRootComment(params.unitId, params.subUnitId, originComment.threadId)?.id !== originComment.parentId) { + return false; + } + const savedComment = await dataSourceService.addComment(originComment); + const comment = { + ...savedComment, + unitId: params.unitId, + subUnitId: params.subUnitId, + ref: originComment.ref, + parentId: originComment.parentId, + ...(!isRoot ? { threadId: originComment.threadId } : {}), + }; + const syncUpdateMutationToColla = dataSourceService.syncUpdateMutationToColla; + + if (!isRoot && threadCommentModel.getRootComment(params.unitId, params.subUnitId, originComment.threadId)?.id !== originComment.parentId) { + try { + await dataSourceService.deleteComment(params.unitId, params.subUnitId, comment.threadId, comment.id); + } catch { + // The local thread is already gone. Server-side parent validation remains authoritative. + } + return false; + } const redo = { id: AddCommentMutation.id, @@ -95,12 +131,17 @@ export const UpdateCommentCommand: ICommand = { } const { children, ...currentComment } = current; + const writeVersion = startCommentWrite(current, 'update'); const success = await dataSourceService.updateComment({ ...currentComment, ...payload, }); - if (!success) { + if ( + !success + || threadCommentModel.getComment(unitId, subUnitId, payload.commentId) !== current + || !isLatestCommentWrite(current, 'update', writeVersion) + ) { return false; } @@ -109,8 +150,7 @@ export const UpdateCommentCommand: ICommand = { params, }; - commandService.executeCommand(redo.id, redo.params, { onlyLocal: !syncUpdateMutationToColla }); - return true; + return commandService.executeCommand(redo.id, redo.params, { onlyLocal: !syncUpdateMutationToColla }); }, }; @@ -137,12 +177,17 @@ export const ResolveCommentCommand: ICommand = { return false; } + const writeVersion = startCommentWrite(currentComment, 'resolve'); const success = await dataSourceService.resolveComment({ ...currentComment, resolved, }); - if (!success) { + if ( + !success + || threadCommentModel.getComment(unitId, subUnitId, commentId) !== currentComment + || !isLatestCommentWrite(currentComment, 'resolve', writeVersion) + ) { return false; } @@ -219,7 +264,15 @@ export const DeleteCommentTreeCommand: ICommand = { return false; } - if (!(await dataSourceService.deleteComment(unitId, subUnitId, commentWithChildren.root.threadId, commentId))) { + const deleted = dataSourceService.deleteThread + ? await dataSourceService.deleteThread(unitId, subUnitId, commentWithChildren.root.threadId) + : await dataSourceService.deleteComment( + unitId, + subUnitId, + commentWithChildren.root.threadId, + commentWithChildren.root.id + ); + if (!deleted) { return false; } diff --git a/packages/thread-comment/src/controllers/__tests__/tc-resource.controller.spec.ts b/packages/thread-comment/src/controllers/__tests__/tc-resource.controller.spec.ts index 9581df687a..ca45ae194c 100644 --- a/packages/thread-comment/src/controllers/__tests__/tc-resource.controller.spec.ts +++ b/packages/thread-comment/src/controllers/__tests__/tc-resource.controller.spec.ts @@ -28,6 +28,7 @@ import { import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { ThreadCommentModel } from '../../models/thread-comment.model'; import { UniverThreadCommentPlugin } from '../../plugin'; +import { serializeThreadCommentAnchor, ThreadCommentAnchorKind } from '../../types/comment-anchor'; import { SHEET_UNIVER_THREAD_COMMENT_PLUGIN } from '../tc-resource.controller'; function createWorkbookData(): IWorkbookData { @@ -129,9 +130,47 @@ describe('ThreadCommentResourceController', () => { }); }); + it.each([ + UniverInstanceType.UNIVER_SLIDE, + UniverInstanceType.UNIVER_BOARD, + UniverInstanceType.UNIVER_BASE, + ])('serializes thread comments for business type %s', (businessType) => { + const root = createComment({ id: 'root-business', ref: 'element-1', subUnitId: 'page-1' }); + threadCommentModel.addComment('unit-1', 'page-1', root); + + const resource = resourceManagerService.getResourcesByType('unit-1', businessType) + .find((item) => item.name === SHEET_UNIVER_THREAD_COMMENT_PLUGIN); + + expect(resource).toBeDefined(); + if (resource === undefined) { + return; + } + + expect(JSON.parse(resource.data)).toEqual({ + 'page-1': [{ + ...root, + children: [], + }], + }); + }); + it('loads serialized comments and clears them on unload', async () => { - const root = createComment({ id: 'root-3', ref: 'C3' }); - const reply = createComment({ id: 'reply-3', parentId: root.id, threadId: root.id, ref: '' }); + const root = createComment({ + id: 'root-3', + ref: serializeThreadCommentAnchor({ kind: ThreadCommentAnchorKind.SHEET_DRAWING, elementId: 'drawing-1' }), + attachments: ['root.png'], + mentions: ['user-2'], + resolved: true, + updated: true, + updateT: '2024-01-02T00:00:00.000Z', + }); + const reply = createComment({ + id: 'reply-3', + parentId: root.id, + threadId: root.id, + ref: '', + attachments: ['reply.png'], + }); resourceManagerService.loadResources('unit-1', [{ name: SHEET_UNIVER_THREAD_COMMENT_PLUGIN, @@ -171,4 +210,38 @@ describe('ThreadCommentResourceController', () => { expect(threadCommentModel.getUnit('unit-1')).toEqual([]); }); + + it('filters malformed snapshot entries and keeps loaded comments inside their resource unit', async () => { + const root = createComment({ + id: 'safe-root', + unitId: 'spoofed-unit', + subUnitId: 'spoofed-sheet', + children: [ + createComment({ id: 'safe-root', threadId: 'safe-root', parentId: 'safe-root' }), + createComment({ id: 'wrong-thread', threadId: 'other-thread', parentId: 'safe-root' }), + createComment({ id: 'safe-reply', threadId: 'safe-root', parentId: 'safe-root' }), + ], + }); + resourceManagerService.loadResources('unit-1', [{ + name: SHEET_UNIVER_THREAD_COMMENT_PLUGIN, + data: JSON.stringify({ + 'not-an-array': {}, + 'sheet-1': [ + null, + { id: '', threadId: 'bad', ref: 'A1' }, + { id: 'bad-body', threadId: 'bad-body', ref: 'A1', text: { dataStream: 1 } }, + root, + ], + }), + }]); + + await Promise.resolve(); + + expect(threadCommentModel.getUnit('spoofed-unit')).toEqual([]); + expect(threadCommentModel.getThread('unit-1', 'sheet-1', root.threadId)).toMatchObject({ + root: { id: root.id, unitId: 'unit-1', subUnitId: 'sheet-1' }, + children: [{ id: 'safe-reply', unitId: 'unit-1', subUnitId: 'sheet-1' }], + }); + expect(threadCommentModel.getComment('unit-1', 'sheet-1', 'wrong-thread')).toBeUndefined(); + }); }); diff --git a/packages/thread-comment/src/controllers/tc-resource.controller.ts b/packages/thread-comment/src/controllers/tc-resource.controller.ts index ab9a6f5e76..f6906231b7 100644 --- a/packages/thread-comment/src/controllers/tc-resource.controller.ts +++ b/packages/thread-comment/src/controllers/tc-resource.controller.ts @@ -18,12 +18,36 @@ import type { IThreadComment } from '../types/interfaces/i-thread-comment'; import { Disposable, Inject, IResourceManagerService, UniverInstanceType } from '@univerjs/core'; import { ThreadCommentModel } from '../models/thread-comment.model'; import { IThreadCommentDataSourceService } from '../services/tc-datasource.service'; +import { isThreadCommentDocumentBody } from '../services/thread-comment-api.service'; import { TC_PLUGIN_NAME } from '../types/const'; export type UnitThreadCommentJSON = Record; export const SHEET_UNIVER_THREAD_COMMENT_PLUGIN = `SHEET_${TC_PLUGIN_NAME}`; +function isStringArray(value: unknown): value is string[] { + return Array.isArray(value) && value.every((item) => typeof item === 'string'); +} + +function isSnapshotComment(value: unknown, allowChildren = true): value is IThreadComment { + if (!value || typeof value !== 'object') { + return false; + } + const comment = value as Record; + return typeof comment.id === 'string' + && comment.id.length > 0 + && typeof comment.threadId === 'string' + && comment.threadId.length > 0 + && typeof comment.ref === 'string' + && (comment.text === undefined || isThreadCommentDocumentBody(comment.text)) + && (comment.attachments === undefined || isStringArray(comment.attachments)) + && (comment.mentions === undefined || isStringArray(comment.mentions)) + && (!allowChildren || comment.children === undefined || ( + Array.isArray(comment.children) + && comment.children.every((child) => isSnapshotComment(child, false)) + )); +} + export class ThreadCommentResourceController extends Disposable { constructor( @IResourceManagerService private readonly _resourceManagerService: IResourceManagerService, @@ -57,8 +81,16 @@ export class ThreadCommentResourceController extends Disposable { return {}; } try { - return JSON.parse(json); - } catch (err) { + const value: unknown = JSON.parse(json); + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + return Object.fromEntries(Object.entries(value).flatMap(([subUnitId, comments]) => ( + Array.isArray(comments) + ? [[subUnitId, comments.filter((comment) => isSnapshotComment(comment))]] + : [] + ))); + } catch { return {}; } }; @@ -66,7 +98,13 @@ export class ThreadCommentResourceController extends Disposable { this.disposeWithMe( this._resourceManagerService.registerPluginResource({ pluginName: SHEET_UNIVER_THREAD_COMMENT_PLUGIN, - businesses: [UniverInstanceType.UNIVER_SHEET, UniverInstanceType.UNIVER_DOC], + businesses: [ + UniverInstanceType.UNIVER_SHEET, + UniverInstanceType.UNIVER_DOC, + UniverInstanceType.UNIVER_SLIDE, + UniverInstanceType.UNIVER_BOARD, + UniverInstanceType.UNIVER_BASE, + ], toJson: (unitID) => toJson(unitID), parseJson: (json) => parseJson(json), onUnLoad: (unitID) => { @@ -76,7 +114,20 @@ export class ThreadCommentResourceController extends Disposable { Object.keys(value).forEach((subunitId) => { const commentList = value[subunitId]; commentList.forEach((comment: IThreadComment) => { - this._threadCommentModel.addComment(unitID, subunitId, comment); + const seenIds = new Set([comment.id]); + const children = comment.children?.filter((child) => { + if (child.threadId !== comment.threadId || seenIds.has(child.id)) { + return false; + } + seenIds.add(child.id); + return true; + }).map((child) => ({ ...child, unitId: unitID, subUnitId: subunitId })); + this._threadCommentModel.addComment(unitID, subunitId, { + ...comment, + unitId: unitID, + subUnitId: subunitId, + children, + }); }); this._threadCommentModel.syncThreadComments(unitID, subunitId, commentList.map((i) => i.threadId)); diff --git a/packages/thread-comment/src/facade/f-enum.ts b/packages/thread-comment/src/facade/f-enum.ts new file mode 100644 index 0000000000..1c9a86e967 --- /dev/null +++ b/packages/thread-comment/src/facade/f-enum.ts @@ -0,0 +1,43 @@ +/** + * 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 { FEnum } from '@univerjs/core/facade'; +import { ThreadCommentAnchorKind } from '@univerjs/thread-comment'; + +export interface IFThreadCommentEnumMixin { + /** + * Stable product anchor kinds used when creating or filtering comments. + * @example + * ```ts + * const kind = univerAPI.Enum.ThreadCommentAnchorKind.BOARD_ELEMENT; + * const comments = univerAPI.getComments({ anchorKinds: [kind] }); + * ``` + */ + ThreadCommentAnchorKind: typeof ThreadCommentAnchorKind; +} + +export class FThreadCommentEnumMixin extends FEnum implements IFThreadCommentEnumMixin { + /** @inheritdoc */ + override get ThreadCommentAnchorKind(): typeof ThreadCommentAnchorKind { + return ThreadCommentAnchorKind; + } +} + +FEnum.extend(FThreadCommentEnumMixin); + +declare module '@univerjs/core/facade' { + interface FEnum extends IFThreadCommentEnumMixin {} +} diff --git a/packages/thread-comment/src/facade/f-univer.ts b/packages/thread-comment/src/facade/f-univer.ts new file mode 100644 index 0000000000..37e1c8a6ae --- /dev/null +++ b/packages/thread-comment/src/facade/f-univer.ts @@ -0,0 +1,211 @@ +/** + * 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 { Injector } from '@univerjs/core'; +import { FUniver } from '@univerjs/core/facade'; +import * as ThreadComment from '@univerjs/thread-comment'; + +export interface IFUniverThreadCommentMixin { + /** + * Creates a root comment on a serialized product anchor. + * @param options Comment content, owner IDs, stable anchor, and optional caller-controlled IDs. + * @returns `true` when the command succeeds; otherwise, `false`. + * @throws {TypeError} If the content is empty or the anchor is invalid. + * @example + * ```ts + * const presentation = univerAPI.getActivePresentation(); + * if (!presentation) throw new Error('No active presentation'); + * const slide = presentation.getSlideByIndex(0); + * const element = slide?.getElements()[0]; + * if (!slide || !element) throw new Error('No commentable slide element'); + * + * await univerAPI.createCommentAsync({ + * unitId: presentation.getId(), + * subUnitId: slide.getId(), + * anchor: { + * kind: univerAPI.Enum.ThreadCommentAnchorKind.SLIDE_ELEMENT, + * pageId: slide.getId(), + * elementId: element.getId(), + * }, + * content: 'Verify this value.', + * }); + * ``` + */ + createCommentAsync(options: ThreadComment.ICreateThreadCommentOptions): Promise; + /** + * Adds a reply to an existing root thread. + * @param options Reply content and the owning unit, subunit, and thread IDs. + * @returns `true` when the reply is created. Returns `false` when the root thread is not loaded or the command fails. + * @throws {TypeError} If the content is empty. + * @example + * ```ts + * const [thread] = univerAPI.getComments({ resolved: false }); + * if (thread) { + * await univerAPI.replyCommentAsync({ + * unitId: thread.unitId, + * subUnitId: thread.subUnitId, + * threadId: thread.threadId, + * content: 'Verified.', + * }); + * } + * ``` + */ + replyCommentAsync(options: ThreadComment.IReplyThreadCommentOptions): Promise; + /** + * Updates the content or attachments of an existing root comment or reply. + * @param options Updated content and the owning unit, subunit, and comment IDs. + * @returns `true` when the update command succeeds; otherwise, `false`. + * @throws {TypeError} If the content is empty. + * @example + * ```ts + * const [comment] = univerAPI.getComments({ resolved: false }); + * if (comment) { + * await univerAPI.updateCommentAsync({ + * unitId: comment.unitId, + * subUnitId: comment.subUnitId, + * commentId: comment.root.id, + * content: 'Updated review result.', + * }); + * } + * ``` + */ + updateCommentAsync(options: ThreadComment.IUpdateThreadCommentOptions): Promise; + /** + * Deletes one comment, or the complete root and reply tree when `deleteThread` is `true`. + * @param options Owning IDs, target comment ID, and the optional whole-thread flag. + * @returns `true` when the delete command succeeds; otherwise, `false`. + * @example + * ```ts + * const [comment] = univerAPI.getComments({ resolved: false }); + * if (comment) { + * await univerAPI.deleteCommentAsync({ + * unitId: comment.unitId, + * subUnitId: comment.subUnitId, + * commentId: comment.root.id, + * deleteThread: true, + * }); + * } + * ``` + */ + deleteCommentAsync(options: ThreadComment.IDeleteThreadCommentOptions): Promise; + /** + * Resolves a thread. Pass `resolved: false` to reopen it. + * @param options Owning IDs, a comment ID in the thread, and the desired resolution state. + * @returns `true` when the resolve command succeeds; otherwise, `false`. + * @example + * ```ts + * const [comment] = univerAPI.getComments({ resolved: false }); + * if (comment) { + * await univerAPI.resolveCommentAsync({ + * unitId: comment.unitId, + * subUnitId: comment.subUnitId, + * commentId: comment.root.id, + * }); + * } + * ``` + */ + resolveCommentAsync(options: ThreadComment.IResolveThreadCommentOptions): Promise; + /** + * Queries locally loaded threads by unit, subunit, anchor kind, author, or resolution state. + * @param query Optional filters. Omit the argument to return every locally loaded thread. + * @returns Matching root threads, their replies, anchor kinds, parsed anchors, and related user IDs. + * @example + * ```ts + * const presentation = univerAPI.getActivePresentation(); + * const openAgentReviews = univerAPI.getComments({ + * unitIds: presentation ? [presentation.getId()] : [], + * authorIds: ['agent-reviewer'], + * anchorKinds: [univerAPI.Enum.ThreadCommentAnchorKind.SLIDE_ELEMENT], + * resolved: false, + * }); + * const sheetCellReviews = univerAPI.getComments({ + * anchorKinds: [univerAPI.Enum.ThreadCommentAnchorKind.SHEET_CELL], + * }); + * ``` + */ + getComments(query?: ThreadComment.IThreadCommentQuery): ThreadComment.IFacadeThreadCommentInfo[]; + /** + * Synchronizes locally known threads from the configured datasource, then applies the same filters as `getComments`. + * This method does not discover thread IDs that have never been loaded into the model. + * @param query Optional filters. Omit the argument to synchronize and return every known thread. + * @returns A promise resolving to the synchronized matching threads. + * @example + * ```ts + * const presentation = univerAPI.getActivePresentation(); + * const comments = await univerAPI.listCommentsAsync({ + * unitIds: presentation ? [presentation.getId()] : [], + * anchorKinds: [univerAPI.Enum.ThreadCommentAnchorKind.SLIDE_ELEMENT], + * resolved: false, + * }); + * comments.forEach(({ root, children, anchorKind, anchor }) => { + * console.log(root.id, children.length, anchorKind, anchor); + * }); + * ``` + */ + listCommentsAsync(query?: ThreadComment.IThreadCommentQuery): Promise; +} + +export class FUniverThreadCommentMixin extends FUniver implements IFUniverThreadCommentMixin { + declare private _threadCommentService: ThreadComment.ThreadCommentFacadeService; + + override _initialize(injector: Injector): void { + let service: ThreadComment.ThreadCommentFacadeService | undefined; + Object.defineProperty(this, '_threadCommentService', { + get: () => service ??= injector.get(ThreadComment.ThreadCommentFacadeService), + }); + } + + /** @inheritdoc */ + override createCommentAsync(options: ThreadComment.ICreateThreadCommentOptions): Promise { + return this._threadCommentService.createCommentAsync(options); + } + + /** @inheritdoc */ + override replyCommentAsync(options: ThreadComment.IReplyThreadCommentOptions): Promise { + return this._threadCommentService.replyCommentAsync(options); + } + + /** @inheritdoc */ + override updateCommentAsync(options: ThreadComment.IUpdateThreadCommentOptions): Promise { + return this._threadCommentService.updateCommentAsync(options); + } + + /** @inheritdoc */ + override deleteCommentAsync(options: ThreadComment.IDeleteThreadCommentOptions): Promise { + return this._threadCommentService.deleteCommentAsync(options); + } + + /** @inheritdoc */ + override resolveCommentAsync(options: ThreadComment.IResolveThreadCommentOptions): Promise { + return this._threadCommentService.resolveCommentAsync(options); + } + + /** @inheritdoc */ + override getComments(query: ThreadComment.IThreadCommentQuery = {}): ThreadComment.IFacadeThreadCommentInfo[] { + return this._threadCommentService.getComments(query); + } + + /** @inheritdoc */ + override async listCommentsAsync(query: ThreadComment.IThreadCommentQuery = {}): Promise { + return this._threadCommentService.listCommentsAsync(query); + } +} + +FUniver.extend(FUniverThreadCommentMixin); + +declare module '@univerjs/core/facade' { + interface FUniver extends IFUniverThreadCommentMixin {} +} diff --git a/packages/thread-comment/src/facade/index.ts b/packages/thread-comment/src/facade/index.ts new file mode 100644 index 0000000000..1ee3984bc0 --- /dev/null +++ b/packages/thread-comment/src/facade/index.ts @@ -0,0 +1,19 @@ +/** + * 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 { FThreadCommentEnumMixin, type IFThreadCommentEnumMixin } from './f-enum'; +export type { IFUniverThreadCommentMixin } from './f-univer'; +export { FUniverThreadCommentMixin } from './f-univer'; diff --git a/packages/thread-comment/src/index.ts b/packages/thread-comment/src/index.ts index 0c2a3f6f5f..a536587bd6 100644 --- a/packages/thread-comment/src/index.ts +++ b/packages/thread-comment/src/index.ts @@ -50,9 +50,31 @@ export { ThreadCommentResourceController, } from './controllers/tc-resource.controller'; export { ThreadCommentModel } from './models/thread-comment.model'; -export type { CommentUpdate, IThreadInfo } from './models/thread-comment.model'; +export type { CommentUpdate, IThreadCommentQuery, IThreadInfo } from './models/thread-comment.model'; export { UniverThreadCommentPlugin } from './plugin'; export { IThreadCommentDataSourceService, ThreadCommentDataSourceService } from './services/tc-datasource.service'; export type { IThreadCommentDataSource, ThreadCommentJSON } from './services/tc-datasource.service'; +export { isThreadCommentDocumentBody, normalizeThreadCommentContent, ThreadCommentFacadeService } from './services/thread-comment-api.service'; +export type { + ICreateThreadCommentOptions, + IDeleteThreadCommentOptions, + IFacadeThreadCommentInfo, + IReplyThreadCommentOptions, + IResolveThreadCommentOptions, + IUpdateThreadCommentOptions, + ThreadCommentContent, +} from './services/thread-comment-api.service'; +export { + deserializeThreadCommentAnchor, + isThreadCommentAnchor, + serializeThreadCommentAnchor, + ThreadCommentAnchorKind, +} from './types/comment-anchor'; +export type { + IThreadCommentAnchor, + IThreadCommentBaseRecordAnchor, + IThreadCommentElementAnchor, + IThreadCommentPositionAnchor, +} from './types/comment-anchor'; export { TC_PLUGIN_NAME } from './types/const'; export type { IBaseComment, IThreadComment, IThreadCommentMention } from './types/interfaces/i-thread-comment'; diff --git a/packages/thread-comment/src/models/__tests__/thread-comment.model.spec.ts b/packages/thread-comment/src/models/__tests__/thread-comment.model.spec.ts index 787d5cdcf1..6bc4f25d67 100644 --- a/packages/thread-comment/src/models/__tests__/thread-comment.model.spec.ts +++ b/packages/thread-comment/src/models/__tests__/thread-comment.model.spec.ts @@ -29,6 +29,7 @@ import { import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { UniverThreadCommentPlugin } from '../../plugin'; import { IThreadCommentDataSourceService } from '../../services/tc-datasource.service'; +import { serializeThreadCommentAnchor, ThreadCommentAnchorKind } from '../../types/comment-anchor'; import { ThreadCommentModel } from '../thread-comment.model'; function createWorkbookData(): IWorkbookData { @@ -93,6 +94,7 @@ describe('ThreadCommentModel', () => { univer = new Univer(); univer.registerPlugin(UniverThreadCommentPlugin); univer.createUnit(UniverInstanceType.UNIVER_SHEET, createWorkbookData()); + univer.createUnit(UniverInstanceType.UNIVER_DOC, { id: 'doc-1', body: createBody('Document text') }); const injector = univer.__getInjector(); get = injector.get.bind(injector); @@ -179,6 +181,8 @@ describe('ThreadCommentModel', () => { threadCommentModel.addComment('unit-1', 'sheet-1', keepRoot); threadCommentModel.addComment('unit-1', 'sheet-2', removeRoot); + const events: CommentUpdate[] = []; + const subscription = threadCommentModel.commentUpdate$.subscribe((event) => events.push(event)); dataSourceService.dataSource = { addComment: vi.fn(), @@ -191,10 +195,15 @@ describe('ThreadCommentModel', () => { await threadCommentModel.syncThreadComments('unit-1', 'sheet-1', ['keep-root']); await threadCommentModel.syncThreadComments('unit-1', 'sheet-2', ['remove-root']); + subscription.unsubscribe(); expect(threadCommentModel.getThread('unit-1', 'sheet-1', 'keep-root')).toBeDefined(); expect(threadCommentModel.getThread('unit-1', 'sheet-2', 'remove-root')).toBeUndefined(); expect(threadCommentModel.getUnit('unit-1').map((thread) => thread.threadId)).toEqual(['keep-root']); + expect(events).toContainEqual(expect.objectContaining({ + type: 'delete', + payload: expect.objectContaining({ commentId: 'remove-root' }), + })); }); it('adds synced thread comments even when no placeholder exists locally', async () => { @@ -229,6 +238,203 @@ describe('ThreadCommentModel', () => { }); }); + it('keeps the newest sync result when overlapping requests finish out of order', async () => { + lifecycleService.stage = LifecycleStages.Rendered; + const root = createComment({ id: 'racing-root', text: createBody('initial') }); + threadCommentModel.addComment('unit-1', 'sheet-1', root); + + const requests: Array<(comments: IThreadComment[]) => void> = []; + dataSourceService.dataSource = { + addComment: vi.fn(), + updateComment: vi.fn(), + resolveComment: vi.fn(), + deleteComment: vi.fn(), + listComments: vi.fn(() => new Promise((resolve) => requests.push(resolve))), + saveCommentToSnapshot: vi.fn(), + }; + + const olderRequest = threadCommentModel.syncThreadComments('unit-1', 'sheet-1', [root.threadId]); + const newerRequest = threadCommentModel.syncThreadComments('unit-1', 'sheet-1', [root.threadId]); + requests[1]([{ ...root, text: createBody('newest') }]); + await newerRequest; + requests[0]([{ ...root, text: createBody('stale') }]); + await olderRequest; + + expect(threadCommentModel.getComment('unit-1', 'sheet-1', root.id)?.text).toEqual(createBody('newest')); + expect(threadCommentModel.getRootComment('unit-1', 'sheet-1', root.threadId)?.text).toEqual(createBody('newest')); + }); + + it('does not resurrect a thread deleted while an older sync request is pending', async () => { + lifecycleService.stage = LifecycleStages.Rendered; + const root = createComment({ id: 'deleted-during-sync' }); + threadCommentModel.addComment('unit-1', 'sheet-1', root); + let finishSync: ((comments: IThreadComment[]) => void) | undefined; + dataSourceService.dataSource = { + addComment: vi.fn(), + updateComment: vi.fn(), + resolveComment: vi.fn(), + deleteComment: vi.fn(), + listComments: vi.fn(() => new Promise((resolve) => { + finishSync = resolve; + })), + saveCommentToSnapshot: vi.fn(), + }; + + const sync = threadCommentModel.syncThreadComments('unit-1', 'sheet-1', [root.threadId]); + threadCommentModel.deleteThread('unit-1', 'sheet-1', root.threadId); + finishSync?.([root]); + await sync; + + expect(threadCommentModel.getThread('unit-1', 'sheet-1', root.threadId)).toBeUndefined(); + }); + + it('does not restore an unknown thread after its unit is unloaded during sync', async () => { + lifecycleService.stage = LifecycleStages.Rendered; + const root = createComment({ id: 'unloaded-sync-root' }); + let finishSync: ((comments: IThreadComment[]) => void) | undefined; + dataSourceService.dataSource = { + addComment: vi.fn(), + updateComment: vi.fn(), + resolveComment: vi.fn(), + deleteComment: vi.fn(), + listComments: vi.fn(() => new Promise((resolve) => { + finishSync = resolve; + })), + saveCommentToSnapshot: vi.fn(), + }; + + const sync = threadCommentModel.syncThreadComments('unit-1', 'sheet-1', [root.threadId]); + threadCommentModel.deleteUnit('unit-1'); + finishSync?.([root]); + await sync; + + expect(threadCommentModel.getUnit('unit-1')).toEqual([]); + expect(threadCommentModel.getAll()).toEqual([]); + }); + + it('preserves local threads across a failed sync and refreshes them after reconnect', async () => { + lifecycleService.stage = LifecycleStages.Rendered; + const root = createComment({ id: 'reconnect-root', text: createBody('local') }); + threadCommentModel.addComment(root.unitId, root.subUnitId, root); + let online = false; + dataSourceService.dataSource = { + addComment: async (comment) => comment, + updateComment: async () => true, + resolveComment: async () => true, + deleteComment: async () => true, + listComments: async () => { + if (!online) { + throw new Error('offline'); + } + return [{ ...root, text: createBody('remote after reconnect') }]; + }, + saveCommentToSnapshot: (comment) => comment, + }; + + await expect(threadCommentModel.syncThreadComments( + root.unitId, + root.subUnitId, + [root.threadId] + )).rejects.toThrow('offline'); + expect(threadCommentModel.getComment(root.unitId, root.subUnitId, root.id)?.text).toEqual(createBody('local')); + + online = true; + await threadCommentModel.syncThreadComments(root.unitId, root.subUnitId, [root.threadId]); + expect(threadCommentModel.getComment(root.unitId, root.subUnitId, root.id)?.text).toEqual( + createBody('remote after reconnect') + ); + }); + + it('ignores duplicate and cross-thread children from an external data source', async () => { + lifecycleService.stage = LifecycleStages.Rendered; + const root = createComment({ id: 'validated-root' }); + threadCommentModel.addComment(root.unitId, root.subUnitId, root); + const validReply = createComment({ + id: 'valid-reply', + threadId: root.threadId, + parentId: root.id, + }); + dataSourceService.dataSource = { + addComment: async (comment) => comment, + updateComment: async () => true, + resolveComment: async () => true, + deleteComment: async () => true, + listComments: async () => [{ + ...root, + children: [ + { ...validReply, id: root.id }, + { ...validReply, id: 'wrong-thread', threadId: 'other-thread' }, + validReply, + { ...validReply }, + ], + }], + saveCommentToSnapshot: (comment) => comment, + }; + + await threadCommentModel.syncThreadComments(root.unitId, root.subUnitId, [root.threadId]); + + expect(threadCommentModel.getThread(root.unitId, root.subUnitId, root.threadId)).toMatchObject({ + root: { id: root.id }, + children: [{ id: validReply.id, parentId: root.id }], + }); + expect(threadCommentModel.getComment(root.unitId, root.subUnitId, 'wrong-thread')).toBeUndefined(); + }); + + it('builds large unit listings in one pass without rescanning every thread', () => { + for (let index = 0; index < 1_000; index += 1) { + const root = createComment({ id: `bulk-root-${index}` }); + threadCommentModel.addComment('unit-1', 'sheet-1', root); + threadCommentModel.addComment('unit-1', 'sheet-1', createComment({ + id: `bulk-reply-${index}`, + threadId: root.threadId, + parentId: root.id, + })); + } + const getThread = vi.spyOn(threadCommentModel, 'getThread'); + + const threads = threadCommentModel.getUnit('unit-1'); + + expect(threads).toHaveLength(1_000); + expect(threads.every((thread) => thread.children.length === 1)).toBe(true); + expect(getThread).not.toHaveBeenCalled(); + }); + + it('removes replies that disappeared from the remote thread', async () => { + lifecycleService.stage = LifecycleStages.Rendered; + const root = createComment({ id: 'pruned-root' }); + const keepReply = createComment({ id: 'keep-reply', parentId: root.id, threadId: root.threadId }); + const removedReply = createComment({ id: 'removed-reply', parentId: root.id, threadId: root.threadId }); + threadCommentModel.addComment('unit-1', 'sheet-1', root); + threadCommentModel.addComment('unit-1', 'sheet-1', keepReply); + threadCommentModel.addComment('unit-1', 'sheet-1', removedReply); + dataSourceService.dataSource = { + addComment: vi.fn(), + updateComment: vi.fn(), + resolveComment: vi.fn(), + deleteComment: vi.fn(), + listComments: vi.fn(async () => [{ ...root, children: [keepReply] }]), + saveCommentToSnapshot: vi.fn(), + }; + + await threadCommentModel.syncThreadComments('unit-1', 'sheet-1', [root.threadId]); + + expect(threadCommentModel.getThread('unit-1', 'sheet-1', root.threadId)?.children).toEqual([{ ...keepReply, ref: '' }]); + expect(threadCommentModel.getComment('unit-1', 'sheet-1', removedReply.id)).toBeUndefined(); + }); + + it('ignores a duplicate add instead of overwriting a newer edit', () => { + const root = createComment({ id: 'duplicate-root', text: createBody('original') }); + threadCommentModel.addComment('unit-1', 'sheet-1', root); + threadCommentModel.updateComment('unit-1', 'sheet-1', { + commentId: root.id, + text: createBody('edited'), + }); + + threadCommentModel.addComment('unit-1', 'sheet-1', root); + + expect(threadCommentModel.getComment('unit-1', 'sheet-1', root.id)?.text).toEqual(createBody('edited')); + }); + it('updates refs, exposes thread lookups, and deletes all comments in a unit', () => { lifecycleService.stage = LifecycleStages.Rendered; @@ -319,4 +525,81 @@ describe('ThreadCommentModel', () => { 'root-3', ]); }); + + it('queries threads by anchor kind, author, subunit, and resolved status', () => { + const sheetRangeComment = createComment({ id: 'sheet-range-comment', ref: 'A1:B2' }); + const docTextComment = createComment({ + id: 'doc-text-comment', + unitId: 'doc-1', + subUnitId: 'doc-1', + ref: 'Document text', + }); + const recordComment = createComment({ + id: 'record-comment', + personId: 'owner', + ref: serializeThreadCommentAnchor({ + kind: ThreadCommentAnchorKind.BASE_RECORD, + tableId: 'table-1', + recordId: 'record-1', + }), + resolved: false, + }); + const reply = createComment({ + id: 'record-reply', + parentId: recordComment.id, + threadId: recordComment.threadId, + personId: 'agent', + ref: '', + }); + const positionComment = createComment({ + id: 'position-comment', + subUnitId: 'sheet-2', + ref: serializeThreadCommentAnchor({ + kind: ThreadCommentAnchorKind.BOARD_POSITION, + x: 10, + y: 20, + }), + resolved: true, + }); + + threadCommentModel.addComment('unit-1', 'sheet-1', sheetRangeComment); + threadCommentModel.addComment('doc-1', 'doc-1', docTextComment); + threadCommentModel.addComment('unit-1', 'sheet-1', recordComment); + threadCommentModel.addComment('unit-1', 'sheet-1', reply); + threadCommentModel.addComment('unit-1', 'sheet-2', positionComment); + + expect(threadCommentModel.query({ + anchorKinds: [ThreadCommentAnchorKind.BASE_RECORD], + authorIds: ['agent'], + resolved: false, + subUnitIds: ['sheet-1'], + }).map((thread) => thread.threadId)).toEqual([recordComment.threadId]); + expect(threadCommentModel.query({ + anchorKinds: [ThreadCommentAnchorKind.SHEET_CELL], + }).map((thread) => thread.threadId)).toEqual([sheetRangeComment.threadId]); + expect(threadCommentModel.query({ + anchorKinds: [ThreadCommentAnchorKind.DOC_TEXT_RANGE], + }).map((thread) => thread.threadId)).toEqual([docTextComment.threadId]); + expect(threadCommentModel.query({ authorIds: ['missing'] })).toEqual([]); + }); + + it('keeps a long reply thread complete and ordered', () => { + const root = createComment({ id: 'long-thread' }); + threadCommentModel.addComment('unit-1', 'sheet-1', root); + + for (let index = 0; index < 100; index += 1) { + threadCommentModel.addComment('unit-1', 'sheet-1', createComment({ + id: `reply-${index}`, + parentId: root.id, + threadId: root.threadId, + ref: '', + })); + } + + const thread = threadCommentModel.getThread('unit-1', 'sheet-1', root.threadId); + expect(thread?.children).toHaveLength(100); + expect(thread?.children.map((comment) => comment.id)).toEqual( + Array.from({ length: 100 }, (_, index) => `reply-${index}`) + ); + }); }); diff --git a/packages/thread-comment/src/models/thread-comment.model.ts b/packages/thread-comment/src/models/thread-comment.model.ts index e28c49a5ab..5ad0d06d70 100644 --- a/packages/thread-comment/src/models/thread-comment.model.ts +++ b/packages/thread-comment/src/models/thread-comment.model.ts @@ -17,9 +17,23 @@ import type { Nullable } from '@univerjs/core'; import type { IUpdateCommentPayload, IUpdateCommentRefPayload } from '../commands/mutations/comment.mutation'; import type { IBaseComment, IThreadComment } from '../types/interfaces/i-thread-comment'; -import { Disposable, Inject, LifecycleService, LifecycleStages } from '@univerjs/core'; +import { Disposable, Inject, IUniverInstanceService, LifecycleService, LifecycleStages, UniverInstanceType } from '@univerjs/core'; import { Subject } from 'rxjs'; import { IThreadCommentDataSourceService } from '../services/tc-datasource.service'; +import { deserializeThreadCommentAnchor, ThreadCommentAnchorKind } from '../types/comment-anchor'; + +export interface IThreadCommentQuery { + /** Match comments in any of these Univer units. */ + unitIds?: string[]; + /** Match comments in any of these sheets, pages, tables, or product subunits. */ + subUnitIds?: string[]; + /** Match product targets such as ranges, drawings, slide elements, or base records. */ + anchorKinds?: ThreadCommentAnchorKind[]; + /** Match threads containing a root comment or reply written by any of these user IDs. */ + authorIds?: string[]; + /** Match resolved or unresolved root threads. */ + resolved?: boolean; +} export type CommentUpdate = { unitId: string; @@ -55,17 +69,25 @@ export type CommentUpdate = { }); export interface IThreadInfo { + /** Univer unit that owns the thread. */ unitId: string; + /** Sheet, page, table, or product subunit that owns the thread. */ subUnitId: string; + /** Stable root thread ID. */ threadId: string; + /** Root comment. */ root: IThreadComment; + /** Replies ordered by the comment model. */ children: IThreadComment[]; + /** Unique user IDs participating in the root and reply tree. */ relativeUsers: Set; } export class ThreadCommentModel extends Disposable { private _commentsMap: Map>> = new Map(); private _threadMap: Map>> = new Map(); + private _syncVersions = new Map(); + private _nextSyncVersion = 0; private _commentUpdate$ = new Subject(); commentUpdate$ = this._commentUpdate$.asObservable(); @@ -74,7 +96,8 @@ export class ThreadCommentModel extends Disposable { constructor( @Inject(IThreadCommentDataSourceService) private readonly _dataSourceService: IThreadCommentDataSourceService, - @Inject(LifecycleService) private readonly _lifecycleService: LifecycleService + @Inject(LifecycleService) private readonly _lifecycleService: LifecycleService, + @IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService ) { super(); @@ -149,25 +172,59 @@ export class ThreadCommentModel extends Disposable { return subUnitMap; } + private _syncKey(unitId: string, subUnitId: string, threadId: string): string { + return `${unitId}\0${subUnitId}\0${threadId}`; + } + + private _invalidateSync(unitId: string, subUnitId: string, threadId: string): void { + this._syncVersions.delete(this._syncKey(unitId, subUnitId, threadId)); + } + private _replaceComment(unitId: string, subUnitId: string, comment: IBaseComment) { const commentMap = this._ensureCommentMap(unitId, subUnitId); const currentComment = commentMap.get(comment.id); if (!currentComment) { - this.addComment(unitId, subUnitId, comment as IThreadComment); + if ('ref' in comment && typeof comment.ref === 'string') { + this.addComment(unitId, subUnitId, { ...comment, ref: comment.ref }); + } return; } - const { children, ...rest } = comment; + const { children = [], ...rest } = comment; + const seenChildIds = new Set([comment.id]); + const safeChildren = children.filter((child) => { + const current = commentMap.get(child.id); + if ( + child.threadId !== comment.threadId + || seenChildIds.has(child.id) + || (current && current.threadId !== comment.threadId) + ) { + return false; + } + seenChildIds.add(child.id); + return true; + }); const newComment = { ...rest, ref: currentComment.ref, }; commentMap.set(comment.id, newComment); + if (!newComment.parentId) { + this._ensureThreadMap(unitId, subUnitId).set(newComment.threadId, newComment); + } - children?.forEach((child) => { + const remoteCommentIds = new Set([comment.id, ...safeChildren.map((child) => child.id)]); + commentMap.forEach((localComment, commentId) => { + if (localComment.threadId === comment.threadId && !remoteCommentIds.has(commentId)) { + this._deleteComment(unitId, subUnitId, commentId); + } + }); + + safeChildren.forEach((child) => { commentMap.set(child.id, { ...child, + parentId: comment.id, ref: '', }); }); @@ -198,13 +255,38 @@ export class ThreadCommentModel extends Disposable { return; } - const threadMap = this._ensureThreadMap(unitId, subUnitId); - const commentMap = this._ensureCommentMap(unitId, subUnitId); - const comments = await this._dataSourceService.listThreadComments(unitId, subUnitId, threadIds); + const versions = new Map(threadIds.map((threadId) => { + const key = this._syncKey(unitId, subUnitId, threadId); + const version = ++this._nextSyncVersion; + this._syncVersions.set(key, version); + return [threadId, { key, version }] as const; + })); + const releaseVersions = () => versions.forEach(({ key, version }) => { + if (this._syncVersions.get(key) === version) { + this._syncVersions.delete(key); + } + }); + let comments: IBaseComment[] | false; + try { + comments = await this._dataSourceService.listThreadComments(unitId, subUnitId, threadIds); + } catch (error) { + releaseVersions(); + throw error; + } if (!comments) { + releaseVersions(); return; } - const deleteThreads = new Set(threadIds); + const deleteThreads = new Set(threadIds.filter((threadId) => { + const request = versions.get(threadId)!; + return this._syncVersions.get(request.key) === request.version; + })); + if (!deleteThreads.size) { + releaseVersions(); + return; + } + const threadMap = this._ensureThreadMap(unitId, subUnitId); + const commentMap = this._ensureCommentMap(unitId, subUnitId); comments.forEach((comment) => { if (!deleteThreads.has(comment.threadId)) { return; @@ -218,14 +300,18 @@ export class ThreadCommentModel extends Disposable { threadMap.delete(id); commentMap.forEach((comment, commentId) => { if (comment.threadId === id) { - commentMap.delete(commentId); + this._deleteComment(unitId, subUnitId, commentId); } }); }); + releaseVersions(); } addComment(unitId: string, subUnitId: string, origin: IThreadComment, shouldSync?: boolean) { const commentMap = this._ensureCommentMap(unitId, subUnitId); + if (commentMap.has(origin.id)) { + return true; + } const { parentId, children = [], ...rest } = origin; const comment = { ...rest, @@ -234,6 +320,7 @@ export class ThreadCommentModel extends Disposable { if (!comment.threadId) { comment.threadId = comment.parentId || comment.id; } + this._invalidateSync(unitId, subUnitId, comment.threadId); const addCommentItem = (item: IThreadComment) => { commentMap.set(item.id, item); @@ -251,7 +338,18 @@ export class ThreadCommentModel extends Disposable { if (!comment.parentId) { threadMap.set(comment.threadId, comment); for (const child of children) { - addCommentItem(child as IThreadComment); + if ( + child.id === comment.id + || child.threadId !== comment.threadId + || commentMap.has(child.id) + ) { + continue; + } + addCommentItem({ + ...child, + parentId: comment.id, + ref: '', + }); } } @@ -265,8 +363,9 @@ export class ThreadCommentModel extends Disposable { const commentMap = this._ensureCommentMap(unitId, subUnitId); const oldComment = commentMap.get(payload.commentId); if (!oldComment) { - return true; + return false; } + this._invalidateSync(unitId, subUnitId, oldComment.threadId); oldComment.updated = true; oldComment.text = payload.text; @@ -290,6 +389,7 @@ export class ThreadCommentModel extends Disposable { return false; } + this._invalidateSync(unitId, subUnitId, oldComment.threadId); oldComment.ref = payload.ref; this._commentUpdate$.next({ unitId, @@ -309,6 +409,7 @@ export class ThreadCommentModel extends Disposable { return false; } + this._invalidateSync(unitId, subUnitId, oldComment.threadId); oldComment.resolved = resolved; this._commentUpdate$.next({ unitId, @@ -324,7 +425,7 @@ export class ThreadCommentModel extends Disposable { getComment(unitId: string, subUnitId: string, commentId: string) { const commentMap = this._ensureCommentMap(unitId, subUnitId); - return commentMap.get(commentId) as IThreadComment | undefined; + return commentMap.get(commentId); } getRootComment(unitId: string, subUnitId: string, threadId: string) { @@ -379,6 +480,7 @@ export class ThreadCommentModel extends Disposable { return; } + this._invalidateSync(unitId, subUnitId, current.threadId); commentMap.delete(commentId); this._commentUpdate$.next({ @@ -394,6 +496,7 @@ export class ThreadCommentModel extends Disposable { } deleteThread(unitId: string, subUnitId: string, threadId: string) { + this._invalidateSync(unitId, subUnitId, threadId); const threadMap = this._ensureThreadMap(unitId, subUnitId); threadMap.delete(threadId); @@ -423,30 +526,54 @@ export class ThreadCommentModel extends Disposable { deleteUnit(unitId: string) { const unitMap = this._commentsMap.get(unitId); - if (!unitMap) { - return; - } - - unitMap.forEach((subUnitMap, subUnitId) => { + unitMap?.forEach((subUnitMap, subUnitId) => { subUnitMap.forEach((comment) => { this.deleteComment(unitId, subUnitId, comment.id); }); }); + this._commentsMap.delete(unitId); + this._threadMap.delete(unitId); + this._tasks = this._tasks.filter((task) => task.unitId !== unitId); + const syncPrefix = `${unitId}\0`; + this._syncVersions.forEach((_version, key) => { + if (key.startsWith(syncPrefix)) { + this._syncVersions.delete(key); + } + }); } getUnit(unitId: string) { - const unitMap = this._threadMap.get(unitId); + const unitMap = this._commentsMap.get(unitId); if (!unitMap) { return []; } const threads: IThreadInfo[] = []; - unitMap.forEach((subUnitSet, subUnitId) => { - subUnitSet.forEach((threadComment, threadId) => { - const thread = this.getThread(unitId, subUnitId, threadId); - if (thread) { - threads.push(thread); + unitMap.forEach((commentMap, subUnitId) => { + const threadInfos = new Map & { root?: IThreadComment }>(); + commentMap.forEach((comment) => { + let thread = threadInfos.get(comment.threadId); + if (!thread) { + thread = { + unitId, + subUnitId, + threadId: comment.threadId, + children: [], + relativeUsers: new Set(), + }; + threadInfos.set(comment.threadId, thread); + } + if (comment.parentId) { + thread.children.push(comment); + } else { + thread.root = comment; + } + thread.relativeUsers.add(comment.personId); + }); + threadInfos.forEach((thread) => { + if (thread.root) { + threads.push({ ...thread, root: thread.root }); } }); }); @@ -465,4 +592,52 @@ export class ThreadCommentModel extends Disposable { return all; } + + getAnchorKind(unitId: string, ref: string): ThreadCommentAnchorKind | null { + const anchor = deserializeThreadCommentAnchor(ref); + if (anchor) { + return anchor.kind; + } + + switch (this._univerInstanceService.getUnitType(unitId)) { + case UniverInstanceType.UNIVER_SHEET: + return ThreadCommentAnchorKind.SHEET_CELL; + case UniverInstanceType.UNIVER_DOC: + return ThreadCommentAnchorKind.DOC_TEXT_RANGE; + default: + return null; + } + } + + query(query: IThreadCommentQuery = {}): IThreadInfo[] { + const unitIds = query.unitIds ? new Set(query.unitIds) : null; + const subUnitIds = query.subUnitIds ? new Set(query.subUnitIds) : null; + const anchorKinds = query.anchorKinds ? new Set(query.anchorKinds) : null; + const authorIds = query.authorIds ? new Set(query.authorIds) : null; + + return this.getAll().flatMap(({ unitId, threads }) => { + if (unitIds && !unitIds.has(unitId)) { + return []; + } + + return threads.filter((thread) => { + if (subUnitIds && !subUnitIds.has(thread.subUnitId)) { + return false; + } + if (query.resolved !== undefined && Boolean(thread.root.resolved) !== query.resolved) { + return false; + } + if (anchorKinds) { + const anchorKind = this.getAnchorKind(unitId, thread.root.ref); + if (!anchorKind || !anchorKinds.has(anchorKind)) { + return false; + } + } + if (authorIds && ![thread.root, ...thread.children].some((comment) => authorIds.has(comment.personId))) { + return false; + } + return true; + }); + }); + } } diff --git a/packages/thread-comment/src/plugin.ts b/packages/thread-comment/src/plugin.ts index 7df3f66882..829b1eae72 100644 --- a/packages/thread-comment/src/plugin.ts +++ b/packages/thread-comment/src/plugin.ts @@ -45,6 +45,7 @@ import { defaultPluginConfig, THREAD_COMMENT_PLUGIN_CONFIG_KEY } from './config/ import { ThreadCommentResourceController } from './controllers/tc-resource.controller'; import { ThreadCommentModel } from './models/thread-comment.model'; import { IThreadCommentDataSourceService, ThreadCommentDataSourceService } from './services/tc-datasource.service'; +import { ThreadCommentFacadeService } from './services/thread-comment-api.service'; import { TC_PLUGIN_NAME } from './types/const'; export class UniverThreadCommentPlugin extends Plugin { @@ -74,6 +75,7 @@ export class UniverThreadCommentPlugin extends Plugin { (mergeOverrideWithDependencies([ [IThreadCommentDataSourceService, { useClass: ThreadCommentDataSourceService }], [ThreadCommentModel], + [ThreadCommentFacadeService], [ThreadCommentResourceController], ], this._config?.overrides) as Dependency[]).forEach( (d) => { diff --git a/packages/thread-comment/src/services/__tests__/tc-datasource.service.spec.ts b/packages/thread-comment/src/services/__tests__/tc-datasource.service.spec.ts index 49c0e41431..fbd9258dce 100644 --- a/packages/thread-comment/src/services/__tests__/tc-datasource.service.spec.ts +++ b/packages/thread-comment/src/services/__tests__/tc-datasource.service.spec.ts @@ -18,7 +18,7 @@ import type { IDocumentBody } from '@univerjs/core'; import type { IThreadComment } from '../../types/interfaces/i-thread-comment'; import type { IThreadCommentDataSource } from '../tc-datasource.service'; import { Injector } from '@univerjs/core'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { IThreadCommentDataSourceService, ThreadCommentDataSourceService } from '../tc-datasource.service'; function createBody(text: string): IDocumentBody { @@ -67,6 +67,7 @@ describe('ThreadCommentDataSourceService', () => { await expect(service.updateComment(comment)).resolves.toBe(true); await expect(service.resolveComment(comment)).resolves.toBe(true); await expect(service.deleteComment('unit-1', 'sheet-1', 'thread-1', 'comment-1')).resolves.toBe(true); + await expect(service.deleteThread?.('unit-1', 'sheet-1', 'thread-1')).resolves.toBe(true); await expect(service.getThreadComment('unit-1', 'sheet-1', 'thread-1')).resolves.toBeNull(); await expect(service.listThreadComments('unit-1', 'sheet-1', ['thread-1'])).resolves.toBe(false); @@ -198,4 +199,73 @@ describe('ThreadCommentDataSourceService', () => { threadId: 'server-id', }); }); + + it('uses the thread delete capability when provided and preserves the legacy fallback', async () => { + const service = createService(); + const deleteComment = vi.fn(async () => true); + const deleteThread = vi.fn(async () => true); + const dataSource: IThreadCommentDataSource = { + addComment: async (comment) => comment, + updateComment: async () => true, + resolveComment: async () => true, + deleteComment, + deleteThread, + listComments: async () => [], + saveCommentToSnapshot: (comment) => comment, + }; + service.dataSource = dataSource; + + await expect(service.deleteThread?.('unit-1', 'sheet-1', 'thread-1')).resolves.toBe(true); + expect(deleteThread).toHaveBeenCalledWith('unit-1', 'sheet-1', 'thread-1'); + expect(deleteComment).not.toHaveBeenCalled(); + + delete dataSource.deleteThread; + await expect(service.deleteThread?.('unit-1', 'sheet-1', 'thread-1')).resolves.toBe(true); + expect(deleteComment).toHaveBeenCalledWith('unit-1', 'sheet-1', 'thread-1', 'thread-1'); + }); + + it('coalesces concurrent adds with the same client comment id', async () => { + const service = createService(); + const comment = createComment({ id: 'idempotent-client-id' }); + let finishAdd: ((comment: IThreadComment) => void) | undefined; + const addComment = vi.fn(() => new Promise((resolve) => { + finishAdd = resolve; + })); + service.dataSource = { + addComment, + updateComment: async () => true, + resolveComment: async () => true, + deleteComment: async () => true, + listComments: async () => [], + saveCommentToSnapshot: (input) => input, + }; + + const first = service.addComment(comment); + const duplicate = service.addComment(comment); + expect(duplicate).toBe(first); + expect(addComment).toHaveBeenCalledTimes(1); + + finishAdd?.(comment); + await expect(Promise.all([first, duplicate])).resolves.toEqual([comment, comment]); + }); + + it('allows an add to be retried after the previous request fails', async () => { + const service = createService(); + const comment = createComment({ id: 'retry-client-id' }); + const addComment = vi.fn() + .mockRejectedValueOnce(new Error('network failure')) + .mockResolvedValueOnce(comment); + service.dataSource = { + addComment, + updateComment: async () => true, + resolveComment: async () => true, + deleteComment: async () => true, + listComments: async () => [], + saveCommentToSnapshot: (input) => input, + }; + + await expect(service.addComment(comment)).rejects.toThrow('network failure'); + await expect(service.addComment(comment)).resolves.toEqual(comment); + expect(addComment).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/thread-comment/src/services/__tests__/thread-comment-api.service.spec.ts b/packages/thread-comment/src/services/__tests__/thread-comment-api.service.spec.ts new file mode 100644 index 0000000000..98bc14a7ba --- /dev/null +++ b/packages/thread-comment/src/services/__tests__/thread-comment-api.service.spec.ts @@ -0,0 +1,55 @@ +/** + * 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 { describe, expect, it } from 'vitest'; +import { isThreadCommentDocumentBody, normalizeThreadCommentContent } from '../thread-comment-api.service'; + +describe('normalizeThreadCommentContent', () => { + it('accepts agent-friendly plain text and normalizes document line endings', () => { + expect(normalizeThreadCommentContent('First\nSecond')).toEqual({ + dataStream: 'First\rSecond\r\n', + }); + }); + + it('preserves an existing rich-text document body', () => { + const body = { dataStream: 'Styled\r\n', textRuns: [{ st: 0, ed: 6, ts: { bl: 1 } }] }; + + expect(normalizeThreadCommentContent(body)).toBe(body); + }); + + it('rejects malformed rich-text bodies at the facade boundary', () => { + expect(() => normalizeThreadCommentContent({ unexpected: true } as never)).toThrow('Invalid thread comment content'); + expect(() => normalizeThreadCommentContent(' \n')).toThrow('Thread comment content cannot be empty'); + expect(() => normalizeThreadCommentContent({ dataStream: '\r\n' })).toThrow('Thread comment content cannot be empty'); + }); + + it('rejects malformed optional document fields before they reach the editor', () => { + expect(isThreadCommentDocumentBody({ dataStream: 'safe\r\n', customRanges: {} })).toBe(false); + expect(isThreadCommentDocumentBody({ dataStream: 'safe\r\n', textRuns: 'bad' })).toBe(false); + expect(isThreadCommentDocumentBody({ dataStream: 'safe\r\n', payloads: { key: 1 } })).toBe(false); + expect(isThreadCommentDocumentBody({ + dataStream: '\r\n', + customRanges: [], + payloads: { key: 'value' }, + })).toBe(true); + }); + + it('normalizes a large plain-text comment without truncating its content', () => { + const content = 'x'.repeat(100_000); + + expect(normalizeThreadCommentContent(content).dataStream).toBe(`${content}\r\n`); + }); +}); diff --git a/packages/thread-comment/src/services/tc-datasource.service.ts b/packages/thread-comment/src/services/tc-datasource.service.ts index ae9f97d1fa..95411b425a 100644 --- a/packages/thread-comment/src/services/tc-datasource.service.ts +++ b/packages/thread-comment/src/services/tc-datasource.service.ts @@ -40,6 +40,11 @@ export interface IThreadCommentDataSource { * handler for delete-comment, throw error means fail and stop the process. */ deleteComment: (unitId: string, subUnitId: string, threadId: string, commentId: string) => Promise; + /** + * Delete a complete thread. Legacy data sources can omit this method and + * fall back to deleting the root comment through `deleteComment`. + */ + deleteThread?: (unitId: string, subUnitId: string, threadId: string) => Promise; /** * handler for batch-fetch-comment, throw error means fail and stop the process. */ @@ -71,6 +76,7 @@ export interface IThreadCommentDataSourceService { * handler for delete-comment, throw error means fail and stop the process. */ deleteComment: (unitId: string, subUnitId: string, threadId: string, commentId: string) => Promise; + deleteThread?: (unitId: string, subUnitId: string, threadId: string) => Promise; saveToSnapshot: (unitComments: Record, unitId: string) => Record; getThreadComment: (unitId: string, subUnitId: string, threadId: string) => Promise>; listThreadComments: (unitId: string, subUnitId: string, threadId: string[]) => Promise; @@ -81,6 +87,7 @@ export interface IThreadCommentDataSourceService { */ export class ThreadCommentDataSourceService extends Disposable implements IThreadCommentDataSourceService { private _dataSource: Nullable = null; + private readonly _pendingAdds = new Map>(); syncUpdateMutationToColla = true; set dataSource(dataSource: Nullable) { @@ -104,12 +111,26 @@ export class ThreadCommentDataSourceService extends Disposable implements IThrea return null; } - async addComment(comment: IThreadComment) { - const savedComment = this._dataSource - ? await this._dataSource.addComment(comment) - : comment; + addComment(comment: IThreadComment): Promise { + const key = `${comment.unitId}\0${comment.subUnitId}\0${comment.id}`; + const pending = this._pendingAdds.get(key); + if (pending) { + return pending; + } - return { ...savedComment, threadId: savedComment.threadId || savedComment.id }; + const request = (async () => { + const savedComment = this._dataSource + ? await this._dataSource.addComment(comment) + : comment; + return { ...savedComment, threadId: savedComment.threadId || savedComment.id }; + })(); + this._pendingAdds.set(key, request); + request.finally(() => { + if (this._pendingAdds.get(key) === request) { + this._pendingAdds.delete(key); + } + }).catch(() => undefined); + return request; } async updateComment(comment: IThreadComment) { @@ -133,6 +154,13 @@ export class ThreadCommentDataSourceService extends Disposable implements IThrea return true; } + async deleteThread(unitId: string, subUnitId: string, threadId: string) { + if (this._dataSource?.deleteThread) { + return this._dataSource.deleteThread(unitId, subUnitId, threadId); + } + return this.deleteComment(unitId, subUnitId, threadId, threadId); + } + async listThreadComments(unitId: string, subUnitId: string, threadIds: string[]) { if (this.dataSource) { return this.dataSource.listComments(unitId, subUnitId, threadIds); @@ -141,7 +169,7 @@ export class ThreadCommentDataSourceService extends Disposable implements IThrea return false; } - saveToSnapshot(unitComments: Record, unitId: string) { + saveToSnapshot(unitComments: Record, _unitId: string) { if (this._dataSource) { const map: Record = {}; Object.keys(unitComments).forEach((subUnitId) => { diff --git a/packages/thread-comment/src/services/thread-comment-api.service.ts b/packages/thread-comment/src/services/thread-comment-api.service.ts new file mode 100644 index 0000000000..fc35bebe23 --- /dev/null +++ b/packages/thread-comment/src/services/thread-comment-api.service.ts @@ -0,0 +1,304 @@ +/** + * 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 { IDocumentBody } from '@univerjs/core'; +import type { IThreadCommentQuery, IThreadInfo } from '../models/thread-comment.model'; +import type { IThreadCommentAnchor, ThreadCommentAnchorKind } from '../types/comment-anchor'; +import { generateRandomId, ICommandService, Inject, UserManagerService } from '@univerjs/core'; +import { + AddCommentCommand, + DeleteCommentCommand, + DeleteCommentTreeCommand, + ResolveCommentCommand, + UpdateCommentCommand, +} from '../commands/commands/comment.command'; +import { getDT } from '../common/utils'; +import { ThreadCommentModel } from '../models/thread-comment.model'; +import { deserializeThreadCommentAnchor, serializeThreadCommentAnchor } from '../types/comment-anchor'; + +export interface ICreateThreadCommentOptions { + /** Univer unit that owns the comment. */ + unitId: string; + /** Sheet, page, table, or product subunit that owns the comment. */ + subUnitId: string; + /** Stable, serializable product anchor. */ + anchor: IThreadCommentAnchor; + /** Plain text or a Univer document body for rich comment content. */ + content: ThreadCommentContent; + /** Attachment resource IDs. */ + attachments?: string[]; + /** Caller-supplied stable comment ID, useful for idempotent agent workflows. */ + id?: string; + /** Caller-supplied thread ID. Defaults to the root comment ID. */ + threadId?: string; + /** Author user ID. Defaults to the current user managed by `UserManagerService`. */ + personId?: string; + /** Creation time. Defaults to the current time. */ + dateTime?: Date; +} + +export interface IReplyThreadCommentOptions { + /** Univer unit that owns the thread. */ + unitId: string; + /** Sheet, page, table, or product subunit that owns the thread. */ + subUnitId: string; + /** Stable ID of the root thread being replied to. */ + threadId: string; + /** Plain text or a Univer document body for rich reply content. */ + content: ThreadCommentContent; + /** Attachment resource IDs. */ + attachments?: string[]; + /** Caller-supplied stable reply ID, useful for idempotent agent workflows. */ + id?: string; + /** Author user ID. Defaults to the current user managed by `UserManagerService`. */ + personId?: string; + /** Creation time. Defaults to the current time. */ + dateTime?: Date; +} + +export interface IUpdateThreadCommentOptions { + /** Univer unit that owns the comment. */ + unitId: string; + /** Sheet, page, table, or product subunit that owns the comment. */ + subUnitId: string; + /** Stable ID of the root comment or reply to update. */ + commentId: string; + /** Replacement plain text or Univer document body. */ + content: ThreadCommentContent; + /** Replacement attachment resource IDs. Omit to preserve the current attachments. */ + attachments?: string[]; + /** Update time. Defaults to the current time. */ + dateTime?: Date; +} + +export interface IDeleteThreadCommentOptions { + /** Univer unit that owns the comment. */ + unitId: string; + /** Sheet, page, table, or product subunit that owns the comment. */ + subUnitId: string; + /** Stable ID of the root comment or reply to delete. */ + commentId: string; + /** Deletes the complete root and reply tree when true. */ + deleteThread?: boolean; +} + +export interface IResolveThreadCommentOptions { + /** Univer unit that owns the thread. */ + unitId: string; + /** Sheet, page, table, or product subunit that owns the thread. */ + subUnitId: string; + /** Stable ID of the root comment or a reply in the target thread. */ + commentId: string; + /** Defaults to `true`. Pass `false` to reopen the thread. */ + resolved?: boolean; +} + +/** Plain text or a Univer document body used as comment content. */ +export type ThreadCommentContent = string | IDocumentBody; + +const DOCUMENT_BODY_ARRAY_FIELDS = [ + 'renderedPageBreaks', + 'textRuns', + 'paragraphs', + 'sectionBreaks', + 'customBlocks', + 'tables', + 'columnGroups', + 'blockRanges', + 'customRanges', + 'customDecorations', +] as const; + +export function isThreadCommentDocumentBody(value: unknown): value is IDocumentBody { + if (!value || typeof value !== 'object') { + return false; + } + if (typeof Reflect.get(value, 'dataStream') !== 'string') { + return false; + } + if (DOCUMENT_BODY_ARRAY_FIELDS.some((field) => { + const fieldValue = Reflect.get(value, field); + return fieldValue !== undefined && !Array.isArray(fieldValue); + })) { + return false; + } + const payloads = Reflect.get(value, 'payloads'); + return payloads === undefined || ( + payloads !== null + && typeof payloads === 'object' + && !Array.isArray(payloads) + && Object.values(payloads).every((item) => typeof item === 'string') + ); +} + +export function normalizeThreadCommentContent(content: ThreadCommentContent): IDocumentBody { + if (typeof content !== 'string') { + if (!isThreadCommentDocumentBody(content)) { + throw new TypeError('Invalid thread comment content'); + } + if (!content.dataStream.replace(/[\r\n]/g, '').trim()) { + throw new TypeError('Thread comment content cannot be empty'); + } + return content; + } + + if (!content.trim()) { + throw new TypeError('Thread comment content cannot be empty'); + } + + const dataStream = content.replace(/\r\n?|\n/g, '\r').replace(/\r*$/, '\r\n'); + return { dataStream }; +} + +export interface IFacadeThreadCommentInfo extends Omit { + /** + * Product anchor category. Facade query results always populate this field, including for legacy + * Sheet cell and Document range comments. It remains optional for source compatibility with + * existing `IFacadeThreadCommentInfo` implementations. + */ + anchorKind?: ThreadCommentAnchorKind | null; + /** Parsed stable product anchor, or `null` for a legacy comment reference. */ + anchor: IThreadCommentAnchor | null; + /** Unique IDs of authors participating in the root and reply tree. */ + relativeUserIds: string[]; +} + +function toFacadeInfo(thread: IThreadInfo, anchorKind: ThreadCommentAnchorKind | null): IFacadeThreadCommentInfo { + return { + unitId: thread.unitId, + subUnitId: thread.subUnitId, + threadId: thread.threadId, + root: thread.root, + children: thread.children, + anchorKind, + anchor: deserializeThreadCommentAnchor(thread.root.ref), + relativeUserIds: Array.from(thread.relativeUsers), + }; +} + +export class ThreadCommentFacadeService { + constructor( + @Inject(ICommandService) private readonly _commandService: ICommandService, + @Inject(ThreadCommentModel) private readonly _model: ThreadCommentModel, + @Inject(UserManagerService) private readonly _userManagerService: UserManagerService + ) {} + + createCommentAsync(options: ICreateThreadCommentOptions): Promise { + const id = options.id ?? generateRandomId(); + const threadId = options.threadId ?? id; + const personId = options.personId ?? this._userManagerService.getCurrentUser().userID; + + return this._commandService.executeCommand(AddCommentCommand.id, { + unitId: options.unitId, + subUnitId: options.subUnitId, + comment: { + id, + threadId, + unitId: options.unitId, + subUnitId: options.subUnitId, + ref: serializeThreadCommentAnchor(options.anchor), + text: normalizeThreadCommentContent(options.content), + attachments: options.attachments ?? [], + dT: getDT(options.dateTime), + personId, + }, + }); + } + + replyCommentAsync(options: IReplyThreadCommentOptions): Promise { + const root = this._model.getRootComment(options.unitId, options.subUnitId, options.threadId); + if (!root) { + return Promise.resolve(false); + } + + return this._commandService.executeCommand(AddCommentCommand.id, { + unitId: options.unitId, + subUnitId: options.subUnitId, + comment: { + id: options.id ?? generateRandomId(), + threadId: options.threadId, + parentId: root.id, + unitId: options.unitId, + subUnitId: options.subUnitId, + ref: root.ref, + text: normalizeThreadCommentContent(options.content), + attachments: options.attachments ?? [], + dT: getDT(options.dateTime), + personId: options.personId ?? this._userManagerService.getCurrentUser().userID, + }, + }); + } + + updateCommentAsync(options: IUpdateThreadCommentOptions): Promise { + return this._commandService.executeCommand(UpdateCommentCommand.id, { + unitId: options.unitId, + subUnitId: options.subUnitId, + payload: { + commentId: options.commentId, + text: normalizeThreadCommentContent(options.content), + attachments: options.attachments, + updateT: getDT(options.dateTime), + }, + }); + } + + deleteCommentAsync(options: IDeleteThreadCommentOptions): Promise { + return this._commandService.executeCommand( + options.deleteThread ? DeleteCommentTreeCommand.id : DeleteCommentCommand.id, + { + unitId: options.unitId, + subUnitId: options.subUnitId, + commentId: options.commentId, + } + ); + } + + resolveCommentAsync(options: IResolveThreadCommentOptions): Promise { + return this._commandService.executeCommand(ResolveCommentCommand.id, { + unitId: options.unitId, + subUnitId: options.subUnitId, + commentId: options.commentId, + resolved: options.resolved ?? true, + }); + } + + getComments(query: IThreadCommentQuery = {}): IFacadeThreadCommentInfo[] { + return this._model.query(query).map((thread) => toFacadeInfo( + thread, + this._model.getAnchorKind(thread.unitId, thread.root.ref) + )); + } + + async listCommentsAsync(query: IThreadCommentQuery = {}): Promise { + const knownThreads = this._model.query({ unitIds: query.unitIds, subUnitIds: query.subUnitIds }); + const groups = new Map(); + knownThreads.forEach((thread) => { + const key = `${thread.unitId}\0${thread.subUnitId}`; + const group = groups.get(key) ?? { unitId: thread.unitId, subUnitId: thread.subUnitId, threadIds: [] }; + group.threadIds.push(thread.threadId); + groups.set(key, group); + }); + await Promise.all(Array.from(groups.values()).map((group) => this._model.syncThreadComments( + group.unitId, + group.subUnitId, + group.threadIds + ))); + return this._model.query(query).map((thread) => toFacadeInfo( + thread, + this._model.getAnchorKind(thread.unitId, thread.root.ref) + )); + } +} diff --git a/packages/thread-comment/src/types/__tests__/comment-anchor.spec.ts b/packages/thread-comment/src/types/__tests__/comment-anchor.spec.ts new file mode 100644 index 0000000000..5feec18ea2 --- /dev/null +++ b/packages/thread-comment/src/types/__tests__/comment-anchor.spec.ts @@ -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 { describe, expect, it } from 'vitest'; +import { + deserializeThreadCommentAnchor, + serializeThreadCommentAnchor, + ThreadCommentAnchorKind, +} from '../comment-anchor'; + +describe('thread comment anchor', () => { + it('round trips every supported anchor shape', () => { + const anchors = [ + { kind: ThreadCommentAnchorKind.SHEET_DRAWING, elementId: 'drawing-1' }, + { kind: ThreadCommentAnchorKind.DOC_DRAWING, elementId: 'drawing-2' }, + { kind: ThreadCommentAnchorKind.SLIDE_ELEMENT, pageId: 'page-1', elementId: 'shape-1' }, + { kind: ThreadCommentAnchorKind.SLIDE_POSITION, pageId: 'page-1', x: 0.25, y: 0.75 }, + { kind: ThreadCommentAnchorKind.BOARD_ELEMENT, elementId: 'shape-2' }, + { kind: ThreadCommentAnchorKind.BOARD_POSITION, x: 120, y: -30 }, + { kind: ThreadCommentAnchorKind.BASE_RECORD, tableId: 'table-1', recordId: 'record-1' }, + ] as const; + + anchors.forEach((anchor) => { + expect(deserializeThreadCommentAnchor(serializeThreadCommentAnchor(anchor))).toEqual(anchor); + }); + }); + + it('rejects legacy and malformed refs without throwing', () => { + expect(deserializeThreadCommentAnchor('A1')).toBeNull(); + expect(deserializeThreadCommentAnchor('univer-comment-anchor:{bad')).toBeNull(); + expect(deserializeThreadCommentAnchor('univer-comment-anchor:{"v":1,"anchor":{"kind":"base-record"}}')).toBeNull(); + }); +}); diff --git a/packages/thread-comment/src/types/comment-anchor.ts b/packages/thread-comment/src/types/comment-anchor.ts new file mode 100644 index 0000000000..04b57e73d5 --- /dev/null +++ b/packages/thread-comment/src/types/comment-anchor.ts @@ -0,0 +1,134 @@ +/** + * 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 enum ThreadCommentAnchorKind { + /** A cell comment managed by the Sheets range Facade. */ + SHEET_CELL = 'sheet-cell', + /** A Sheet image, chart, Shape, or other drawing element. */ + SHEET_DRAWING = 'sheet-drawing', + /** A fixed text range comment managed by the Docs text range Facade. */ + DOC_TEXT_RANGE = 'doc-text-range', + /** A Document image, chart, Shape, or other drawing element. */ + DOC_DRAWING = 'doc-drawing', + /** A Slide page element identified by a stable element ID. */ + SLIDE_ELEMENT = 'slide-element', + /** A free position normalized to the Slide page size. */ + SLIDE_POSITION = 'slide-position', + /** A Board element identified by a stable element ID. */ + BOARD_ELEMENT = 'board-element', + /** A free position in Board world coordinates. */ + BOARD_POSITION = 'board-position', + /** A Base record identified by stable table and record IDs. */ + BASE_RECORD = 'base-record', +} + +export interface IThreadCommentElementAnchor { + kind: + | ThreadCommentAnchorKind.SHEET_DRAWING + | ThreadCommentAnchorKind.DOC_DRAWING + | ThreadCommentAnchorKind.SLIDE_ELEMENT + | ThreadCommentAnchorKind.BOARD_ELEMENT; + elementId: string; + /** Slide or Board page ID when the host product has pages. */ + pageId?: string; +} + +export interface IThreadCommentPositionAnchor { + kind: ThreadCommentAnchorKind.SLIDE_POSITION | ThreadCommentAnchorKind.BOARD_POSITION; + x: number; + y: number; + /** Slide or Board page ID when the host product has pages. */ + pageId?: string; +} + +export interface IThreadCommentBaseRecordAnchor { + kind: ThreadCommentAnchorKind.BASE_RECORD; + tableId: string; + recordId: string; +} + +export type IThreadCommentAnchor = + | IThreadCommentElementAnchor + | IThreadCommentPositionAnchor + | IThreadCommentBaseRecordAnchor; + +const THREAD_COMMENT_ANCHOR_PREFIX = 'univer-comment-anchor:'; + +interface ISerializedThreadCommentAnchor { + v: 1; + anchor: IThreadCommentAnchor; +} + +function isNonEmptyString(value: unknown): value is string { + return typeof value === 'string' && value.length > 0; +} + +function isOptionalString(value: unknown): value is string | undefined { + return value === undefined || isNonEmptyString(value); +} + +function isFiniteNumber(value: unknown): value is number { + return typeof value === 'number' && Number.isFinite(value); +} + +export function isThreadCommentAnchor(value: unknown): value is IThreadCommentAnchor { + if (!value || typeof value !== 'object') { + return false; + } + + const kind = Reflect.get(value, 'kind'); + switch (kind) { + case ThreadCommentAnchorKind.SHEET_DRAWING: + case ThreadCommentAnchorKind.DOC_DRAWING: + case ThreadCommentAnchorKind.SLIDE_ELEMENT: + case ThreadCommentAnchorKind.BOARD_ELEMENT: + return isNonEmptyString(Reflect.get(value, 'elementId')) && isOptionalString(Reflect.get(value, 'pageId')); + case ThreadCommentAnchorKind.SLIDE_POSITION: + case ThreadCommentAnchorKind.BOARD_POSITION: + return isFiniteNumber(Reflect.get(value, 'x')) && isFiniteNumber(Reflect.get(value, 'y')) + && isOptionalString(Reflect.get(value, 'pageId')); + case ThreadCommentAnchorKind.BASE_RECORD: + return isNonEmptyString(Reflect.get(value, 'tableId')) && isNonEmptyString(Reflect.get(value, 'recordId')); + default: + return false; + } +} + +export function serializeThreadCommentAnchor(anchor: IThreadCommentAnchor): string { + if (!isThreadCommentAnchor(anchor)) { + throw new TypeError('Invalid thread comment anchor'); + } + + const value: ISerializedThreadCommentAnchor = { v: 1, anchor }; + return `${THREAD_COMMENT_ANCHOR_PREFIX}${JSON.stringify(value)}`; +} + +export function deserializeThreadCommentAnchor(ref: string): IThreadCommentAnchor | null { + if (!ref.startsWith(THREAD_COMMENT_ANCHOR_PREFIX)) { + return null; + } + + try { + const value: unknown = JSON.parse(ref.slice(THREAD_COMMENT_ANCHOR_PREFIX.length)); + if (!value || typeof value !== 'object' || Reflect.get(value, 'v') !== 1) { + return null; + } + const anchor = Reflect.get(value, 'anchor'); + return isThreadCommentAnchor(anchor) ? anchor : null; + } catch { + return null; + } +} diff --git a/packages/thread-comment/src/types/interfaces/i-thread-comment.ts b/packages/thread-comment/src/types/interfaces/i-thread-comment.ts index 972c946367..2315a3bd41 100644 --- a/packages/thread-comment/src/types/interfaces/i-thread-comment.ts +++ b/packages/thread-comment/src/types/interfaces/i-thread-comment.ts @@ -41,4 +41,12 @@ export interface IBaseComment { export interface IThreadComment extends IBaseComment { ref: string; + /** Inclusive start offset for a document text-range comment. */ + startOffset?: number; + /** Exclusive end offset for a document text-range comment. */ + endOffset?: number; + /** Document segment that owns the text range. Empty string means the document body. */ + segmentId?: string; + /** Whether the document text range is collapsed. */ + collapsed?: boolean; } diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index fa9e5a21b8..43680d0ee0 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -100,6 +100,7 @@ export type { IMenuSchema, IRibbonGridLayout, MenuSchemaType } from './services/ export { ContextMenuGroup, ContextMenuPosition, + FloatingObjectToolbarPosition, MenuManagerPosition, RibbonDataGroup, RibbonFormulasGroup, diff --git a/packages/ui/src/services/menu/__tests__/menu-manager.service.spec.ts b/packages/ui/src/services/menu/__tests__/menu-manager.service.spec.ts index e269b1516a..3377837ce6 100644 --- a/packages/ui/src/services/menu/__tests__/menu-manager.service.spec.ts +++ b/packages/ui/src/services/menu/__tests__/menu-manager.service.spec.ts @@ -19,6 +19,7 @@ import { ConfigService, IConfigService, Injector } from '@univerjs/core'; import { describe, expect, it } from 'vitest'; import { isMenuButtonSelectorItem, isMenuSelectorItem, MenuItemType } from '../menu'; import { IMenuManagerService, MenuManagerService } from '../menu-manager.service'; +import { FloatingObjectToolbarPosition } from '../types'; function createService(): IMenuManagerService { const injector = new Injector(); @@ -55,6 +56,18 @@ describe('MenuManagerService', () => { sub.unsubscribe(); }); + it.each(Object.values(FloatingObjectToolbarPosition))('accepts contributions for %s', (position) => { + const service = createService(); + + service.mergeMenu({ + [position]: { + comment: { order: 1, menuItemFactory: () => ({ id: 'comment' }) }, + }, + }); + + expect(service.getFlatMenuByPositionKey(position).map((item) => item.item?.id)).toEqual(['comment']); + }); + it('returns an empty array for missing menu positions', () => { const service = createService(); diff --git a/packages/ui/src/services/menu/menu-manager.service.ts b/packages/ui/src/services/menu/menu-manager.service.ts index fd5665e316..c576c436e2 100644 --- a/packages/ui/src/services/menu/menu-manager.service.ts +++ b/packages/ui/src/services/menu/menu-manager.service.ts @@ -20,7 +20,19 @@ import type { IMenuItem } from './menu'; import { createIdentifier, Disposable, IConfigService, Inject, Injector, merge } from '@univerjs/core'; import { Subject } from 'rxjs'; import { mergeMenuConfigs } from '../../common/menu-merge-configs'; -import { ContextMenuGroup, ContextMenuPosition, MenuManagerPosition, RibbonDataGroup, RibbonFormulasGroup, RibbonInsertGroup, RibbonOthersGroup, RibbonPosition, RibbonStartGroup, RibbonViewGroup } from './types'; +import { + ContextMenuGroup, + ContextMenuPosition, + FloatingObjectToolbarPosition, + MenuManagerPosition, + RibbonDataGroup, + RibbonFormulasGroup, + RibbonInsertGroup, + RibbonOthersGroup, + RibbonPosition, + RibbonStartGroup, + RibbonViewGroup, +} from './types'; export const IMenuManagerService = createIdentifier('univer.menu-manager-service'); @@ -272,6 +284,10 @@ export class MenuManagerService extends Disposable implements IMenuManagerServic }, }, }, + [FloatingObjectToolbarPosition.SHEET]: {}, + [FloatingObjectToolbarPosition.DOC]: {}, + [FloatingObjectToolbarPosition.SLIDE]: {}, + [FloatingObjectToolbarPosition.BOARD]: {}, }; constructor( diff --git a/packages/ui/src/services/menu/types.ts b/packages/ui/src/services/menu/types.ts index 9f313c98d5..6b7a9c3364 100644 --- a/packages/ui/src/services/menu/types.ts +++ b/packages/ui/src/services/menu/types.ts @@ -19,6 +19,13 @@ export enum MenuManagerPosition { CONTEXT_MENU = 'contextMenu', } +export enum FloatingObjectToolbarPosition { + SHEET = 'floatingObjectToolbar.sheet', + DOC = 'floatingObjectToolbar.doc', + SLIDE = 'floatingObjectToolbar.slide', + BOARD = 'floatingObjectToolbar.board', +} + export enum RibbonPosition { START = 'ribbon.start', // RibbonStartGroup INSERT = 'ribbon.insert', // RibbonInsertGroup diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ef8ba474e..291a72bf59 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1385,6 +1385,31 @@ importers: specifier: ^4.1.11 version: 4.1.11(@types/node@26.1.2)(@vitest/coverage-istanbul@4.1.11)(happy-dom@20.11.6)(jsdom@29.1.1)(vite@8.0.11(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.87.0)(terser@5.47.1)(tsx@4.22.4)(yaml@2.9.0)) + packages/docs-thread-comment: + dependencies: + '@univerjs/core': + specifier: workspace:* + version: link:../core + '@univerjs/docs': + specifier: workspace:* + version: link:../docs + '@univerjs/thread-comment': + specifier: workspace:* + version: link:../thread-comment + devDependencies: + '@univerjs-infra/shared': + specifier: workspace:* + version: link:../../common/shared + rxjs: + specifier: ^7.8.2 + version: 7.8.2 + typescript: + specifier: ^6.0.3 + version: 6.0.3 + vitest: + specifier: ^4.1.10 + version: 4.1.11(@types/node@26.2.0)(@vitest/coverage-istanbul@4.1.11)(happy-dom@20.11.6)(jsdom@29.1.1)(vite@8.0.11(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.87.0)(terser@5.47.1)(tsx@4.22.4)(yaml@2.9.0)) + packages/docs-thread-comment-ui: dependencies: '@univerjs/core': @@ -1393,9 +1418,15 @@ importers: '@univerjs/docs': specifier: workspace:* version: link:../docs + '@univerjs/docs-thread-comment': + specifier: workspace:* + version: link:../docs-thread-comment '@univerjs/docs-ui': specifier: workspace:* version: link:../docs-ui + '@univerjs/drawing': + specifier: workspace:* + version: link:../drawing '@univerjs/engine-render': specifier: workspace:* version: link:../engine-render @@ -2739,6 +2770,9 @@ importers: '@univerjs/core': specifier: workspace:* version: link:../core + '@univerjs/drawing': + specifier: workspace:* + version: link:../drawing '@univerjs/engine-formula': specifier: workspace:* version: link:../engine-formula @@ -2976,6 +3010,9 @@ importers: '@univerjs/docs-ui': specifier: workspace:* version: link:../docs-ui + '@univerjs/engine-render': + specifier: workspace:* + version: link:../engine-render '@univerjs/icons': specifier: 1.38.0 version: 1.38.0(react-dom@19.2.8(react@19.2.8))(react@19.2.8) @@ -2989,9 +3026,6 @@ importers: '@univerjs-infra/shared': specifier: workspace:* version: link:../../common/shared - '@univerjs/engine-render': - specifier: workspace:* - version: link:../engine-render postcss: specifier: ^8.5.26 version: 8.5.26 @@ -4252,21 +4286,10 @@ packages: '@csstools/css-parser-algorithms': ^4.0.0 '@csstools/css-tokenizer': ^4.0.0 - '@csstools/color-helpers@6.1.0': - resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==} - engines: {node: '>=20.19.0'} - '@csstools/color-helpers@6.1.1': resolution: {integrity: sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==} engines: {node: '>=20.19.0'} - '@csstools/css-calc@3.2.1': - resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==} - engines: {node: '>=20.19.0'} - peerDependencies: - '@csstools/css-parser-algorithms': ^4.0.0 - '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-calc@3.3.0': resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==} engines: {node: '>=20.19.0'} @@ -4274,13 +4297,6 @@ packages: '@csstools/css-parser-algorithms': ^4.0.0 '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-color-parser@4.1.9': - resolution: {integrity: sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==} - engines: {node: '>=20.19.0'} - peerDependencies: - '@csstools/css-parser-algorithms': ^4.0.0 - '@csstools/css-tokenizer': ^4.0.0 - '@csstools/css-color-parser@4.2.0': resolution: {integrity: sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==} engines: {node: '>=20.19.0'} @@ -8207,9 +8223,6 @@ packages: es-module-lexer@1.7.0: resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} - es-module-lexer@2.3.0: - resolution: {integrity: sha512-KLdwQm2NvGLDkQDCGvmiQrhkd0JbMzXthwQAUgWjQuQdBLFa3eiBP5arXZyA+f8x+x7OXgud6bq2rxjGtHV2tw==} - es-module-lexer@2.3.2: resolution: {integrity: sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==} @@ -9771,11 +9784,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - nanoid@3.3.18: resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -10354,10 +10362,6 @@ packages: resolution: {integrity: sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==} engines: {node: ^10 || ^12 || >=14} - postcss@8.5.25: - resolution: {integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.26: resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} engines: {node: ^10 || ^12 || >=14} @@ -11113,10 +11117,6 @@ packages: resolution: {integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==} engines: {node: '>=14.0.0'} - tinyrainbow@3.1.0: - resolution: {integrity: sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==} - engines: {node: '>=14.0.0'} - tinyrainbow@3.1.1: resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==} engines: {node: '>=14.0.0'} @@ -11835,8 +11835,8 @@ snapshots: '@asamuzakjp/css-color@5.1.11': dependencies: '@asamuzakjp/generational-cache': 1.0.1 - '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-color-parser': 4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-color-parser': 4.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 @@ -11901,10 +11901,10 @@ snapshots: '@babel/helper-compilation-targets': 7.29.7 '@babel/helper-module-transforms': 7.29.7(@babel/core@7.29.7(supports-color@8.1.1))(supports-color@8.1.1) '@babel/helpers': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 '@babel/template': 7.29.7 '@babel/traverse': 7.29.7(supports-color@8.1.1) - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 debug: 4.4.3(supports-color@8.1.1) @@ -11924,8 +11924,8 @@ snapshots: '@babel/generator@7.29.7': dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@jridgewell/gen-mapping': 0.3.13 '@jridgewell/trace-mapping': 0.3.31 jsesc: 3.1.0 @@ -11942,7 +11942,7 @@ snapshots: dependencies: '@babel/compat-data': 7.29.7 '@babel/helper-validator-option': 7.29.7 - browserslist: 4.28.7 + browserslist: 4.28.8 lru-cache: 5.1.1 semver: 6.3.1 @@ -11960,7 +11960,7 @@ snapshots: '@babel/helper-module-imports@7.29.7(supports-color@8.1.1)': dependencies: '@babel/traverse': 7.29.7(supports-color@8.1.1) - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 transitivePeerDependencies: - supports-color @@ -12004,7 +12004,7 @@ snapshots: '@babel/helpers@7.29.7': dependencies: '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 '@babel/parser@7.29.0': dependencies: @@ -12037,8 +12037,8 @@ snapshots: '@babel/template@7.29.7': dependencies: '@babel/code-frame': 7.29.7 - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 '@babel/traverse@7.29.0(supports-color@8.1.1)': dependencies: @@ -12057,9 +12057,9 @@ snapshots: '@babel/code-frame': 7.29.7 '@babel/generator': 7.29.7 '@babel/helper-globals': 7.29.7 - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 '@babel/template': 7.29.7 - '@babel/types': 7.29.7 + '@babel/types': 7.29.8 debug: 4.4.3(supports-color@8.1.1) transitivePeerDependencies: - supports-color @@ -12229,27 +12229,13 @@ snapshots: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/color-helpers@6.1.0': {} - '@csstools/color-helpers@6.1.1': {} - '@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': - dependencies: - '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-color-parser@4.1.9(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': - dependencies: - '@csstools/color-helpers': 6.1.0 - '@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) - '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) - '@csstools/css-tokenizer': 4.0.0 - '@csstools/css-color-parser@4.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': dependencies: '@csstools/color-helpers': 6.1.1 @@ -14391,7 +14377,6 @@ snapshots: '@types/node@26.2.0': dependencies: undici-types: 8.3.0 - optional: true '@types/rbush@4.0.0': {} @@ -14425,7 +14410,7 @@ snapshots: '@types/ws@8.18.1': dependencies: - '@types/node': 26.1.2 + '@types/node': 26.2.0 '@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3))(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1)(typescript@6.0.3)': dependencies: @@ -14615,7 +14600,7 @@ snapshots: istanbul-reports: 3.2.0 magicast: 0.5.3 obug: 2.1.4 - tinyrainbow: 3.1.0 + tinyrainbow: 3.1.1 vitest: 4.1.11(@types/node@26.1.2)(@vitest/coverage-istanbul@4.1.11)(happy-dom@20.11.6)(jsdom@29.1.1)(vite@8.0.11(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.87.0)(terser@5.47.1)(tsx@4.22.4)(yaml@2.9.0)) transitivePeerDependencies: - supports-color @@ -14719,7 +14704,7 @@ snapshots: '@vue/compiler-core@3.5.40': dependencies: - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 '@vue/shared': 3.5.40 entities: 7.0.1 estree-walker: 2.0.2 @@ -14745,14 +14730,14 @@ snapshots: '@vue/compiler-sfc@3.5.40': dependencies: - '@babel/parser': 7.29.7 + '@babel/parser': 7.29.8 '@vue/compiler-core': 3.5.40 '@vue/compiler-dom': 3.5.40 '@vue/compiler-ssr': 3.5.40 '@vue/shared': 3.5.40 estree-walker: 2.0.2 magic-string: 0.30.21 - postcss: 8.5.25 + postcss: 8.5.26 source-map-js: 1.2.1 '@vue/compiler-ssr@3.5.40': @@ -15228,7 +15213,7 @@ snapshots: buffer-image-size@0.6.4: dependencies: - '@types/node': 26.1.2 + '@types/node': 26.2.0 builtin-modules@5.0.0: {} @@ -15867,8 +15852,6 @@ snapshots: es-module-lexer@1.7.0: {} - es-module-lexer@2.3.0: {} - es-module-lexer@2.3.2: {} es-object-atoms@1.1.1: @@ -16711,7 +16694,7 @@ snapshots: happy-dom@20.11.6: dependencies: - '@types/node': 26.1.2 + '@types/node': 26.2.0 '@types/whatwg-mimetype': 3.0.2 '@types/ws': 8.18.1 buffer-image-size: 0.6.4 @@ -17107,7 +17090,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 26.1.2 + '@types/node': 26.2.0 merge-stream: 2.0.0 supports-color: 8.1.1 @@ -17349,8 +17332,8 @@ snapshots: magicast@0.5.3: dependencies: - '@babel/parser': 7.29.7 - '@babel/types': 7.29.7 + '@babel/parser': 7.29.8 + '@babel/types': 7.29.8 source-map-js: 1.2.1 make-asynchronous@1.1.0: @@ -17804,8 +17787,6 @@ snapshots: nanoid@3.3.12: {} - nanoid@3.3.16: {} - nanoid@3.3.18: {} natural-compare@1.4.0: {} @@ -18304,13 +18285,13 @@ snapshots: dependencies: icss-utils: 5.1.0(postcss@8.5.26) postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.5 postcss-value-parser: 4.2.0 postcss-modules-scope@3.2.1(postcss@8.5.26): dependencies: postcss: 8.5.26 - postcss-selector-parser: 7.1.4 + postcss-selector-parser: 7.1.5 postcss-modules-values@4.0.0(postcss@8.5.26): dependencies: @@ -18481,12 +18462,6 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 - postcss@8.5.25: - dependencies: - nanoid: 3.3.16 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.26: dependencies: nanoid: 3.3.18 @@ -19413,8 +19388,6 @@ snapshots: tinyrainbow@2.0.0: {} - tinyrainbow@3.1.0: {} - tinyrainbow@3.1.1: {} tinyspy@4.0.4: {} @@ -19736,7 +19709,7 @@ snapshots: dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 - postcss: 8.5.25 + postcss: 8.5.26 rolldown: 1.0.0-rc.18 tinyglobby: 0.2.17 optionalDependencies: @@ -19753,7 +19726,7 @@ snapshots: dependencies: lightningcss: 1.33.0 picomatch: 4.0.5 - postcss: 8.5.25 + postcss: 8.5.26 rolldown: 1.0.0-rc.18 tinyglobby: 0.2.17 optionalDependencies: @@ -19826,6 +19799,36 @@ snapshots: transitivePeerDependencies: - msw + vitest@4.1.11(@types/node@26.2.0)(@vitest/coverage-istanbul@4.1.11)(happy-dom@20.11.6)(jsdom@29.1.1)(vite@8.0.11(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.87.0)(terser@5.47.1)(tsx@4.22.4)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.0.11(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.87.0)(terser@5.47.1)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.0.11(@types/node@26.2.0)(esbuild@0.28.1)(jiti@2.7.0)(sass@1.87.0)(terser@5.47.1)(tsx@4.22.4)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 26.2.0 + '@vitest/coverage-istanbul': 4.1.11(supports-color@8.1.1)(vitest@4.1.11) + happy-dom: 20.11.6 + jsdom: 29.1.1 + transitivePeerDependencies: + - msw + vscode-uri@3.1.0: {} vue-eslint-parser@10.4.1(eslint@10.8.0(jiti@2.7.0)(supports-color@8.1.1))(supports-color@8.1.1): @@ -19899,10 +19902,10 @@ snapshots: '@webassemblyjs/wasm-parser': 1.14.1 acorn: 8.17.0 acorn-import-phases: 1.0.4(acorn@8.17.0) - browserslist: 4.28.7 + browserslist: 4.28.8 chrome-trace-event: 1.0.4 enhanced-resolve: 5.24.3 - es-module-lexer: 2.3.0 + es-module-lexer: 2.3.2 eslint-scope: 5.1.1 events: 3.3.0 glob-to-regexp: 0.4.1