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

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

View File

@@ -0,0 +1,85 @@
# @univerjs/thread-comment
[![npm version](https://img.shields.io/npm/v/@univerjs/thread-comment?style=flat-square)](https://npmjs.com/package/@univerjs/thread-comment)
[![license](https://img.shields.io/npm/l/@univerjs/thread-comment?style=flat-square)](https://npmjs.com/package/@univerjs/thread-comment)
[![downloads](https://img.shields.io/npm/dm/@univerjs/thread-comment?style=flat-square)](https://npmjs.com/package/@univerjs/thread-comment)
`@univerjs/thread-comment` provides shared thread comment models, commands, and services that product-specific comment packages build on.
## Package Overview
| Package | UMD global | CSS | Locales | Facade entry |
| --- | --- | :---: | :---: | :---: |
| `@univerjs/thread-comment` | `UniverThreadComment` | No | No | Yes |
## Installation
```sh
pnpm add @univerjs/thread-comment
# or
npm install @univerjs/thread-comment
```
Keep all `@univerjs/*` packages on the same version.
## Usage
```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.
## Resources
- [Documentation](https://docs.univer.ai)
- [NPM package](https://npmjs.com/package/@univerjs/thread-comment)
- [GitHub repository](https://github.com/dream-num/univer)

View File

@@ -0,0 +1,89 @@
{
"name": "@univerjs/thread-comment",
"version": "1.0.0-rc.0",
"private": false,
"description": "Shared thread comment models, commands, and services for Univer.",
"author": "DreamNum Co., Ltd. <developer@univer.ai>",
"license": "Apache-2.0",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/univer"
},
"homepage": "https://univer.ai",
"repository": {
"type": "git",
"url": "https://github.com/dream-num/univer"
},
"bugs": {
"url": "https://github.com/dream-num/univer/issues"
},
"keywords": [
"univer",
"comment",
"thread-comment",
"collaboration",
"plugin"
],
"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"
},
"peerDependencies": {
"rxjs": ">=7.0.0"
},
"dependencies": {
"@univerjs/core": "workspace:*"
},
"devDependencies": {
"@univerjs-infra/shared": "workspace:*",
"rxjs": "^7.8.2",
"typescript": "^6.0.3",
"vitest": "^5.0.0"
}
}

View File

@@ -0,0 +1,752 @@
/**
* 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, Injector, IWorkbookData } from '@univerjs/core';
import type { CommentUpdate } from '../../../models/thread-comment.model';
import type { IThreadComment } from '../../../types/interfaces/i-thread-comment';
import {
ICommandService,
IUniverInstanceService,
LifecycleService,
LifecycleStages,
LocaleType,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { ThreadCommentModel } from '../../../models/thread-comment.model';
import { UniverThreadCommentPlugin } from '../../../plugin';
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,
DeleteCommentTreeCommand,
ResolveCommentCommand,
UpdateCommentCommand,
} from '../comment.command';
function createWorkbookData(): IWorkbookData {
return {
id: 'unit-1',
appVersion: '3.0.0-alpha',
locale: LocaleType.EN_US,
name: '',
sheetOrder: ['sheet-1'],
styles: {},
sheets: {
'sheet-1': {
id: 'sheet-1',
name: 'Sheet1',
cellData: {},
},
},
};
}
function createBody(text: string): IDocumentBody {
return {
dataStream: `${text}\r\n`,
};
}
function createComment(overrides: Partial<IThreadComment> = {}): IThreadComment {
const id = overrides.id ?? 'comment-1';
return {
id,
threadId: overrides.threadId ?? overrides.parentId ?? id,
ref: overrides.ref ?? 'A1',
dT: overrides.dT ?? '2024-01-01T00:00:00.000Z',
personId: overrides.personId ?? 'user-1',
text: overrides.text ?? createBody(id),
unitId: overrides.unitId ?? 'unit-1',
subUnitId: overrides.subUnitId ?? 'sheet-1',
attachments: overrides.attachments,
children: overrides.children,
mentions: overrides.mentions,
parentId: overrides.parentId,
resolved: overrides.resolved,
updateT: overrides.updateT,
updated: overrides.updated,
};
}
describe('Thread comment commands', () => {
let univer: Univer;
let get: Injector['get'];
let commandService: ICommandService;
let threadCommentModel: ThreadCommentModel;
beforeEach(() => {
univer = new Univer();
univer.registerPlugin(UniverThreadCommentPlugin);
univer.createUnit(UniverInstanceType.UNIVER_SHEET, createWorkbookData());
const injector = univer.__getInjector();
get = injector.get.bind(injector);
get(IUniverInstanceService).focusUnit('unit-1');
get(LifecycleService).stage = LifecycleStages.Rendered;
commandService = get(ICommandService);
threadCommentModel = get(ThreadCommentModel);
});
afterEach(() => {
univer.dispose();
});
it('adds root comments and replies through real commands', async () => {
const events: CommentUpdate[] = [];
const subscription = threadCommentModel.commentUpdate$.subscribe((event) => events.push(event));
const root = createComment({ id: 'root-1', personId: 'owner' });
const reply = createComment({
id: 'reply-1',
parentId: root.id,
threadId: root.id,
personId: 'guest',
ref: '',
});
await commandService.executeCommand(AddCommentCommand.id, {
unitId: root.unitId,
subUnitId: root.subUnitId,
comment: root,
});
await commandService.executeCommand(AddCommentCommand.id, {
unitId: reply.unitId,
subUnitId: reply.subUnitId,
comment: reply,
});
subscription.unsubscribe();
const eventSummary: Array<{ type: CommentUpdate['type']; isRoot?: boolean }> = [];
for (const event of events) {
eventSummary.push({ type: event.type, isRoot: event.type === 'add' ? event.isRoot : undefined });
}
expect(threadCommentModel.getThread('unit-1', 'sheet-1', root.id)).toEqual({
unitId: 'unit-1',
subUnitId: 'sheet-1',
threadId: root.id,
root,
children: [reply],
relativeUsers: new Set(['owner', 'guest']),
});
expect(eventSummary).toEqual([
{ type: 'add', isRoot: true },
{ type: 'add', isRoot: false },
]);
});
it('resolves a thread and deletes the whole tree when deleting by reply id', async () => {
const root = createComment({ id: 'root-2' });
const reply = createComment({
id: 'reply-2',
parentId: root.id,
threadId: root.id,
ref: '',
});
const events: CommentUpdate[] = [];
const subscription = threadCommentModel.commentUpdate$.subscribe((event) => events.push(event));
await commandService.executeCommand(AddCommentCommand.id, {
unitId: root.unitId,
subUnitId: root.subUnitId,
comment: root,
});
await commandService.executeCommand(AddCommentCommand.id, {
unitId: reply.unitId,
subUnitId: reply.subUnitId,
comment: reply,
});
await commandService.executeCommand(ResolveCommentCommand.id, {
unitId: root.unitId,
subUnitId: root.subUnitId,
commentId: root.id,
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,
commentId: reply.id,
});
subscription.unsubscribe();
expect(threadCommentModel.getComment('unit-1', 'sheet-1', root.id)).toBeUndefined();
expect(threadCommentModel.getThread('unit-1', 'sheet-1', root.id)).toBeUndefined();
const resolveEvents: CommentUpdate[] = [];
const deletedCommentIds: string[] = [];
for (const event of events) {
if (event.type === 'resolve') {
resolveEvents.push(event);
}
if (event.type === 'delete') {
deletedCommentIds.push(event.payload.commentId);
}
}
expect(resolveEvents).toEqual([
{
unitId: 'unit-1',
subUnitId: 'sheet-1',
type: 'resolve',
payload: {
commentId: root.id,
resolved: true,
},
},
]);
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 () => {
const root = createComment({ id: 'root-3', attachments: ['before.png'] });
await commandService.executeCommand(AddCommentCommand.id, {
unitId: root.unitId,
subUnitId: root.subUnitId,
comment: root,
});
const result = await commandService.executeCommand(UpdateCommentCommand.id, {
unitId: root.unitId,
subUnitId: root.subUnitId,
payload: {
commentId: root.id,
text: createBody('updated text'),
attachments: ['after.png'],
updateT: '2024-01-02T00:00:00.000Z',
},
});
const updated = threadCommentModel.getComment('unit-1', 'sheet-1', root.id);
expect(result).toBe(true);
expect(updated?.text).toEqual(createBody('updated text'));
expect(updated?.attachments).toEqual(['after.png']);
expect(updated?.updated).toBe(true);
expect(updated?.updateT).toBe('2024-01-02T00:00:00.000Z');
expect(threadCommentModel.getThread('unit-1', 'sheet-1', root.id)?.root.id).toBe(root.id);
});
it('deletes a reply without removing the root thread', async () => {
const root = createComment({ id: 'root-4' });
const reply = createComment({
id: 'reply-4',
parentId: root.id,
threadId: root.id,
ref: '',
});
await commandService.executeCommand(AddCommentCommand.id, {
unitId: root.unitId,
subUnitId: root.subUnitId,
comment: root,
});
await commandService.executeCommand(AddCommentCommand.id, {
unitId: reply.unitId,
subUnitId: reply.subUnitId,
comment: reply,
});
const result = await commandService.executeCommand(DeleteCommentCommand.id, {
unitId: reply.unitId,
subUnitId: reply.subUnitId,
commentId: reply.id,
});
const thread = threadCommentModel.getThread('unit-1', 'sheet-1', root.id);
expect(result).toBe(true);
expect(thread?.root.id).toBe(root.id);
expect(thread?.children).toEqual([]);
expect(threadCommentModel.getComment('unit-1', 'sheet-1', reply.id)).toBeUndefined();
});
it('updates a comment reference through the mutation used by integrations', async () => {
const root = createComment({ id: 'root-5', ref: 'A1' });
await commandService.executeCommand(AddCommentCommand.id, {
unitId: root.unitId,
subUnitId: root.subUnitId,
comment: root,
});
const result = await commandService.executeCommand(UpdateCommentRefMutation.id, {
unitId: root.unitId,
subUnitId: root.subUnitId,
payload: {
commentId: root.id,
ref: 'B2',
},
});
expect(result).toBe(true);
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',
subUnitId: 'sheet-1',
payload: {
commentId: 'missing-comment',
text: createBody('updated'),
updateT: '2024-01-02T00:00:00.000Z',
},
})).resolves.toBe(false);
await expect(commandService.executeCommand(DeleteCommentCommand.id, {
unitId: 'unit-1',
subUnitId: 'sheet-1',
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();
});
});

View File

@@ -0,0 +1,285 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICommand } from '@univerjs/core';
import type { IThreadComment } from '../../types/interfaces/i-thread-comment';
import type { IUpdateCommentPayload } from '../mutations/comment.mutation';
import { CommandType, ICommandService } from '@univerjs/core';
import { ThreadCommentModel } from '../../models/thread-comment.model';
import { IThreadCommentDataSourceService } from '../../services/tc-datasource.service';
import {
AddCommentMutation,
DeleteCommentMutation,
ResolveCommentMutation,
UpdateCommentMutation,
} from '../mutations/comment.mutation';
type CommentWriteKind = 'resolve' | 'update';
const latestWriteVersions = new WeakMap<IThreadComment, Record<CommentWriteKind, number>>();
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;
comment: IThreadComment;
}
export const AddCommentCommand: ICommand<IAddCommentCommandParams> = {
id: 'thread-comment.command.add-comment',
type: CommandType.COMMAND,
async handler(accessor, params) {
if (!params) {
return false;
}
const commandService = accessor.get(ICommandService);
const dataSourceService = accessor.get(IThreadCommentDataSourceService);
const threadCommentModel = accessor.get(ThreadCommentModel);
const { comment: originComment } = params;
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,
params: {
...params,
comment,
},
};
if (isRoot) {
const res = await commandService.executeCommand(redo.id, redo.params);
return res;
}
return commandService.executeCommand(redo.id, redo.params, {
onlyLocal: !syncUpdateMutationToColla,
});
},
};
export interface IUpdateCommentCommandParams {
unitId: string;
subUnitId: string;
payload: IUpdateCommentPayload;
}
export const UpdateCommentCommand: ICommand<IUpdateCommentCommandParams> = {
id: 'thread-comment.command.update-comment',
type: CommandType.COMMAND,
async handler(accessor, params) {
if (!params) {
return false;
}
const { unitId, subUnitId, payload } = params;
const commandService = accessor.get(ICommandService);
const threadCommentModel = accessor.get(ThreadCommentModel);
const dataSourceService = accessor.get(IThreadCommentDataSourceService);
const syncUpdateMutationToColla = dataSourceService.syncUpdateMutationToColla;
const current = threadCommentModel.getComment(
unitId,
subUnitId,
payload.commentId
);
if (!current) {
return false;
}
const { children, ...currentComment } = current;
const writeVersion = startCommentWrite(current, 'update');
const success = await dataSourceService.updateComment({
...currentComment,
...payload,
});
if (
!success
|| threadCommentModel.getComment(unitId, subUnitId, payload.commentId) !== current
|| !isLatestCommentWrite(current, 'update', writeVersion)
) {
return false;
}
const redo = {
id: UpdateCommentMutation.id,
params,
};
return commandService.executeCommand(redo.id, redo.params, { onlyLocal: !syncUpdateMutationToColla });
},
};
export interface IResolveCommentCommandParams {
unitId: string;
subUnitId: string;
commentId: string;
resolved: boolean;
}
export const ResolveCommentCommand: ICommand<IResolveCommentCommandParams> = {
id: 'thread-comment.command.resolve-comment',
type: CommandType.COMMAND,
async handler(accessor, params) {
if (!params) {
return false;
}
const { unitId, subUnitId, resolved, commentId } = params;
const dataSourceService = accessor.get(IThreadCommentDataSourceService);
const threadCommentModel = accessor.get(ThreadCommentModel);
const currentComment = threadCommentModel.getComment(unitId, subUnitId, commentId);
const syncUpdateMutationToColla = dataSourceService.syncUpdateMutationToColla;
if (!currentComment) {
return false;
}
const writeVersion = startCommentWrite(currentComment, 'resolve');
const success = await dataSourceService.resolveComment({
...currentComment,
resolved,
});
if (
!success
|| threadCommentModel.getComment(unitId, subUnitId, commentId) !== currentComment
|| !isLatestCommentWrite(currentComment, 'resolve', writeVersion)
) {
return false;
}
const commandService = accessor.get(ICommandService);
return commandService.executeCommand(
ResolveCommentMutation.id,
params,
{ onlyLocal: !syncUpdateMutationToColla }
);
},
};
export interface IDeleteCommentCommandParams {
unitId: string;
subUnitId: string;
commentId: string;
}
/**
* Delete Reply
*/
export const DeleteCommentCommand: ICommand<IDeleteCommentCommandParams> = {
id: 'thread-comment.command.delete-comment',
type: CommandType.COMMAND,
async handler(accessor, params) {
if (!params) {
return false;
}
const threadCommentModel = accessor.get(ThreadCommentModel);
const dataSourceService = accessor.get(IThreadCommentDataSourceService);
const commandService = accessor.get(ICommandService);
const { unitId, subUnitId, commentId } = params;
const syncUpdateMutationToColla = dataSourceService.syncUpdateMutationToColla;
const comment = threadCommentModel.getComment(unitId, subUnitId, commentId);
if (!comment) {
return false;
}
if (!(await dataSourceService.deleteComment(unitId, subUnitId, comment.threadId, commentId))) {
return false;
}
const redo = {
id: DeleteCommentMutation.id,
params,
};
return commandService.executeCommand(redo.id, redo.params, { onlyLocal: !syncUpdateMutationToColla });
},
};
export interface IDeleteCommentTreeCommandParams {
unitId: string;
subUnitId: string;
commentId: string;
}
export const DeleteCommentTreeCommand: ICommand<IDeleteCommentCommandParams> = {
id: 'thread-comment.command.delete-comment-tree',
type: CommandType.COMMAND,
async handler(accessor, params) {
if (!params) {
return false;
}
const threadCommentModel = accessor.get(ThreadCommentModel);
const commandService = accessor.get(ICommandService);
const dataSourceService = accessor.get(IThreadCommentDataSourceService);
const { unitId, subUnitId, commentId } = params;
const commentWithChildren = threadCommentModel.getCommentWithChildren(unitId, subUnitId, commentId);
if (!commentWithChildren) {
return false;
}
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;
}
return await commandService.executeCommand(DeleteCommentMutation.id, {
unitId,
subUnitId,
commentId: commentWithChildren.root.id,
});
},
};

View File

@@ -0,0 +1,133 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { ICommand, IDocumentBody } from '@univerjs/core';
import type { IThreadComment } from '../../types/interfaces/i-thread-comment';
import { CommandType } from '@univerjs/core';
import { ThreadCommentModel } from '../../models/thread-comment.model';
export interface IAddCommentMutationParams {
unitId: string;
subUnitId: string;
comment: IThreadComment;
sync?: boolean;
}
export const AddCommentMutation: ICommand<IAddCommentMutationParams> = {
id: 'thread-comment.mutation.add-comment',
type: CommandType.MUTATION,
handler(accessor, params, options) {
if (!params) {
return false;
}
const threadCommentModel = accessor.get(ThreadCommentModel);
const { unitId, subUnitId, comment, sync } = params;
const shouldSync = sync || ((options?.fromChangeset) && !comment.parentId);
return threadCommentModel.addComment(unitId, subUnitId, comment, shouldSync);
},
};
export interface IUpdateCommentPayload {
commentId: string;
text: IDocumentBody;
attachments?: string[];
updated?: boolean;
updateT?: string;
}
export interface IUpdateCommentMutationParams {
unitId: string;
subUnitId: string;
payload: IUpdateCommentPayload;
silent?: boolean;
}
export const UpdateCommentMutation: ICommand<IUpdateCommentMutationParams> = {
id: 'thread-comment.mutation.update-comment',
type: CommandType.MUTATION,
handler(accessor, params) {
if (!params) {
return false;
}
const threadCommentModel = accessor.get(ThreadCommentModel);
const { unitId, subUnitId, payload, silent } = params;
return threadCommentModel.updateComment(unitId, subUnitId, payload, silent);
},
};
export interface IUpdateCommentRefPayload {
commentId: string;
ref: string;
}
export interface IUpdateCommentRefMutationParams {
unitId: string;
subUnitId: string;
payload: IUpdateCommentRefPayload;
silent?: boolean;
}
export const UpdateCommentRefMutation: ICommand<IUpdateCommentRefMutationParams> = {
id: 'thread-comment.mutation.update-comment-ref',
type: CommandType.MUTATION,
handler(accessor, params) {
if (!params) {
return false;
}
const threadCommentModel = accessor.get(ThreadCommentModel);
const { unitId, subUnitId, payload, silent } = params;
return threadCommentModel.updateCommentRef(unitId, subUnitId, payload, silent);
},
};
export interface IResolveCommentMutationParams {
unitId: string;
subUnitId: string;
commentId: string;
resolved: boolean;
}
export const ResolveCommentMutation: ICommand<IResolveCommentMutationParams> = {
id: 'thread-comment.mutation.resolve-comment',
type: CommandType.MUTATION,
handler(accessor, params) {
if (!params) {
return false;
}
const threadCommentModel = accessor.get(ThreadCommentModel);
const { unitId, subUnitId, resolved, commentId } = params;
return threadCommentModel.resolveComment(unitId, subUnitId, commentId, resolved);
},
};
export interface IDeleteCommentMutationParams {
unitId: string;
subUnitId: string;
commentId: string;
}
export const DeleteCommentMutation: ICommand<IDeleteCommentMutationParams> = {
id: 'thread-comment.mutation.delete-comment',
type: CommandType.MUTATION,
handler(accessor, params) {
if (!params) {
return false;
}
const threadCommentModel = accessor.get(ThreadCommentModel);
const { unitId, subUnitId, commentId } = params;
return threadCommentModel.deleteComment(unitId, subUnitId, commentId);
},
};

View File

@@ -0,0 +1,116 @@
/**
* 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 { IResourceHook, UniverInstanceType } from '@univerjs/core';
import type { ThreadCommentModel } from '../models/thread-comment.model';
import type { IThreadCommentDataSourceService } from '../services/tc-datasource.service';
import type { IThreadComment } from '../types/interfaces/i-thread-comment';
import { isThreadCommentDocumentBody } from '../services/thread-comment-api.service';
export type UnitThreadCommentJSON = Record<string, IThreadComment[]>;
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<string, unknown>;
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 function createThreadCommentResourceHook(
threadCommentModel: ThreadCommentModel,
threadCommentDataSourceService: IThreadCommentDataSourceService,
pluginName: IResourceHook['pluginName'],
businesses: UniverInstanceType[]
): IResourceHook<UnitThreadCommentJSON> {
return {
pluginName,
businesses,
toJson: (unitID) => {
const map = threadCommentModel.getUnit(unitID);
const resultMap: UnitThreadCommentJSON = {};
map.forEach((info) => {
const subUnitComments = resultMap[info.subUnitId] ?? [];
subUnitComments.push({
...info.root,
children: info.children,
});
resultMap[info.subUnitId] = subUnitComments;
});
return JSON.stringify(threadCommentDataSourceService.saveToSnapshot(resultMap, unitID));
},
parseJson: (json) => {
if (!json) {
return {};
}
try {
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 {};
}
},
onUnLoad: (unitID) => {
threadCommentModel.deleteUnit(unitID);
},
onLoad: (unitID, value) => {
Object.keys(value).forEach((subunitId) => {
const commentList = value[subunitId];
commentList.forEach((comment: IThreadComment) => {
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 }));
threadCommentModel.addComment(unitID, subunitId, {
...comment,
unitId: unitID,
subUnitId: subunitId,
children,
});
});
threadCommentModel.syncThreadComments(unitID, subunitId, commentList.map((i) => i.threadId));
});
},
};
}

View File

@@ -0,0 +1,21 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { dateKit } from '@univerjs/core';
export function getDT(date?: Date) {
return dateKit(date).format('YYYY/MM/DD HH:mm');
}

View File

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

View File

@@ -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 {}
}

View File

@@ -0,0 +1,221 @@
/**
* 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 drawing, page element, page position, or Base record anchor.
* For a sheet-cell comment, use `FRange.addCommentAsync`; for document text, use `FDocumentTextRange.createCommentAsync`.
* @param options Comment content, owner IDs, stable anchor, and optional caller-controlled IDs.
* @returns {Promise<boolean>} `true` when the command succeeds; otherwise, `false`.
* @throws {TypeError} If the content is empty or the anchor is invalid.
* @example
* ```ts
* const workbook = univerAPI.getActiveWorkbook();
* if (!workbook) {
* throw new Error('No active workbook');
* }
* const sheet = workbook.getActiveSheet();
* const image = sheet.getImages()[0];
* if (!image) {
* throw new Error('No image to comment on');
* }
*
* await univerAPI.createCommentAsync({
* unitId: workbook.getId(),
* subUnitId: sheet.getSheetId(),
* anchor: {
* kind: univerAPI.Enum.ThreadCommentAnchorKind.SHEET_DRAWING,
* elementId: image.getId(),
* },
* content: 'Verify this value.',
* });
* ```
*/
createCommentAsync(options: ThreadComment.ICreateThreadCommentOptions): Promise<boolean>;
/**
* Adds a reply to an existing root thread.
* @param options Reply content and the owning unit, subunit, and thread IDs.
* @returns {Promise<boolean>} `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<boolean>;
/**
* 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 {Promise<boolean>} `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<boolean>;
/**
* 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 {Promise<boolean>} `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<boolean>;
/**
* 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 {Promise<boolean>} `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<boolean>;
/**
* 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 workbook = univerAPI.getActiveWorkbook();
* if (!workbook) {
* throw new Error('No active workbook');
* }
* const openAgentReviews = univerAPI.getComments({
* unitIds: [workbook.getId()],
* authorIds: ['agent-reviewer'],
* anchorKinds: [univerAPI.Enum.ThreadCommentAnchorKind.SHEET_DRAWING],
* 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 {Promise<ThreadComment.IFacadeThreadCommentInfo[]>} A promise resolving to the synchronized matching threads.
* @example
* ```ts
* const workbook = univerAPI.getActiveWorkbook();
* if (!workbook) {
* throw new Error('No active workbook');
* }
* const comments = await univerAPI.listCommentsAsync({
* unitIds: [workbook.getId()],
* anchorKinds: [univerAPI.Enum.ThreadCommentAnchorKind.SHEET_DRAWING],
* resolved: false,
* });
* comments.forEach(({ root, children, anchorKind, anchor }) => {
* console.log(root.id, children.length, anchorKind, anchor);
* });
* ```
*/
listCommentsAsync(query?: ThreadComment.IThreadCommentQuery): Promise<ThreadComment.IFacadeThreadCommentInfo[]>;
}
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<boolean> {
return this._threadCommentService.createCommentAsync(options);
}
/** @inheritdoc */
override replyCommentAsync(options: ThreadComment.IReplyThreadCommentOptions): Promise<boolean> {
return this._threadCommentService.replyCommentAsync(options);
}
/** @inheritdoc */
override updateCommentAsync(options: ThreadComment.IUpdateThreadCommentOptions): Promise<boolean> {
return this._threadCommentService.updateCommentAsync(options);
}
/** @inheritdoc */
override deleteCommentAsync(options: ThreadComment.IDeleteThreadCommentOptions): Promise<boolean> {
return this._threadCommentService.deleteCommentAsync(options);
}
/** @inheritdoc */
override resolveCommentAsync(options: ThreadComment.IResolveThreadCommentOptions): Promise<boolean> {
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<ThreadComment.IFacadeThreadCommentInfo[]> {
return this._threadCommentService.listCommentsAsync(query);
}
}
FUniver.extend(FUniverThreadCommentMixin);
declare module '@univerjs/core/facade' {
interface FUniver extends IFUniverThreadCommentMixin {}
}

View File

@@ -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';

View File

@@ -0,0 +1,79 @@
/**
* 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 {
AddCommentCommand,
DeleteCommentCommand,
DeleteCommentTreeCommand,
ResolveCommentCommand,
UpdateCommentCommand,
} from './commands/commands/comment.command';
export type {
IAddCommentCommandParams,
IDeleteCommentCommandParams,
IDeleteCommentTreeCommandParams,
IResolveCommentCommandParams,
IUpdateCommentCommandParams,
} from './commands/commands/comment.command';
export {
AddCommentMutation,
DeleteCommentMutation,
ResolveCommentMutation,
UpdateCommentMutation,
UpdateCommentRefMutation,
} from './commands/mutations/comment.mutation';
export type {
IAddCommentMutationParams,
IDeleteCommentMutationParams,
IResolveCommentMutationParams,
IUpdateCommentMutationParams,
IUpdateCommentPayload,
IUpdateCommentRefMutationParams,
} from './commands/mutations/comment.mutation';
export {
createThreadCommentResourceHook,
} from './common/tc-resource';
export { getDT } from './common/utils';
export type { IUniverThreadCommentConfig } from './config/config';
export { ThreadCommentModel } 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';

View File

@@ -0,0 +1,605 @@
/**
* 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, Injector, IWorkbookData } from '@univerjs/core';
import type { IThreadComment } from '../../types/interfaces/i-thread-comment';
import type { CommentUpdate } from '../thread-comment.model';
import {
awaitTime,
IUniverInstanceService,
LifecycleService,
LifecycleStages,
LocaleType,
Univer,
UniverInstanceType,
} from '@univerjs/core';
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 {
return {
id: 'unit-1',
appVersion: '3.0.0-alpha',
locale: LocaleType.EN_US,
name: '',
sheetOrder: ['sheet-1', 'sheet-2'],
styles: {},
sheets: {
'sheet-1': {
id: 'sheet-1',
name: 'Sheet1',
cellData: {},
},
'sheet-2': {
id: 'sheet-2',
name: 'Sheet2',
cellData: {},
},
},
};
}
function createBody(text: string): IDocumentBody {
return {
dataStream: `${text}\r\n`,
};
}
function createComment(overrides: Partial<IThreadComment> = {}): IThreadComment {
const id = overrides.id ?? 'comment-1';
return {
id,
threadId: overrides.threadId ?? overrides.parentId ?? id,
ref: overrides.ref ?? 'A1',
dT: overrides.dT ?? '2024-01-01T00:00:00.000Z',
personId: overrides.personId ?? 'user-1',
text: overrides.text ?? createBody(id),
unitId: overrides.unitId ?? 'unit-1',
subUnitId: overrides.subUnitId ?? 'sheet-1',
attachments: overrides.attachments,
children: overrides.children,
mentions: overrides.mentions,
parentId: overrides.parentId,
resolved: overrides.resolved,
updateT: overrides.updateT,
updated: overrides.updated,
};
}
describe('ThreadCommentModel', () => {
let univer: Univer;
let get: Injector['get'];
let lifecycleService: LifecycleService;
let threadCommentModel: ThreadCommentModel;
let dataSourceService: IThreadCommentDataSourceService;
beforeEach(() => {
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);
get(IUniverInstanceService).focusUnit('unit-1');
lifecycleService = get(LifecycleService);
threadCommentModel = get(ThreadCommentModel);
dataSourceService = get(IThreadCommentDataSourceService);
});
afterEach(() => {
univer.dispose();
});
it('queues sync requests before rendered and applies merged updates after lifecycle is rendered', async () => {
const root = createComment({ id: 'root-1', ref: 'A1', text: createBody('before sync') });
threadCommentModel.addComment('unit-1', 'sheet-1', root);
const listComments = vi.fn(async () => [{
...root,
text: createBody('after sync'),
resolved: true,
ref: 'SHOULD_NOT_REPLACE_EXISTING_REF',
}]);
dataSourceService.dataSource = {
addComment: vi.fn(),
updateComment: vi.fn(),
resolveComment: vi.fn(),
deleteComment: vi.fn(),
listComments,
saveCommentToSnapshot: vi.fn(),
};
const events: CommentUpdate[] = [];
const subscription = threadCommentModel.commentUpdate$.subscribe((event) => events.push(event));
await threadCommentModel.syncThreadComments('unit-1', 'sheet-1', ['root-1']);
await threadCommentModel.syncThreadComments('unit-1', 'sheet-1', ['root-1']);
expect(listComments).not.toHaveBeenCalled();
lifecycleService.stage = LifecycleStages.Rendered;
await awaitTime(0);
await awaitTime(0);
subscription.unsubscribe();
expect(listComments).toHaveBeenCalledTimes(1);
expect(listComments).toHaveBeenCalledWith('unit-1', 'sheet-1', ['root-1']);
expect(threadCommentModel.getComment('unit-1', 'sheet-1', 'root-1')).toMatchObject({
id: 'root-1',
ref: 'A1',
resolved: true,
text: createBody('after sync'),
});
expect(events).toEqual([
{
unitId: 'unit-1',
subUnitId: 'sheet-1',
type: 'syncUpdate',
payload: expect.objectContaining({
id: 'root-1',
ref: 'A1',
resolved: true,
}),
},
{
unitId: 'unit-1',
subUnitId: 'sheet-1',
type: 'resolve',
payload: {
commentId: 'root-1',
resolved: true,
},
},
]);
});
it('removes threads that are absent from sync results', async () => {
lifecycleService.stage = LifecycleStages.Rendered;
const keepRoot = createComment({ id: 'keep-root' });
const removeRoot = createComment({ id: 'remove-root', subUnitId: 'sheet-2' });
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(),
updateComment: vi.fn(),
resolveComment: vi.fn(),
deleteComment: vi.fn(),
listComments: vi.fn(async () => [keepRoot]),
saveCommentToSnapshot: vi.fn(),
};
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 () => {
lifecycleService.stage = LifecycleStages.Rendered;
const root = createComment({ id: 'synced-root', threadId: 'synced-thread', ref: '' });
const reply = createComment({
id: 'synced-reply',
threadId: 'synced-thread',
parentId: 'synced-root',
ref: '',
});
dataSourceService.dataSource = {
addComment: vi.fn(),
updateComment: vi.fn(),
resolveComment: vi.fn(),
deleteComment: vi.fn(),
listComments: vi.fn(async () => [{ ...root, children: [reply] }]),
saveCommentToSnapshot: vi.fn(),
};
await threadCommentModel.syncThreadComments('unit-1', 'sheet-1', ['synced-thread']);
expect(threadCommentModel.getThread('unit-1', 'sheet-1', 'synced-thread')).toEqual({
unitId: 'unit-1',
subUnitId: 'sheet-1',
threadId: 'synced-thread',
root,
children: [reply],
relativeUsers: new Set(['user-1']),
});
});
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<IThreadComment[]>((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<IThreadComment[]>((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<IThreadComment[]>((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;
const root = createComment({ id: 'root-2', personId: 'owner' });
const reply = createComment({
id: 'reply-2',
parentId: 'root-2',
threadId: 'root-2',
personId: 'guest',
ref: '',
});
const otherRoot = createComment({ id: 'root-3', subUnitId: 'sheet-2' });
threadCommentModel.addComment('unit-1', 'sheet-1', root);
threadCommentModel.addComment('unit-1', 'sheet-1', reply);
threadCommentModel.addComment('unit-1', 'sheet-2', otherRoot);
const events: CommentUpdate[] = [];
const subscription = threadCommentModel.commentUpdate$.subscribe((event) => events.push(event));
expect(threadCommentModel.updateCommentRef('unit-1', 'sheet-1', {
commentId: 'root-2',
ref: 'C5',
})).toBe(true);
expect(threadCommentModel.updateCommentRef('unit-1', 'sheet-1', {
commentId: 'missing',
ref: 'D6',
})).toBe(false);
expect(threadCommentModel.getCommentWithChildren('unit-1', 'sheet-1', 'reply-2')).toEqual({
unitId: 'unit-1',
subUnitId: 'sheet-1',
threadId: 'root-2',
root: {
...root,
ref: 'C5',
},
children: [reply],
relativeUsers: new Set(['owner', 'guest']),
});
expect(threadCommentModel.getAll()).toEqual([
{
unitId: 'unit-1',
threads: [
{
unitId: 'unit-1',
subUnitId: 'sheet-1',
threadId: 'root-2',
root: {
...root,
ref: 'C5',
},
children: [reply],
relativeUsers: new Set(['owner', 'guest']),
},
{
unitId: 'unit-1',
subUnitId: 'sheet-2',
threadId: 'root-3',
root: otherRoot,
children: [],
relativeUsers: new Set(['user-1']),
},
],
},
]);
threadCommentModel.deleteUnit('unit-1');
subscription.unsubscribe();
expect(threadCommentModel.getUnit('unit-1')).toEqual([]);
expect(events.filter((event) => event.type === 'updateRef')).toEqual([
{
unitId: 'unit-1',
subUnitId: 'sheet-1',
type: 'updateRef',
payload: {
commentId: 'root-2',
ref: 'C5',
},
threadId: 'root-2',
silent: undefined,
},
]);
expect(events.filter((event) => event.type === 'delete').map((event) => event.payload.commentId)).toEqual([
'root-2',
'reply-2',
'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}`)
);
});
});

View File

@@ -0,0 +1,643 @@
/**
* 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 { 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, 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;
subUnitId: string;
silent?: boolean;
} & ({
type: 'add';
payload: IThreadComment;
isRoot: boolean;
} | {
type: 'update';
payload: IUpdateCommentPayload;
} | {
type: 'delete';
payload: {
commentId: string;
isRoot: boolean;
comment: IThreadComment;
};
} | {
type: 'updateRef';
payload: IUpdateCommentRefPayload;
threadId: string;
} | {
type: 'resolve';
payload: {
commentId: string;
resolved: boolean;
};
} | {
type: 'syncUpdate';
payload: IThreadComment;
});
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<string>;
}
export class ThreadCommentModel extends Disposable {
private _commentsMap: Map<string, Map<string, Map<string, IThreadComment>>> = new Map();
private _threadMap: Map<string, Map<string, Map<string, IThreadComment>>> = new Map();
private _syncVersions = new Map<string, number>();
private _nextSyncVersion = 0;
private _commentUpdate$ = new Subject<CommentUpdate>();
commentUpdate$ = this._commentUpdate$.asObservable();
private _tasks: { unitId: string; subUnitId: string; threadIds: string[] }[] = [];
constructor(
@Inject(IThreadCommentDataSourceService) private readonly _dataSourceService: IThreadCommentDataSourceService,
@Inject(LifecycleService) private readonly _lifecycleService: LifecycleService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService
) {
super();
this.disposeWithMe(() => {
this._commentUpdate$.complete();
});
this.disposeWithMe(this._lifecycleService.lifecycle$.subscribe((stage) => {
const taskMap = new Map<string, Map<string, Set<string>>>();
if (stage === LifecycleStages.Rendered) {
this._tasks.forEach(({ unitId, subUnitId, threadIds }) => {
let unitMap = taskMap.get(unitId);
if (!unitMap) {
unitMap = new Map();
taskMap.set(unitId, unitMap);
}
let subUnitMap = unitMap.get(subUnitId);
if (!subUnitMap) {
subUnitMap = new Set();
unitMap.set(subUnitId, subUnitMap);
}
for (const threadId of threadIds) {
subUnitMap.add(threadId);
}
});
this._tasks = [];
taskMap.forEach((subUnitMap, unitId) => {
subUnitMap.forEach((threadIds, subUnitId) => {
this.syncThreadComments(unitId, subUnitId, Array.from(threadIds));
});
});
}
}));
}
private _ensureCommentMap(unitId: string, subUnitId: string) {
let unitMap = this._commentsMap.get(unitId);
if (!unitMap) {
unitMap = new Map();
this._commentsMap.set(unitId, unitMap);
}
let subUnitMap = unitMap.get(subUnitId);
if (!subUnitMap) {
subUnitMap = new Map();
unitMap.set(subUnitId, subUnitMap);
}
return subUnitMap;
}
ensureMap(unitId: string, subUnitId: string) {
return this._ensureCommentMap(unitId, subUnitId);
}
private _ensureThreadMap(unitId: string, subUnitId: string) {
let unitMap = this._threadMap.get(unitId);
if (!unitMap) {
unitMap = new Map();
this._threadMap.set(unitId, unitMap);
}
let subUnitMap = unitMap.get(subUnitId);
if (!subUnitMap) {
subUnitMap = new Map();
unitMap.set(subUnitId, subUnitMap);
}
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) {
if ('ref' in comment && typeof comment.ref === 'string') {
this.addComment(unitId, subUnitId, { ...comment, ref: comment.ref });
}
return;
}
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);
}
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: '',
});
});
this._commentUpdate$.next({
unitId,
subUnitId,
type: 'syncUpdate',
payload: newComment,
});
if (Boolean(comment.resolved) !== Boolean(currentComment.resolved)) {
this._commentUpdate$.next({
unitId,
subUnitId,
type: 'resolve',
payload: {
commentId: comment.id,
resolved: Boolean(comment.resolved),
},
});
}
}
async syncThreadComments(unitId: string, subUnitId: string, threadIds: string[]) {
if (this._lifecycleService.stage < LifecycleStages.Rendered) {
this._tasks.push({ unitId, subUnitId, threadIds });
return;
}
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.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;
}
this._replaceComment(unitId, subUnitId, comment);
deleteThreads.delete(comment.threadId);
});
deleteThreads.forEach((id) => {
threadMap.delete(id);
commentMap.forEach((comment, commentId) => {
if (comment.threadId === id) {
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,
parentId: parentId === origin.id ? undefined : parentId,
};
if (!comment.threadId) {
comment.threadId = comment.parentId || comment.id;
}
this._invalidateSync(unitId, subUnitId, comment.threadId);
const addCommentItem = (item: IThreadComment) => {
commentMap.set(item.id, item);
this._commentUpdate$.next({
unitId,
subUnitId,
type: 'add',
payload: item,
isRoot: !item.parentId,
});
};
addCommentItem(comment);
const threadMap = this._ensureThreadMap(unitId, subUnitId);
if (!comment.parentId) {
threadMap.set(comment.threadId, comment);
for (const child of children) {
if (
child.id === comment.id
|| child.threadId !== comment.threadId
|| commentMap.has(child.id)
) {
continue;
}
addCommentItem({
...child,
parentId: comment.id,
ref: '',
});
}
}
if (shouldSync) {
this.syncThreadComments(unitId, subUnitId, [comment.threadId]);
}
return true;
}
updateComment(unitId: string, subUnitId: string, payload: IUpdateCommentPayload, silent?: boolean) {
const commentMap = this._ensureCommentMap(unitId, subUnitId);
const oldComment = commentMap.get(payload.commentId);
if (!oldComment) {
return false;
}
this._invalidateSync(unitId, subUnitId, oldComment.threadId);
oldComment.updated = true;
oldComment.text = payload.text;
oldComment.attachments = payload.attachments;
oldComment.updateT = payload.updateT;
this._commentUpdate$.next({
unitId,
subUnitId,
type: 'update',
payload,
silent,
});
return true;
}
updateCommentRef(unitId: string, subUnitId: string, payload: IUpdateCommentRefPayload, silent?: boolean) {
const commentMap = this._ensureCommentMap(unitId, subUnitId);
const oldComment = commentMap.get(payload.commentId);
if (!oldComment) {
return false;
}
this._invalidateSync(unitId, subUnitId, oldComment.threadId);
oldComment.ref = payload.ref;
this._commentUpdate$.next({
unitId,
subUnitId,
type: 'updateRef',
payload,
silent,
threadId: oldComment.threadId,
});
return true;
}
resolveComment(unitId: string, subUnitId: string, commentId: string, resolved: boolean) {
const commentMap = this._ensureCommentMap(unitId, subUnitId);
const oldComment = commentMap.get(commentId);
if (!oldComment) {
return false;
}
this._invalidateSync(unitId, subUnitId, oldComment.threadId);
oldComment.resolved = resolved;
this._commentUpdate$.next({
unitId,
subUnitId,
type: 'resolve',
payload: {
commentId,
resolved,
},
});
return true;
}
getComment(unitId: string, subUnitId: string, commentId: string) {
const commentMap = this._ensureCommentMap(unitId, subUnitId);
return commentMap.get(commentId);
}
getRootComment(unitId: string, subUnitId: string, threadId: string) {
const threadMap = this._ensureThreadMap(unitId, subUnitId);
return threadMap.get(threadId);
}
getThread(unitId: string, subUnitId: string, threadId: string): Nullable<IThreadInfo> {
const commentMap = this._ensureCommentMap(unitId, subUnitId);
const comments = Array.from(commentMap.values()).filter((comment) => comment.threadId === threadId);
let root: IThreadComment | undefined;
const children: IThreadComment[] = [];
const relativeUsers = new Set<string>();
for (const comment of comments) {
if (!comment.parentId) {
root = comment;
} else {
children.push(comment);
}
relativeUsers.add(comment.personId);
}
if (!root) {
return undefined;
}
return {
root,
children,
relativeUsers,
unitId,
subUnitId,
threadId,
};
}
getCommentWithChildren(unitId: string, subUnitId: string, commentId: string) {
const comment = this.getComment(unitId, subUnitId, commentId);
if (!comment) {
return undefined;
}
return this.getThread(unitId, subUnitId, comment.threadId);
}
private _deleteComment(unitId: string, subUnitId: string, commentId: string) {
const commentMap = this._ensureCommentMap(unitId, subUnitId);
const current = commentMap.get(commentId);
if (!current) {
return;
}
this._invalidateSync(unitId, subUnitId, current.threadId);
commentMap.delete(commentId);
this._commentUpdate$.next({
unitId,
subUnitId,
type: 'delete',
payload: {
commentId,
isRoot: !current.parentId,
comment: current,
},
});
}
deleteThread(unitId: string, subUnitId: string, threadId: string) {
this._invalidateSync(unitId, subUnitId, threadId);
const threadMap = this._ensureThreadMap(unitId, subUnitId);
threadMap.delete(threadId);
const commentMap = this._ensureCommentMap(unitId, subUnitId);
commentMap.forEach((comment) => {
if (comment.threadId === threadId) {
this._deleteComment(unitId, subUnitId, comment.id);
}
});
}
deleteComment(unitId: string, subUnitId: string, commentId: string) {
const commentMap = this._ensureCommentMap(unitId, subUnitId);
const current = commentMap.get(commentId);
if (!current) {
return true;
}
if (current.parentId) {
this._deleteComment(unitId, subUnitId, commentId);
} else {
this.deleteThread(unitId, subUnitId, current.threadId);
}
return true;
}
deleteUnit(unitId: string) {
const unitMap = this._commentsMap.get(unitId);
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._commentsMap.get(unitId);
if (!unitMap) {
return [];
}
const threads: IThreadInfo[] = [];
unitMap.forEach((commentMap, subUnitId) => {
const threadInfos = new Map<string, Omit<IThreadInfo, 'root'> & { 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 });
}
});
});
return threads;
}
getAll() {
const all: { unitId: string; threads: IThreadInfo[] }[] = [];
this._commentsMap.forEach((unitMap, unitId) => {
all.push({
unitId,
threads: this.getUnit(unitId),
});
});
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;
});
});
}
}

View File

@@ -0,0 +1,99 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Dependency } from '@univerjs/core';
import type { IUniverThreadCommentConfig } from './config/config';
import {
ICommandService,
IConfigService,
Inject,
Injector,
merge,
mergeOverrideWithDependencies,
Plugin,
UniverInstanceType,
} from '@univerjs/core';
import pkg from '../package.json';
import {
AddCommentCommand,
DeleteCommentCommand,
DeleteCommentTreeCommand,
ResolveCommentCommand,
UpdateCommentCommand,
} from './commands/commands/comment.command';
import {
AddCommentMutation,
DeleteCommentMutation,
ResolveCommentMutation,
UpdateCommentMutation,
UpdateCommentRefMutation,
} from './commands/mutations/comment.mutation';
import { defaultPluginConfig, THREAD_COMMENT_PLUGIN_CONFIG_KEY } from './config/config';
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 {
static override pluginName = TC_PLUGIN_NAME;
static override packageName = pkg.name;
static override version = pkg.version;
static override type = UniverInstanceType.UNIVER_UNKNOWN;
constructor(
private readonly _config: Partial<IUniverThreadCommentConfig> = defaultPluginConfig,
@Inject(Injector) protected _injector: Injector,
@ICommandService protected _commandService: ICommandService,
@IConfigService private readonly _configService: IConfigService
) {
super();
// Manage the plugin configuration.
const { ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
this._configService.setConfig(THREAD_COMMENT_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
(mergeOverrideWithDependencies([
[IThreadCommentDataSourceService, { useClass: ThreadCommentDataSourceService }],
[ThreadCommentModel],
[ThreadCommentFacadeService],
], this._config?.overrides) as Dependency[]).forEach(
(d) => {
this._injector.add(d);
}
);
[
AddCommentCommand,
UpdateCommentCommand,
DeleteCommentCommand,
ResolveCommentCommand,
DeleteCommentTreeCommand,
AddCommentMutation,
UpdateCommentMutation,
UpdateCommentRefMutation,
DeleteCommentMutation,
ResolveCommentMutation,
].forEach((command) => {
this._commandService.registerCommand(command);
});
}
}

View File

@@ -0,0 +1,271 @@
/**
* 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 { IThreadComment } from '../../types/interfaces/i-thread-comment';
import type { IThreadCommentDataSource } from '../tc-datasource.service';
import { Injector } from '@univerjs/core';
import { describe, expect, it, vi } from 'vitest';
import { IThreadCommentDataSourceService, ThreadCommentDataSourceService } from '../tc-datasource.service';
function createBody(text: string): IDocumentBody {
return {
dataStream: `${text}\r\n`,
};
}
function createComment(overrides: Partial<IThreadComment> = {}): IThreadComment {
const id = overrides.id ?? 'comment-1';
return {
id,
threadId: overrides.threadId ?? overrides.parentId ?? id,
ref: overrides.ref ?? 'A1',
dT: overrides.dT ?? '2024-01-01T00:00:00.000Z',
personId: overrides.personId ?? 'user-1',
text: overrides.text ?? createBody(id),
unitId: overrides.unitId ?? 'unit-1',
subUnitId: overrides.subUnitId ?? 'sheet-1',
attachments: overrides.attachments,
children: overrides.children,
mentions: overrides.mentions,
parentId: overrides.parentId,
resolved: overrides.resolved,
updateT: overrides.updateT,
updated: overrides.updated,
};
}
function createService(): IThreadCommentDataSourceService {
const injector = new Injector();
injector.add([IThreadCommentDataSourceService, { useClass: ThreadCommentDataSourceService }]);
return injector.get(IThreadCommentDataSourceService);
}
describe('ThreadCommentDataSourceService', () => {
it('falls back to local defaults when no external data source is configured', async () => {
const service = createService();
const comment = createComment({ threadId: undefined });
await expect(service.addComment({ ...comment, threadId: undefined as unknown as string })).resolves.toEqual({
...comment,
threadId: comment.id,
});
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);
const snapshot = service.saveToSnapshot({
'sheet-1': [comment],
}, 'unit-1');
expect(snapshot).toEqual({
'sheet-1': [comment],
});
});
it('normalizes an empty threadId to the comment id when no external data source is configured', async () => {
const service = createService();
const comment = createComment({ id: 'root-1', threadId: '' });
await expect(service.addComment(comment)).resolves.toEqual({
...comment,
threadId: 'root-1',
});
});
it('delegates CRUD, query and snapshot behavior to the configured data source', async () => {
const service = createService();
const comment = createComment({ id: 'root-1' });
const listRequests: Array<{ unitId: string; subUnitId: string; threadIds: string[] }> = [];
const savedSnapshots: IThreadComment[] = [];
const dataSource: IThreadCommentDataSource = {
async addComment(input) {
return { ...input, threadId: 'server-thread' };
},
async updateComment() {
return false;
},
async resolveComment() {
return false;
},
async deleteComment() {
return false;
},
async listComments(unitId, subUnitId, threadIds) {
listRequests.push({ unitId, subUnitId, threadIds });
return [{
...comment,
children: [createComment({ id: 'reply-1', parentId: 'root-1', threadId: 'root-1', ref: '' })],
}];
},
saveCommentToSnapshot(input) {
savedSnapshots.push(input);
return {
id: input.id,
threadId: input.threadId,
ref: input.ref,
personId: input.personId,
};
},
};
service.dataSource = dataSource;
await expect(service.addComment(comment)).resolves.toEqual({
...comment,
threadId: 'server-thread',
});
await expect(service.updateComment(comment)).resolves.toBe(false);
await expect(service.resolveComment(comment)).resolves.toBe(false);
await expect(service.deleteComment('unit-1', 'sheet-1', 'root-1', 'root-1')).resolves.toBe(false);
await expect(service.getThreadComment('unit-1', 'sheet-1', 'root-1')).resolves.toEqual({
...comment,
children: [createComment({ id: 'reply-1', parentId: 'root-1', threadId: 'root-1', ref: '' })],
});
await expect(service.listThreadComments('unit-1', 'sheet-1', ['root-1'])).resolves.toEqual([
{
...comment,
children: [createComment({ id: 'reply-1', parentId: 'root-1', threadId: 'root-1', ref: '' })],
},
]);
expect(service.saveToSnapshot({
'sheet-1': [comment],
}, 'unit-1')).toEqual({
'sheet-1': [
{
id: 'root-1',
threadId: 'root-1',
ref: 'A1',
personId: 'user-1',
},
],
});
expect(listRequests).toEqual([
{ unitId: 'unit-1', subUnitId: 'sheet-1', threadIds: ['root-1'] },
{ unitId: 'unit-1', subUnitId: 'sheet-1', threadIds: ['root-1'] },
]);
expect(savedSnapshots).toEqual([comment]);
});
it('normalizes an empty threadId returned from an external data source to the returned comment id', async () => {
const service = createService();
const comment = createComment({ id: 'client-id', threadId: '' });
const dataSource: IThreadCommentDataSource = {
async addComment(input) {
return { ...input, id: 'server-id', threadId: '' };
},
async updateComment() {
return true;
},
async resolveComment() {
return true;
},
async deleteComment() {
return true;
},
async listComments() {
return [];
},
saveCommentToSnapshot(input) {
return {
id: input.id,
threadId: input.threadId,
ref: input.ref,
};
},
};
service.dataSource = dataSource;
await expect(service.addComment(comment)).resolves.toEqual({
...comment,
id: 'server-id',
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<IThreadComment>((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);
});
});

View File

@@ -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: '<img src=x onerror=alert(1)>\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`);
});
});

View File

@@ -0,0 +1,186 @@
/**
* 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 { Nullable } from '@univerjs/core';
import type { IBaseComment, IThreadComment } from '../types/interfaces/i-thread-comment';
import { createIdentifier, Disposable } from '@univerjs/core';
export type ThreadCommentJSON = {
id: string;
threadId: string;
ref: string;
} & Partial<Omit<IThreadComment, 'id' | 'threadId' | 'ref'>>;
type Success = boolean;
export interface IThreadCommentDataSource {
/**
* handler for add-comment, throw error means fail and stop the process.
*/
addComment: (comment: IThreadComment) => Promise<IThreadComment>;
/**
* handler for update-comment, throw error means fail and stop the process.
*/
updateComment: (comment: IThreadComment) => Promise<Success>;
resolveComment: (comment: IThreadComment) => Promise<Success>;
/**
* handler for delete-comment, throw error means fail and stop the process.
*/
deleteComment: (unitId: string, subUnitId: string, threadId: string, commentId: string) => Promise<Success>;
/**
* 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<Success>;
/**
* handler for batch-fetch-comment, throw error means fail and stop the process.
*/
listComments: (unitId: string, subUnitId: string, threadId: string[]) => Promise<IBaseComment[]>;
saveCommentToSnapshot: (comment: IThreadComment) => ThreadCommentJSON;
}
export interface IThreadCommentDataSourceService {
dataSource: Nullable<IThreadCommentDataSource>;
/**
* should sync update mutations to collaboration-server
*/
syncUpdateMutationToColla: boolean;
/**
* handler for add-comment, throw error means fail and stop the process.
*/
addComment: (comment: IThreadComment) => Promise<IThreadComment>;
/**
* handler for update-comment, throw error means fail and stop the process.
*/
updateComment: (comment: IThreadComment) => Promise<Success>;
/**
* handler for resolve-comment, throw error means fail and stop the process.
*/
resolveComment: (comment: IThreadComment) => Promise<Success>;
/**
* handler for delete-comment, throw error means fail and stop the process.
*/
deleteComment: (unitId: string, subUnitId: string, threadId: string, commentId: string) => Promise<Success>;
deleteThread?: (unitId: string, subUnitId: string, threadId: string) => Promise<Success>;
saveToSnapshot: (unitComments: Record<string, IThreadComment[]>, unitId: string) => Record<string, ThreadCommentJSON[]>;
getThreadComment: (unitId: string, subUnitId: string, threadId: string) => Promise<Nullable<IBaseComment>>;
listThreadComments: (unitId: string, subUnitId: string, threadId: string[]) => Promise<IBaseComment[] | false>;
}
/**
* Preserve for import async comment system
*/
export class ThreadCommentDataSourceService extends Disposable implements IThreadCommentDataSourceService {
private _dataSource: Nullable<IThreadCommentDataSource> = null;
private readonly _pendingAdds = new Map<string, Promise<IThreadComment>>();
syncUpdateMutationToColla = true;
set dataSource(dataSource: Nullable<IThreadCommentDataSource>) {
this._dataSource = dataSource;
}
get dataSource() {
return this._dataSource;
}
constructor() {
super();
}
async getThreadComment(unitId: string, subUnitId: string, threadId: string): Promise<Nullable<IBaseComment>> {
if (this._dataSource) {
const comments = await this._dataSource.listComments(unitId, subUnitId, [threadId]);
return comments[0];
}
return null;
}
addComment(comment: IThreadComment): Promise<IThreadComment> {
const key = `${comment.unitId}\0${comment.subUnitId}\0${comment.id}`;
const pending = this._pendingAdds.get(key);
if (pending) {
return pending;
}
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) {
if (this._dataSource) {
return this._dataSource.updateComment(comment);
}
return true;
}
async resolveComment(comment: IThreadComment) {
if (this._dataSource) {
return this._dataSource.resolveComment(comment);
}
return true;
}
async deleteComment(unitId: string, subUnitId: string, threadId: string, commentId: string) {
if (this._dataSource) {
return this._dataSource.deleteComment(unitId, subUnitId, threadId, commentId);
}
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);
}
return false;
}
saveToSnapshot(unitComments: Record<string, IThreadComment[]>, _unitId: string) {
if (this._dataSource) {
const map: Record<string, ThreadCommentJSON[]> = {};
Object.keys(unitComments).forEach((subUnitId) => {
const comments = unitComments[subUnitId];
map[subUnitId] = comments.map(this.dataSource!.saveCommentToSnapshot);
});
return map;
}
return unitComments;
}
}
export const IThreadCommentDataSourceService = createIdentifier<IThreadCommentDataSourceService>('univer.thread-comment.data-source-service');

View File

@@ -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<IThreadInfo, 'relativeUsers'> {
/**
* 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<boolean> {
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<boolean> {
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<boolean> {
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<boolean> {
return this._commandService.executeCommand(
options.deleteThread ? DeleteCommentTreeCommand.id : DeleteCommentCommand.id,
{
unitId: options.unitId,
subUnitId: options.subUnitId,
commentId: options.commentId,
}
);
}
resolveCommentAsync(options: IResolveThreadCommentOptions): Promise<boolean> {
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<IFacadeThreadCommentInfo[]> {
const knownThreads = this._model.query({ unitIds: query.unitIds, subUnitIds: query.subUnitIds });
const groups = new Map<string, { unitId: string; subUnitId: string; threadIds: string[] }>();
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)
));
}
}

View File

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

View File

@@ -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;
}
}

View File

@@ -0,0 +1,17 @@
/**
* 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 TC_PLUGIN_NAME = 'UNIVER_THREAD_COMMENT_PLUGIN';

View File

@@ -0,0 +1,52 @@
/**
* 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';
export interface IThreadCommentMention {
label: string;
id: string;
icon?: string;
}
export interface IBaseComment {
id: string;
threadId: string;
dT: string;
updateT?: string;
personId: string;
text: IDocumentBody;
attachments?: string[];
updated?: boolean;
mentions?: string[];
parentId?: string;
resolved?: boolean;
unitId: string;
subUnitId: string;
children?: 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;
}

View File

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

View File

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

View File

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