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
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:
85
packages/thread-comment/README.md
Normal file
85
packages/thread-comment/README.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# @univerjs/thread-comment
|
||||
|
||||
[](https://npmjs.com/package/@univerjs/thread-comment)
|
||||
[](https://npmjs.com/package/@univerjs/thread-comment)
|
||||
[](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)
|
||||
89
packages/thread-comment/package.json
Normal file
89
packages/thread-comment/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
285
packages/thread-comment/src/commands/commands/comment.command.ts
Normal file
285
packages/thread-comment/src/commands/commands/comment.command.ts
Normal 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,
|
||||
});
|
||||
},
|
||||
};
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
116
packages/thread-comment/src/common/tc-resource.ts
Normal file
116
packages/thread-comment/src/common/tc-resource.ts
Normal 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));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
21
packages/thread-comment/src/common/utils.ts
Normal file
21
packages/thread-comment/src/common/utils.ts
Normal 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');
|
||||
}
|
||||
27
packages/thread-comment/src/config/config.ts
Normal file
27
packages/thread-comment/src/config/config.ts
Normal 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 = {};
|
||||
43
packages/thread-comment/src/facade/f-enum.ts
Normal file
43
packages/thread-comment/src/facade/f-enum.ts
Normal 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 {}
|
||||
}
|
||||
221
packages/thread-comment/src/facade/f-univer.ts
Normal file
221
packages/thread-comment/src/facade/f-univer.ts
Normal 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 {}
|
||||
}
|
||||
19
packages/thread-comment/src/facade/index.ts
Normal file
19
packages/thread-comment/src/facade/index.ts
Normal 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';
|
||||
79
packages/thread-comment/src/index.ts
Normal file
79
packages/thread-comment/src/index.ts
Normal 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';
|
||||
@@ -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}`)
|
||||
);
|
||||
});
|
||||
});
|
||||
643
packages/thread-comment/src/models/thread-comment.model.ts
Normal file
643
packages/thread-comment/src/models/thread-comment.model.ts
Normal 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;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
99
packages/thread-comment/src/plugin.ts
Normal file
99
packages/thread-comment/src/plugin.ts
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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`);
|
||||
});
|
||||
});
|
||||
186
packages/thread-comment/src/services/tc-datasource.service.ts
Normal file
186
packages/thread-comment/src/services/tc-datasource.service.ts
Normal 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');
|
||||
@@ -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)
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
134
packages/thread-comment/src/types/comment-anchor.ts
Normal file
134
packages/thread-comment/src/types/comment-anchor.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
17
packages/thread-comment/src/types/const/index.ts
Normal file
17
packages/thread-comment/src/types/const/index.ts
Normal 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';
|
||||
@@ -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;
|
||||
}
|
||||
8
packages/thread-comment/tsconfig.json
Normal file
8
packages/thread-comment/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@univerjs-infra/shared/tsconfigs/base",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
18
packages/thread-comment/tsconfig.node.json
Normal file
18
packages/thread-comment/tsconfig.node.json
Normal 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"
|
||||
]
|
||||
}
|
||||
3
packages/thread-comment/vitest.config.ts
Normal file
3
packages/thread-comment/vitest.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import createConfig from '@univerjs-infra/shared/vitest';
|
||||
|
||||
export default createConfig();
|
||||
Reference in New Issue
Block a user