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

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

View File

@@ -0,0 +1,41 @@
# @univerjs/sheets-thread-comment
[![npm version](https://img.shields.io/npm/v/@univerjs/sheets-thread-comment?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-thread-comment)
[![license](https://img.shields.io/npm/l/@univerjs/sheets-thread-comment?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-thread-comment)
[![downloads](https://img.shields.io/npm/dm/@univerjs/sheets-thread-comment?style=flat-square)](https://npmjs.com/package/@univerjs/sheets-thread-comment)
`@univerjs/sheets-thread-comment` connects shared thread comments to Univer Sheets and provides sheet-specific comment services.
## Package Overview
| Package | UMD global | CSS | Locales | Facade entry |
| --- | --- | :---: | :---: | :---: |
| `@univerjs/sheets-thread-comment` | `UniverSheetsThreadComment` | No | No | Yes |
## Installation
```sh
pnpm add @univerjs/sheets-thread-comment
# or
npm install @univerjs/sheets-thread-comment
```
Keep all `@univerjs/*` packages on the same version.
## Usage
```ts
import { UniverSheetsThreadCommentPlugin } from '@univerjs/sheets-thread-comment';
univer.registerPlugin(UniverSheetsThreadCommentPlugin);
```
## Integration Notes
Use this package with `@univerjs/sheets-thread-comment-ui` when users need comment UI in sheets.
## Resources
- [Documentation](https://docs.univer.ai)
- [NPM package](https://npmjs.com/package/@univerjs/sheets-thread-comment)
- [GitHub repository](https://github.com/dream-num/univer)

View File

@@ -0,0 +1,92 @@
{
"name": "@univerjs/sheets-thread-comment",
"version": "1.0.0-rc.0",
"private": false,
"description": "Thread comment integration for Univer Sheets.",
"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",
"sheets",
"comment",
"thread-comment",
"plugin"
],
"exports": {
".": "./src/index.ts",
"./*": "./src/*",
"./facade": "./src/facade/index.ts"
},
"main": "./src/index.ts",
"types": "./lib/types/index.d.ts",
"publishConfig": {
"access": "public",
"main": "./lib/es/index.js",
"module": "./lib/es/index.js",
"exports": {
".": {
"import": "./lib/es/index.js",
"require": "./lib/cjs/index.js",
"types": "./lib/types/index.d.ts"
},
"./*": {
"import": "./lib/es/*",
"require": "./lib/cjs/*",
"types": "./lib/types/index.d.ts"
},
"./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:*",
"@univerjs/engine-formula": "workspace:*",
"@univerjs/sheets": "workspace:*",
"@univerjs/thread-comment": "workspace:*"
},
"devDependencies": {
"@univerjs-infra/shared": "workspace:*",
"rxjs": "^7.8.2",
"typescript": "^6.0.3",
"vitest": "^5.0.0"
}
}

View File

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

View File

@@ -0,0 +1,232 @@
/**
* 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 { AddCommentMutation, DeleteCommentMutation, UpdateCommentRefMutation } from '@univerjs/thread-comment';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { SheetsThreadCommentRefRangeController } from '../sheets-thread-comment-ref-range.controller';
const sheetsMocks = vi.hoisted(() => ({
handleRangeChange: vi.fn(),
}));
vi.mock('@univerjs/sheets', async () => {
const actual = await vi.importActual<typeof import('@univerjs/sheets')>('@univerjs/sheets');
return {
...actual,
handleCommonRangeChangeWithEffectRefCommandsSkipNoInterests: sheetsMocks.handleRangeChange,
};
});
function createRoot(ref = 'A1', overrides: Record<string, unknown> = {}) {
return {
id: 'comment-1',
threadId: 'thread-1',
ref,
dT: '2025-01-01T00:00:00.000Z',
personId: 'user-1',
text: { dataStream: 'hello\r\n', textRuns: [] },
attachments: [],
unitId: 'unit-1',
subUnitId: 'sheet-1',
...overrides,
} as any;
}
describe('SheetsThreadCommentRefRangeController', () => {
it('should register initial comments and transform ref changes into undo/redo mutations', async () => {
const registerDisposes: Array<ReturnType<typeof vi.fn>> = [];
const watchDisposes: Array<ReturnType<typeof vi.fn>> = [];
const registerCalls: Array<{ callback: (commandInfo: unknown) => unknown }> = [];
const watchCalls: Array<{ callback: (before: unknown, after: any) => void }> = [];
const executeCommand = vi.fn(async () => true);
const root = createRoot('A1', { row: 0, column: 0 });
const updates$ = new Subject<any>();
const controller = new SheetsThreadCommentRefRangeController(
{
registerRefRange: (_range: unknown, callback: (commandInfo: unknown) => unknown) => {
const dispose = vi.fn();
registerDisposes.push(dispose);
registerCalls.push({ callback });
return { dispose };
},
watchRange: (_unitId: string, _subUnitId: string, _range: unknown, callback: (before: unknown, after: any) => void) => {
const dispose = vi.fn();
watchDisposes.push(dispose);
watchCalls.push({ callback });
return { dispose };
},
} as any,
{
commentUpdate$: updates$,
getComment: vi.fn(),
} as any,
{
getAll: () => [{ threads: [{ unitId: 'unit-1', subUnitId: 'sheet-1', root }] }],
} as any,
{} as any,
{ executeCommand } as any
);
expect(registerCalls).toHaveLength(1);
expect(watchCalls).toHaveLength(1);
sheetsMocks.handleRangeChange.mockReturnValueOnce({ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 });
expect(registerCalls[0].callback({})).toEqual({ redos: [], undos: [] });
sheetsMocks.handleRangeChange.mockReturnValueOnce({ startRow: 2, endRow: 2, startColumn: 2, endColumn: 2 });
expect(registerCalls[0].callback({})).toEqual({
redos: [{
id: UpdateCommentRefMutation.id,
params: {
unitId: 'unit-1',
subUnitId: 'sheet-1',
payload: { ref: 'C3', commentId: 'comment-1' },
silent: false,
},
}],
undos: [{
id: UpdateCommentRefMutation.id,
params: {
unitId: 'unit-1',
subUnitId: 'sheet-1',
payload: { ref: 'A1', commentId: 'comment-1' },
silent: false,
},
}],
});
sheetsMocks.handleRangeChange.mockReturnValueOnce(null);
expect(registerCalls[0].callback({})).toEqual({
redos: [{
id: DeleteCommentMutation.id,
params: { unitId: 'unit-1', subUnitId: 'sheet-1', commentId: 'comment-1' },
}],
undos: [{
id: AddCommentMutation.id,
params: expect.objectContaining({
unitId: 'unit-1',
subUnitId: 'sheet-1',
comment: expect.objectContaining(root),
sync: true,
}),
}],
});
await watchCalls[0].callback(null, { startRow: 4, endRow: 4, startColumn: 1, endColumn: 1 });
expect(executeCommand).toHaveBeenCalledWith(UpdateCommentRefMutation.id, {
unitId: 'unit-1',
subUnitId: 'sheet-1',
payload: { ref: 'B5', commentId: 'comment-1' },
silent: true,
}, { onlyLocal: true });
controller.dispose();
});
it('should react to add, delete and updateRef comment updates by maintaining watchers', () => {
const registerDisposes: Array<ReturnType<typeof vi.fn>> = [];
const watchDisposes: Array<ReturnType<typeof vi.fn>> = [];
const updates$ = new Subject<any>();
const movedComment = createRoot('D4');
const getComment = vi.fn(() => movedComment);
const controller = new SheetsThreadCommentRefRangeController(
{
registerRefRange: vi.fn(() => {
const dispose = vi.fn();
registerDisposes.push(dispose);
return { dispose };
}),
watchRange: vi.fn(() => {
const dispose = vi.fn();
watchDisposes.push(dispose);
return { dispose };
}),
} as any,
{
commentUpdate$: updates$,
getComment,
} as any,
{
getAll: () => [],
} as any,
{} as any,
{ executeCommand: vi.fn() } as any
);
updates$.next({
type: 'add',
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 0,
column: 0,
payload: createRoot('A1'),
});
expect(registerDisposes).toHaveLength(1);
expect(watchDisposes).toHaveLength(1);
updates$.next({
type: 'add',
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 0,
column: 0,
payload: createRoot('A1', { parentId: 'root-1' }),
});
expect(registerDisposes).toHaveLength(1);
updates$.next({
type: 'updateRef',
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 3,
column: 3,
silent: false,
payload: { commentId: 'comment-1' },
});
expect(getComment).toHaveBeenCalledWith('unit-1', 'sheet-1', 'comment-1');
expect(registerDisposes[0]).toHaveBeenCalledTimes(1);
expect(watchDisposes[0]).toHaveBeenCalledTimes(1);
expect(registerDisposes).toHaveLength(2);
expect(watchDisposes).toHaveLength(2);
updates$.next({
type: 'updateRef',
unitId: 'unit-1',
subUnitId: 'sheet-1',
row: 3,
column: 3,
silent: true,
payload: { commentId: 'comment-1' },
});
expect(watchDisposes[1]).toHaveBeenCalledTimes(0);
expect(watchDisposes).toHaveLength(2);
expect(registerDisposes).toHaveLength(3);
updates$.next({
type: 'delete',
unitId: 'unit-1',
subUnitId: 'sheet-1',
payload: { commentId: 'comment-1' },
});
expect(registerDisposes[2]).toHaveBeenCalledTimes(1);
expect(watchDisposes[1]).toHaveBeenCalledTimes(1);
controller.dispose();
});
});

View File

@@ -0,0 +1,183 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { UniverInstanceType } from '@univerjs/core';
import { CopySheetCommand, RemoveSheetCommand } from '@univerjs/sheets';
import {
AddCommentMutation,
DeleteCommentMutation,
} from '@univerjs/thread-comment';
import { describe, expect, it, vi } from 'vitest';
import { SHEET_UNIVER_THREAD_COMMENT_PLUGIN } from '../../types/const';
import { SheetsThreadCommentResourceController } from '../sheets-thread-comment-resource.controller';
function createRootComment() {
return {
id: 'comment-1',
threadId: 'thread-1',
ref: 'A1',
dT: '2025-01-01T00:00:00.000Z',
personId: 'user-1',
text: { dataStream: 'hello\r\n', textRuns: [] },
attachments: [],
unitId: 'unit-1',
subUnitId: 'sheet-1',
children: [{ id: 'reply-1', parentId: 'comment-1' }],
} as any;
}
describe('SheetsThreadCommentResourceController', () => {
it('should create undo/redo mutations when removing a sheet', () => {
let interceptorConfig: any;
const rootComment = createRootComment();
const controller = new SheetsThreadCommentResourceController(
{
registerPluginResource: () => ({ dispose: vi.fn() }),
} as any,
{
getCurrentUnitOfType: () => ({
getUnitId: () => 'unit-1',
getActiveSheet: () => ({ getSheetId: () => 'sheet-1' }),
}),
} as any,
{
interceptCommand: (config: any) => {
interceptorConfig = config;
return { dispose: vi.fn() };
},
} as any,
{
ensureMap: () => new Map([
[rootComment.id, rootComment],
['reply-1', { id: 'reply-1', parentId: rootComment.id }],
]),
} as any,
{
syncUpdateMutationToColla: true,
} as any
);
const result = interceptorConfig.getMutations({
id: RemoveSheetCommand.id,
params: {},
});
expect(result.redos).toEqual([
{
id: DeleteCommentMutation.id,
params: { unitId: 'unit-1', subUnitId: 'sheet-1', commentId: 'comment-1' },
},
]);
expect(result.undos).toEqual([
{
id: AddCommentMutation.id,
params: {
unitId: 'unit-1',
subUnitId: 'sheet-1',
comment: rootComment,
sync: false,
},
},
]);
controller.dispose();
});
it('should clone root comments onto the copied sheet and omit children when sync is disabled', () => {
let interceptorConfig: any;
const rootComment = createRootComment();
const controller = new SheetsThreadCommentResourceController(
{
registerPluginResource: () => ({ dispose: vi.fn() }),
} as any,
{} as any,
{
interceptCommand: (config: any) => {
interceptorConfig = config;
return { dispose: vi.fn() };
},
} as any,
{
ensureMap: () => new Map([
[rootComment.id, rootComment],
['reply-1', { id: 'reply-1', parentId: rootComment.id }],
]),
} as any,
{
syncUpdateMutationToColla: false,
} as any
);
const result = interceptorConfig.getMutations({
id: CopySheetCommand.id,
params: {
unitId: 'unit-1',
subUnitId: 'sheet-1',
targetSubUnitId: 'sheet-2',
},
});
expect(result.redos).toHaveLength(1);
expect(result.redos[0]).toEqual(expect.objectContaining({
id: AddCommentMutation.id,
params: expect.objectContaining({
unitId: 'unit-1',
subUnitId: 'sheet-2',
sync: true,
}),
}));
expect(result.redos[0].params.comment).toEqual(expect.objectContaining({
subUnitId: 'sheet-2',
children: undefined,
}));
expect(result.redos[0].params.comment.id).not.toBe(rootComment.id);
expect(result.redos[0].params.comment.threadId).not.toBe(rootComment.threadId);
expect(result.undos).toEqual([
{
id: DeleteCommentMutation.id,
params: {
unitId: 'unit-1',
subUnitId: 'sheet-2',
commentId: result.redos[0].params.comment.id,
},
},
]);
controller.dispose();
});
it('registers sheet thread comment snapshot resources for sheet units', () => {
let hook: any;
const controller = new SheetsThreadCommentResourceController(
{
registerPluginResource: (nextHook: any) => {
hook = nextHook;
return { dispose: vi.fn() };
},
} as any,
{} as any,
{ interceptCommand: () => ({ dispose: vi.fn() }) } as any,
{ getUnit: () => [] } as any,
{ saveToSnapshot: (value: unknown) => value } as any
);
expect(hook.pluginName).toBe(SHEET_UNIVER_THREAD_COMMENT_PLUGIN);
expect(hook.businesses).toEqual([UniverInstanceType.UNIVER_SHEET]);
controller.dispose();
});
});

View File

@@ -0,0 +1,236 @@
/**
* 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 { IDisposable, IRange, Nullable } from '@univerjs/core';
import type { EffectRefRangeParams } from '@univerjs/sheets';
import type { IAddCommentMutationParams, IUpdateCommentRefMutationParams } from '@univerjs/thread-comment';
import type { ISheetThreadComment } from '../types/interfaces/i-sheet-thread-comment';
import { Disposable, ICommandService, Inject, sequenceExecuteAsync, toDisposable } from '@univerjs/core';
import { serializeRange, singleReferenceToGrid } from '@univerjs/engine-formula';
import { handleCommonRangeChangeWithEffectRefCommandsSkipNoInterests, RefRangeService, SheetsSelectionsService } from '@univerjs/sheets';
import { AddCommentMutation, DeleteCommentMutation, deserializeThreadCommentAnchor, ThreadCommentModel, UpdateCommentRefMutation } from '@univerjs/thread-comment';
import { SheetsThreadCommentModel } from '../models/sheets-thread-comment.model';
export class SheetsThreadCommentRefRangeController extends Disposable {
private _disposableMap = new Map<string, IDisposable>();
private _watcherMap = new Map<string, IDisposable>();
constructor(
@Inject(RefRangeService) private readonly _refRangeService: RefRangeService,
@Inject(SheetsThreadCommentModel) private readonly _sheetsThreadCommentModel: SheetsThreadCommentModel,
@Inject(ThreadCommentModel) private readonly _threadCommentModel: ThreadCommentModel,
@Inject(SheetsSelectionsService) private readonly _selectionManagerService: SheetsSelectionsService,
@ICommandService private readonly _commandService: ICommandService
) {
super();
this._initData();
this._initRefRange();
}
private _getIdWithUnitId(unitId: string, subUnitId: string, id: string) {
return `${unitId}-${subUnitId}-${id}`;
}
private _handleRangeChange = (unitId: string, subUnitId: string, comment: ISheetThreadComment, resultRange: Nullable<IRange>, silent?: boolean) => {
const commentId = comment.id;
const oldRange: IRange = {
startColumn: comment.column,
endColumn: comment.column,
startRow: comment.row,
endRow: comment.row,
};
if (!resultRange) {
return {
redos: [{
id: DeleteCommentMutation.id,
params: {
unitId,
subUnitId,
commentId,
},
}],
undos: [{
id: AddCommentMutation.id,
params: {
unitId,
subUnitId,
comment,
sync: true,
} as IAddCommentMutationParams,
}],
};
}
return {
redos: [{
id: UpdateCommentRefMutation.id,
params: {
unitId,
subUnitId,
payload: {
ref: serializeRange(resultRange),
commentId,
},
silent,
} as IUpdateCommentRefMutationParams,
}],
undos: [{
id: UpdateCommentRefMutation.id,
params: {
unitId,
subUnitId,
payload: {
ref: serializeRange(oldRange),
commentId,
},
silent,
} as IUpdateCommentRefMutationParams,
}],
};
};
private _register(unitId: string, subUnitId: string, comment: ISheetThreadComment) {
const commentId = comment.id;
const oldRange: IRange = {
startColumn: comment.column,
endColumn: comment.column,
startRow: comment.row,
endRow: comment.row,
};
this._disposableMap.set(
this._getIdWithUnitId(unitId, subUnitId, commentId),
this._refRangeService.registerRefRange(oldRange, (commandInfo: EffectRefRangeParams) => {
const resultRanges = handleCommonRangeChangeWithEffectRefCommandsSkipNoInterests(oldRange, commandInfo, { selectionManagerService: this._selectionManagerService });
const resultRange = Array.isArray(resultRanges) ? resultRanges[0] : resultRanges;
if (resultRange && resultRange.startColumn === oldRange.startColumn && resultRange.startRow === oldRange.startRow) {
return {
undos: [],
redos: [],
};
}
const res = this._handleRangeChange(unitId, subUnitId, comment, resultRange, false);
return res;
}, unitId, subUnitId)
);
}
private _watch(unitId: string, subUnitId: string, comment: ISheetThreadComment) {
const commentId = comment.id;
const oldRange: IRange = {
startColumn: comment.column,
endColumn: comment.column,
startRow: comment.row,
endRow: comment.row,
};
this._watcherMap.set(
this._getIdWithUnitId(unitId, subUnitId, commentId),
this._refRangeService.watchRange(unitId, subUnitId, oldRange, (before, after) => {
const { redos } = this._handleRangeChange(unitId, subUnitId, comment, after, true);
sequenceExecuteAsync(redos, this._commandService, { onlyLocal: true });
}, true)
);
}
private _unwatch(unitId: string, subUnitId: string, commentId: string) {
const id = this._getIdWithUnitId(unitId, subUnitId, commentId);
this._watcherMap.get(id)?.dispose();
this._watcherMap.delete(id);
}
private _unregister(unitId: string, subUnitId: string, commentId: string) {
const id = this._getIdWithUnitId(unitId, subUnitId, commentId);
this._disposableMap.get(id)?.dispose();
this._disposableMap.delete(id);
}
private _initData() {
const datas = this._threadCommentModel.getAll();
for (const data of datas) {
for (const thread of data.threads) {
const { unitId, subUnitId, root } = thread;
if (deserializeThreadCommentAnchor(root.ref)) {
continue;
}
const pos = singleReferenceToGrid(root.ref);
const sheetComment = {
...root,
...pos,
};
this._register(unitId, subUnitId, sheetComment);
this._watch(unitId, subUnitId, sheetComment);
}
}
}
private _initRefRange() {
this.disposeWithMe(
this._sheetsThreadCommentModel.commentUpdate$.subscribe((option) => {
const { unitId, subUnitId } = option;
switch (option.type) {
case 'add': {
if (option.payload.parentId) {
return;
}
const comment = {
...option.payload,
row: option.row,
column: option.column,
};
this._register(option.unitId, option.subUnitId, comment);
this._watch(option.unitId, option.subUnitId, comment);
break;
}
case 'delete': {
this._unregister(unitId, subUnitId, option.payload.commentId);
this._unwatch(unitId, subUnitId, option.payload.commentId);
break;
}
case 'updateRef': {
const comment = this._sheetsThreadCommentModel.getComment(unitId, subUnitId, option.payload.commentId);
if (!comment) {
return;
}
this._unregister(unitId, subUnitId, option.payload.commentId);
const sheetComment = {
...comment,
row: option.row,
column: option.column,
};
if (!option.silent) {
this._unwatch(unitId, subUnitId, option.payload.commentId);
this._watch(unitId, subUnitId, sheetComment);
}
this._register(option.unitId, option.subUnitId, sheetComment);
break;
}
}
})
);
this.disposeWithMe(toDisposable(() => {
this._disposableMap.forEach((item) => {
item.dispose();
});
this._disposableMap.clear();
}));
}
}

View File

@@ -0,0 +1,157 @@
/**
* 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 { IMutationInfo, Workbook } from '@univerjs/core';
import type { ICopySheetCommandParams, IRemoveSheetCommandParams } from '@univerjs/sheets';
import {
Disposable,
generateRandomId,
Inject,
IResourceManagerService,
IUniverInstanceService,
UniverInstanceType,
} from '@univerjs/core';
import { CopySheetCommand, RemoveSheetCommand, SheetInterceptorService } from '@univerjs/sheets';
import {
AddCommentMutation,
createThreadCommentResourceHook,
DeleteCommentMutation,
IThreadCommentDataSourceService,
ThreadCommentModel,
} from '@univerjs/thread-comment';
import { SHEET_UNIVER_THREAD_COMMENT_PLUGIN } from '../types/const';
export class SheetsThreadCommentResourceController extends Disposable {
constructor(
@IResourceManagerService private readonly _resourceManagerService: IResourceManagerService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@Inject(SheetInterceptorService) private readonly _sheetInterceptorService: SheetInterceptorService,
@Inject(ThreadCommentModel) private readonly _threadCommentModel: ThreadCommentModel,
@IThreadCommentDataSourceService private readonly _threadCommentDataSourceService: IThreadCommentDataSourceService
) {
super();
this._initSnapshot();
this._initSheetChange();
}
private _initSnapshot() {
this.disposeWithMe(this._resourceManagerService.registerPluginResource(createThreadCommentResourceHook(
this._threadCommentModel,
this._threadCommentDataSourceService,
SHEET_UNIVER_THREAD_COMMENT_PLUGIN,
[UniverInstanceType.UNIVER_SHEET]
)));
}
// eslint-disable-next-line max-lines-per-function
private _initSheetChange() {
this.disposeWithMe(
this._sheetInterceptorService.interceptCommand({
// eslint-disable-next-line max-lines-per-function
getMutations: (commandInfo) => {
if (commandInfo.id === RemoveSheetCommand.id) {
const params = commandInfo.params as IRemoveSheetCommandParams;
const unitId = params.unitId || this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!.getUnitId();
const subUnitId = params.subUnitId || this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!.getActiveSheet()?.getSheetId();
if (!unitId || !subUnitId) {
return { redos: [], undos: [] };
}
const commentMap = this._threadCommentModel.ensureMap(unitId, subUnitId);
const comments = Array.from(commentMap.values()).filter((comment) => !comment.parentId);
const shouldSync = this._threadCommentDataSourceService.syncUpdateMutationToColla;
const redos: IMutationInfo[] = [];
const undos: IMutationInfo[] = [];
comments.forEach(({ children, ...comment }) => {
redos.push({
id: DeleteCommentMutation.id,
params: {
unitId,
subUnitId,
commentId: comment.id,
},
});
undos.push({
id: AddCommentMutation.id,
params: {
unitId,
subUnitId,
comment: {
...comment,
children: shouldSync ? children : undefined,
},
sync: !shouldSync,
},
});
});
return { redos, undos };
} else if (commandInfo.id === CopySheetCommand.id) {
const params = commandInfo.params as ICopySheetCommandParams & { targetSubUnitId: string };
const { unitId, subUnitId, targetSubUnitId } = params;
if (!unitId || !subUnitId || !targetSubUnitId) {
return { redos: [], undos: [] };
}
const commentMap = this._threadCommentModel.ensureMap(unitId, subUnitId);
const comments = Array.from(commentMap.values()).map((comment) => {
return {
...comment,
subUnitId: targetSubUnitId,
id: generateRandomId(),
threadId: generateRandomId(),
};
}).filter((comment) => !comment.parentId);
const shouldSync = this._threadCommentDataSourceService.syncUpdateMutationToColla;
const redos: IMutationInfo[] = [];
const undos: IMutationInfo[] = [];
comments.forEach(({ children, ...comment }) => {
redos.push({
id: AddCommentMutation.id,
params: {
unitId,
subUnitId: targetSubUnitId,
comment: {
...comment,
children: shouldSync ? children : undefined,
},
sync: !shouldSync,
},
});
undos.push({
id: DeleteCommentMutation.id,
params: {
unitId,
subUnitId: targetSubUnitId,
commentId: comment.id,
},
});
});
return { redos, undos };
}
return { redos: [], undos: [] };
},
})
);
}
}

View File

@@ -0,0 +1,37 @@
/**
* 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 { FEventName } from '@univerjs/core/facade';
import { describe, expect, it } from 'vitest';
import { FSheetsThreadCommentEventNameMixin } from '../f-event';
describe('FSheetsThreadCommentEventNameMixin', () => {
it('adds thread-comment event names to facade event names', () => {
const eventName = Object.create(FSheetsThreadCommentEventNameMixin.prototype) as FSheetsThreadCommentEventNameMixin;
const extended = new FEventName();
expect(eventName.CommentAdded).toBe('CommentAdded');
expect(eventName.BeforeCommentAdd).toBe('BeforeCommentAdd');
expect(eventName.CommentUpdated).toBe('CommentUpdated');
expect(eventName.BeforeCommentUpdate).toBe('BeforeCommentUpdate');
expect(eventName.CommentDeleted).toBe('CommentDeleted');
expect(eventName.BeforeCommentDelete).toBe('BeforeCommentDelete');
expect(eventName.CommentResolved).toBe('CommentResolved');
expect(eventName.BeforeCommentResolve).toBe('BeforeCommentResolve');
expect(extended.CommentAdded).toBe('CommentAdded');
expect(extended.BeforeCommentResolve).toBe('BeforeCommentResolve');
});
});

View File

@@ -0,0 +1,169 @@
/**
* 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 {
ICommandService,
ILogService,
Injector,
IUniverInstanceService,
RichTextBuilder,
UniverInstanceType,
UserManagerService,
} from '@univerjs/core';
import { FormulaDataModel } from '@univerjs/engine-formula';
import { AddCommentCommand, DeleteCommentTreeCommand, ThreadCommentModel } from '@univerjs/thread-comment';
import { describe, expect, it, vi } from 'vitest';
import { SheetsThreadCommentModel } from '../../models/sheets-thread-comment.model';
import { FRangeSheetsThreadCommentMixin } from '../f-range';
import { FTheadCommentBuilder } from '../f-thread-comment';
function createComment(id: string, ref: string) {
return {
id,
threadId: `thread-${id}`,
ref,
dT: '2025-01-01T00:00:00.000Z',
personId: 'user-1',
text: RichTextBuilder.newEmptyData().body!,
attachments: [],
unitId: 'book-1',
subUnitId: 'sheet-1',
children: [],
};
}
describe('FRangeSheetsThreadCommentMixin', () => {
it('gets, adds, and clears comments through injected services', async () => {
const commandService = { executeCommand: vi.fn(async () => true) };
const comments = new Map([
['comment-1', createComment('comment-1', 'A1')],
['comment-2', createComment('comment-2', 'B1')],
]);
const locations = new Map([
['0:0', 'comment-1'],
['0:1', 'comment-2'],
]);
class TestSheetsThreadCommentModel {
getByLocation = (_unitId: string, _sheetId: string, row: number, col: number) => locations.get(`${row}:${col}`);
getComment = (_unitId: string, _sheetId: string, commentId: string) => comments.get(commentId);
getCommentWithChildren = (_unitId: string, _sheetId: string, commentId: string) => comments.get(commentId);
}
class TestUserManagerService {
getCurrentUser = () => ({ userID: 'current-user' });
}
const worksheet = {
getSheetId: () => 'sheet-1',
getRowCount: () => 100,
getColumnCount: () => 100,
};
const workbook = { getUnitId: () => 'book-1', getSheetBySheetId: () => worksheet };
class TestUniverInstanceService {
getUnit = (_unitId: string, type: UniverInstanceType) => (type === UniverInstanceType.UNIVER_SHEET ? workbook : null);
}
class TestThreadCommentModel {
getCommentWithChildren = (_unitId: string, _subUnitId: string, commentId: string) => comments.get(commentId);
}
class TestFormulaDataModel {}
class TestLogService {}
const injector = new Injector();
injector.add([SheetsThreadCommentModel, { useClass: TestSheetsThreadCommentModel as never }]);
injector.add([ICommandService, { useClass: class { executeCommand = commandService.executeCommand; } as never }]);
injector.add([UserManagerService, { useClass: TestUserManagerService as never }]);
injector.add([IUniverInstanceService, { useClass: TestUniverInstanceService as never }]);
injector.add([ThreadCommentModel, { useClass: TestThreadCommentModel as never }]);
injector.add([FormulaDataModel, { useClass: TestFormulaDataModel as never }]);
injector.add([ILogService, { useClass: TestLogService as never }]);
const range = injector.createInstance(
FRangeSheetsThreadCommentMixin,
workbook as never,
worksheet as never,
{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 1 }
);
expect(range.getComment()?.getCommentData().id).toBe('comment-1');
expect(range.getComments().map((comment) => comment.getCommentData().id)).toEqual(['comment-1', 'comment-2']);
await expect(range.addCommentAsync(FTheadCommentBuilder.create().setId('new-comment'))).resolves.toBe(true);
expect(commandService.executeCommand).toHaveBeenCalledWith(AddCommentCommand.id, expect.objectContaining({
comment: expect.objectContaining({
id: 'new-comment',
ref: 'A1',
personId: 'current-user',
parentId: 'comment-1',
unitId: 'book-1',
subUnitId: 'sheet-1',
}),
}));
await expect(range.addCommentAsync('Agent review', {
id: 'agent-comment',
personId: 'agent-user',
})).resolves.toBe(true);
expect(commandService.executeCommand).toHaveBeenCalledWith(AddCommentCommand.id, expect.objectContaining({
comment: expect.objectContaining({
id: 'agent-comment',
personId: 'agent-user',
text: expect.objectContaining({ dataStream: 'Agent review\r\n' }),
}),
}));
await expect(range.clearCommentAsync()).resolves.toBe(true);
expect(commandService.executeCommand).toHaveBeenCalledWith(DeleteCommentTreeCommand.id, {
unitId: 'book-1',
subUnitId: 'sheet-1',
threadId: 'thread-comment-1',
commentId: 'comment-1',
});
await expect(range.clearCommentsAsync()).resolves.toBe(true);
expect(commandService.executeCommand).toHaveBeenCalledWith(DeleteCommentTreeCommand.id, expect.objectContaining({ commentId: 'comment-2' }));
});
it('returns empty comment results and treats clearing an empty cell as successful', async () => {
class TestSheetsThreadCommentModel {
getByLocation = () => 'missing-comment';
getComment = () => null;
}
const injector = new Injector();
injector.add([SheetsThreadCommentModel, { useClass: TestSheetsThreadCommentModel as never }]);
injector.add([ICommandService, { useClass: class { executeCommand = vi.fn(async () => true); } as never }]);
injector.add([UserManagerService, { useClass: class { getCurrentUser = () => ({ userID: 'user' }); } as never }]);
injector.add([IUniverInstanceService, { useClass: class { getUnit = () => null; } as never }]);
injector.add([ThreadCommentModel, { useClass: class { getCommentWithChildren = () => null; } as never }]);
injector.add([FormulaDataModel, { useClass: class {} as never }]);
injector.add([ILogService, { useClass: class {} as never }]);
const range = injector.createInstance(
FRangeSheetsThreadCommentMixin,
{ getUnitId: () => 'book-1' } as never,
{ getSheetId: () => 'sheet-1', getRowCount: () => 10, getColumnCount: () => 10 } as never,
{ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }
);
expect(range.getComment()).toBeNull();
expect(range.getComments()).toEqual([]);
await expect(range.clearCommentAsync()).resolves.toBe(true);
});
});

View File

@@ -0,0 +1,203 @@
/**
* 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 { RichTextValue, UniverInstanceType } from '@univerjs/core';
import { AddCommentCommand, DeleteCommentCommand, DeleteCommentTreeCommand, ResolveCommentCommand, UpdateCommentCommand } from '@univerjs/thread-comment';
import { describe, expect, it, vi } from 'vitest';
import { FTheadCommentBuilder, FTheadCommentItem, FThreadComment } from '../f-thread-comment';
import { FWorksheetCommentMixin } from '../f-worksheet';
function createBody(text: string) {
return RichTextValue.create({
id: 'doc-1',
body: {
dataStream: `${text}\r\n`,
textRuns: [],
},
documentStyle: {},
});
}
function createThread(overrides: Record<string, unknown> = {}) {
return {
id: 'comment-1',
threadId: 'thread-1',
ref: 'A1',
dT: '2025-01-01T00:00:00.000Z',
personId: 'user-1',
text: createBody('hello').getData().body!,
attachments: [],
unitId: 'unit-1',
subUnitId: 'sheet-1',
resolved: false,
children: [],
...overrides,
} as any;
}
describe('thread comment facade', () => {
it('should build comment payloads with rich text and keep copies isolated', () => {
const richText = createBody('hello univer');
const builder = FTheadCommentBuilder.create()
.setContent(richText)
.setPersonId('user-2')
.setDateTime(new Date('2025-02-21T14:22:22.000Z'))
.setId('comment-2')
.setThreadId('thread-2');
const cloned = builder.copy().setPersonId('user-3');
expect(builder.content.getData().body).toEqual(richText.getData().body);
expect(builder.personId).toBe('user-2');
expect(builder.id).toBe('comment-2');
expect(builder.threadId).toBe('thread-2');
expect(builder.dateTime).toBe(builder.build().dT);
expect(cloned.personId).toBe('user-3');
expect(builder.personId).toBe('user-2');
const item = FTheadCommentItem.create(builder.build());
expect(item.content.getData().body).toEqual(builder.build().text);
});
it('should expose thread comment behaviors and execute comment commands with business payloads', async () => {
const commandService = {
executeCommand: vi.fn(async () => true),
};
const child = createThread({ id: 'reply-1', parentId: 'comment-1', ref: '' });
const root = createThread({ children: [child] });
const injector = {
createInstance: vi.fn((cls: unknown, ...args: any[]) => {
if ((cls as { name?: string }).name === 'FRange') {
return { workbook: args[0], worksheet: args[1], range: args[2] };
}
if (cls === FThreadComment) {
return { comment: args[0] };
}
return null;
}),
};
const workbook = {
getSheetBySheetId: (sheetId: string) => (sheetId === 'sheet-1' ? { getSheetId: () => 'sheet-1' } : null),
};
const univerInstanceService = {
getUnit: (_unitId: string, type: UniverInstanceType) => (type === UniverInstanceType.UNIVER_SHEET ? workbook : null),
};
const threadCommentModel = {
getCommentWithChildren: () => ({ ...root, children: [child] }),
};
const userManagerService = {
getCurrentUser: () => ({ userID: 'current-user' }),
};
const rootComment = new FThreadComment(
root,
undefined,
injector as any,
commandService as any,
univerInstanceService as any,
threadCommentModel as any,
userManagerService as any
);
const replyComment = new FThreadComment(
child,
root,
injector as any,
commandService as any,
univerInstanceService as any,
threadCommentModel as any,
userManagerService as any
);
expect(rootComment.getIsRoot()).toBe(true);
expect(replyComment.getIsRoot()).toBe(false);
expect(rootComment.getCommentData()).not.toHaveProperty('children');
expect(rootComment.getReplies()).toEqual([{ comment: child }]);
expect(rootComment.getRange()).toEqual(expect.objectContaining({
workbook,
worksheet: { getSheetId: expect.any(Function) },
range: expect.objectContaining({ startRow: 0, endRow: 0, startColumn: 0, endColumn: 0 }),
}));
expect(rootComment.getRichText().getData().body).toEqual(root.text);
await rootComment.deleteAsync();
await replyComment.deleteAsync();
const updatedBody = createBody('updated');
await rootComment.updateAsync(updatedBody);
await rootComment.resolveAsync();
await rootComment.resolveAsync(true);
await rootComment.replyAsync(FTheadCommentBuilder.create().setContent(createBody('reply')));
expect(commandService.executeCommand).toHaveBeenCalledWith(DeleteCommentTreeCommand.id, expect.objectContaining({ commentId: root.id }));
expect(commandService.executeCommand).toHaveBeenCalledWith(DeleteCommentCommand.id, expect.objectContaining({ commentId: child.id }));
expect(commandService.executeCommand).toHaveBeenCalledWith(UpdateCommentCommand.id, expect.objectContaining({
payload: expect.objectContaining({
commentId: root.id,
updated: true,
text: updatedBody.getData().body,
}),
}));
expect(commandService.executeCommand).toHaveBeenCalledWith(ResolveCommentCommand.id, expect.objectContaining({ resolved: true }));
expect(commandService.executeCommand).toHaveBeenCalledWith(ResolveCommentCommand.id, expect.objectContaining({ resolved: true }));
expect(commandService.executeCommand).toHaveBeenCalledWith(AddCommentCommand.id, expect.objectContaining({
comment: expect.objectContaining({
parentId: root.id,
threadId: root.threadId,
ref: root.ref,
personId: 'current-user',
}),
}));
});
it('worksheet facade should list, clear and subscribe to sheet comments', async () => {
const root = createThread();
const deleteAsync = vi.fn(async () => true);
const commandListeners: Array<(command: { id: string; params: unknown }) => void> = [];
const instance = Object.create(FWorksheetCommentMixin.prototype) as any;
instance._workbook = { getUnitId: () => 'unit-1' };
instance._worksheet = { getSheetId: () => 'sheet-1' };
instance._injector = {
get: (token: unknown) => {
if (typeof token === 'function' && token.name === 'SheetsThreadCommentModel') {
return {
getSubUnitAll: () => [root],
getComment: (_unitId: string, _subUnitId: string, commentId: string) => (commentId === root.id ? root : undefined),
};
}
return {
onCommandExecuted: (listener: (command: { id: string; params: unknown }) => void) => {
commandListeners.push(listener);
return { dispose: vi.fn() };
},
};
},
createInstance: vi.fn(() => ({ deleteAsync })),
};
expect(instance.getComments()).toHaveLength(1);
expect(await instance.clearComments()).toBe(true);
expect(deleteAsync).toHaveBeenCalledTimes(1);
const callback = vi.fn();
instance.onCommented(callback);
commandListeners.forEach((listener) => listener({ id: AddCommentCommand.id, params: { comment: root } }));
commandListeners.forEach((listener) => listener({ id: UpdateCommentCommand.id, params: { comment: root } }));
expect(callback).toHaveBeenCalledTimes(1);
expect(instance.getCommentById(root.id)).toEqual({ deleteAsync });
expect(instance.getCommentById('missing')).toBeUndefined();
});
});

View File

@@ -0,0 +1,193 @@
/**
* 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 { ICommandService, Injector, RichTextBuilder } from '@univerjs/core';
import {
AddCommentCommand,
DeleteCommentCommand,
DeleteCommentTreeCommand,
ResolveCommentCommand,
UpdateCommentCommand,
} from '@univerjs/thread-comment';
import { describe, expect, it, vi } from 'vitest';
import { FUniverSheetsThreadCommentMixin } from '../f-univer';
import '../f-event';
function createApi(cancelEvents = new Set<string>()) {
const afterListeners: Array<(commandInfo: any) => void> = [];
const beforeListeners: Array<(commandInfo: any) => void> = [];
const commandService = {
onCommandExecuted: (listener: (commandInfo: any) => void) => {
afterListeners.push(listener);
return { dispose: vi.fn() };
},
beforeCommandExecuted: (listener: (commandInfo: any) => void) => {
beforeListeners.push(listener);
return { dispose: vi.fn() };
},
};
class TestCommandService {
onCommandExecuted = commandService.onCommandExecuted;
beforeCommandExecuted = commandService.beforeCommandExecuted;
}
const injector = new Injector();
injector.add([ICommandService, { useClass: TestCommandService as never }]);
const fired: Array<{ name: string; params: any }> = [];
const range = { getRow: () => 2, getColumn: () => 3 };
const threadComment = {
getRange: () => range,
};
const workbook = {};
const worksheet = {
getCommentById: (commentId: string) => (commentId === 'missing' ? null : threadComment),
};
const api = Object.create(FUniverSheetsThreadCommentMixin.prototype) as any;
Object.defineProperty(api, 'Event', {
value: {
CommentAdded: 'CommentAdded',
BeforeCommentAdd: 'BeforeCommentAdd',
CommentUpdated: 'CommentUpdated',
BeforeCommentUpdate: 'BeforeCommentUpdate',
CommentDeleted: 'CommentDeleted',
BeforeCommentDelete: 'BeforeCommentDelete',
CommentResolved: 'CommentResolved',
BeforeCommentResolve: 'BeforeCommentResolve',
},
});
api.disposeWithMe = vi.fn((disposable) => disposable);
api.registerEventHandler = vi.fn((_name: string, register: () => unknown) => register());
api.getSheetCommandTarget = vi.fn(() => ({ workbook, worksheet }));
api.fireEvent = vi.fn((name: string, params: any) => {
if (cancelEvents.has(name)) {
params.cancel = true;
}
fired.push({ name, params });
});
api._initialize(injector);
return { api, fired, afterListeners, beforeListeners, workbook, worksheet, threadComment };
}
describe('FUniverSheetsThreadCommentMixin', () => {
it('creates thread comment builders', () => {
const { api } = createApi();
const builder = api.newTheadComment().setId('comment-1');
expect(builder.id).toBe('comment-1');
});
it('fires public comment events from executed comment commands', () => {
const { api, fired, afterListeners, workbook, worksheet, threadComment } = createApi();
afterListeners.forEach((listener) => listener({ id: 'unrelated.command', params: {} }));
api.getSheetCommandTarget.mockReturnValueOnce(null);
afterListeners.forEach((listener) => listener({
id: AddCommentCommand.id,
params: { unitId: 'book-1', subUnitId: 'sheet-1', comment: { id: 'comment-1' } },
}));
afterListeners.forEach((listener) => listener({
id: AddCommentCommand.id,
params: { unitId: 'book-1', subUnitId: 'sheet-1', comment: { id: 'comment-1' } },
}));
afterListeners.forEach((listener) => listener({
id: UpdateCommentCommand.id,
params: { unitId: 'book-1', subUnitId: 'sheet-1', payload: { commentId: 'comment-1' } },
}));
afterListeners.forEach((listener) => listener({
id: DeleteCommentTreeCommand.id,
params: { unitId: 'book-1', subUnitId: 'sheet-1', commentId: 'comment-1' },
}));
afterListeners.forEach((listener) => listener({
id: ResolveCommentCommand.id,
params: { unitId: 'book-1', subUnitId: 'sheet-1', commentId: 'comment-1', resolved: true },
}));
afterListeners.forEach((listener) => listener({
id: AddCommentCommand.id,
params: { unitId: 'book-1', subUnitId: 'sheet-1', comment: { id: 'missing' } },
}));
afterListeners.forEach((listener) => listener({
id: UpdateCommentCommand.id,
params: { unitId: 'book-1', subUnitId: 'sheet-1', payload: { commentId: 'missing' } },
}));
afterListeners.forEach((listener) => listener({
id: ResolveCommentCommand.id,
params: { unitId: 'book-1', subUnitId: 'sheet-1', commentId: 'missing', resolved: true },
}));
expect(fired).toEqual([
{ name: 'CommentAdded', params: { workbook, worksheet, row: 2, col: 3, comment: threadComment } },
{ name: 'CommentUpdated', params: { workbook, worksheet, row: 2, col: 3, comment: threadComment } },
{ name: 'CommentDeleted', params: { workbook, worksheet, commentId: 'comment-1' } },
{ name: 'CommentResolved', params: { workbook, worksheet, row: 2, col: 3, comment: threadComment, resolved: true } },
]);
});
it('fires before comment events and cancels commands when listeners request it', () => {
const body = RichTextBuilder.newEmptyData().body!;
const { api, fired, beforeListeners } = createApi();
beforeListeners.forEach((listener) => listener({ id: 'unrelated.command', params: {} }));
api.getSheetCommandTarget.mockReturnValueOnce(null);
beforeListeners.forEach((listener) => listener({
id: AddCommentCommand.id,
params: { unitId: 'book-1', subUnitId: 'sheet-1', comment: { id: 'comment-1', ref: 'B2', text: body } },
}));
beforeListeners.forEach((listener) => listener({
id: AddCommentCommand.id,
params: { unitId: 'book-1', subUnitId: 'sheet-1', comment: { id: 'comment-1', ref: 'B2', text: body } },
}));
beforeListeners.forEach((listener) => listener({
id: UpdateCommentCommand.id,
params: { unitId: 'book-1', subUnitId: 'sheet-1', payload: { commentId: 'comment-1', text: body } },
}));
beforeListeners.forEach((listener) => listener({
id: DeleteCommentCommand.id,
params: { unitId: 'book-1', subUnitId: 'sheet-1', commentId: 'comment-1' },
}));
beforeListeners.forEach((listener) => listener({
id: ResolveCommentCommand.id,
params: { unitId: 'book-1', subUnitId: 'sheet-1', commentId: 'comment-1', resolved: false },
}));
beforeListeners.forEach((listener) => listener({
id: UpdateCommentCommand.id,
params: { unitId: 'book-1', subUnitId: 'sheet-1', payload: { commentId: 'missing', text: body } },
}));
beforeListeners.forEach((listener) => listener({
id: DeleteCommentCommand.id,
params: { unitId: 'book-1', subUnitId: 'sheet-1', commentId: 'missing' },
}));
beforeListeners.forEach((listener) => listener({
id: ResolveCommentCommand.id,
params: { unitId: 'book-1', subUnitId: 'sheet-1', commentId: 'missing', resolved: false },
}));
expect(fired.map((event) => event.name)).toEqual([
'BeforeCommentAdd',
'BeforeCommentUpdate',
'BeforeCommentDelete',
'BeforeCommentResolve',
]);
const canceled = createApi(new Set(['BeforeCommentAdd']));
expect(() => {
canceled.beforeListeners.forEach((listener) => listener({
id: AddCommentCommand.id,
params: { unitId: 'book-1', subUnitId: 'sheet-1', comment: { id: 'comment-1', ref: 'A1', text: body } },
}));
}).toThrow();
});
});

View File

@@ -0,0 +1,40 @@
/**
* 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, vi } from 'vitest';
import { FWorkbookSheetsThreadCommentMixin } from '../f-workbook';
describe('FWorkbookSheetsThreadCommentMixin', () => {
it('should lazily resolve the thread comment model and create workbook comment facades', async () => {
const rootComment = { id: 'comment-1' };
const deleteAsync = vi.fn(async () => true);
const getUnit = vi.fn(() => [{ root: rootComment }]);
const createInstance = vi.fn(() => ({ deleteAsync }));
const get = vi.fn(() => ({ getUnit }));
const instance = Object.create(FWorkbookSheetsThreadCommentMixin.prototype) as any;
instance._injector = { get, createInstance };
instance._workbook = { getUnitId: () => 'unit-1' };
instance._initialize();
expect(instance._threadCommentModel.getUnit('unit-1')).toEqual([{ root: rootComment }]);
expect(instance.getComments()).toEqual([{ deleteAsync }]);
expect(await instance.clearComments()).toBe(true);
expect(deleteAsync).toHaveBeenCalledTimes(1);
expect(createInstance).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,346 @@
/**
* 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 { RichTextValue } from '@univerjs/core';
import type { IEventBase } from '@univerjs/core/facade';
import type { FWorkbook, FWorksheet } from '@univerjs/sheets/facade';
import type { FTheadCommentItem, FThreadComment } from './f-thread-comment';
import { FEventName } from '@univerjs/core/facade';
/**
* @ignore
*/
interface IFSheetsThreadCommentEventNameMixin {
/**
* Event fired after comment added
* @see {@link ISheetCommentAddEventParams}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.CommentAdded, (params) => {
* const { comment, workbook, worksheet, row, col } = params;
* console.log(params);
* });
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly CommentAdded: 'CommentAdded';
/**
* Event fired before comment added
* @see {@link IBeforeSheetCommentAddEventParams}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.BeforeCommentAdd, (params) => {
* const { comment, workbook, worksheet, row, col } = params;
* console.log(params);
*
* // Cancel the comment add operation
* params.cancel = true;
* });
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly BeforeCommentAdd: 'BeforeCommentAdd';
/**
* Event fired after comment updated
* @see {@link ISheetCommentUpdateEventParams}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.CommentUpdated, (params) => {
* const { comment, workbook, worksheet, row, col } = params;
* console.log(params);
* });
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly CommentUpdated: 'CommentUpdated';
/**
* Event fired before comment update
* @see {@link IBeforeSheetCommentUpdateEventParams}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.BeforeCommentUpdate, (params) => {
* const { comment, workbook, worksheet, row, col, newContent } = params;
* console.log(params);
*
* // Cancel the comment update operation
* params.cancel = true;
* });
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly BeforeCommentUpdate: 'BeforeCommentUpdate';
/**
* Event fired after comment deleted
* @see {@link ISheetCommentDeleteEventParams}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.CommentDeleted, (params) => {
* const { commentId, workbook, worksheet } = params;
* console.log(params);
* });
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly CommentDeleted: 'CommentDeleted';
/**
* Event fired before comment delete
* @see {@link IBeforeSheetCommentDeleteEventParams}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.BeforeCommentDelete, (params) => {
* const { comment, workbook, worksheet, row, col } = params;
* console.log(params);
*
* // Cancel the comment delete operation
* params.cancel = true;
* });
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly BeforeCommentDelete: 'BeforeCommentDelete';
/**
* Event fired after comment resolve
* @see {@link ISheetCommentResolveEventParams}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.CommentResolved, (params) => {
* const { comment, row, col, resolved, workbook, worksheet } = params;
* console.log(params);
* });
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly CommentResolved: 'CommentResolved';
/**
* Event fired before comment resolve
* @see {@link ISheetCommentResolveEventParams}
* @example
* ```ts
* const disposable = univerAPI.addEvent(univerAPI.Event.BeforeCommentResolve, (params) => {
* const { comment, row, col, resolved, workbook, worksheet } = params;
* console.log(params);
*
* // Cancel the comment resolve operation
* params.cancel = true;
* });
*
* // Remove the event listener, use `disposable.dispose()`
* ```
*/
readonly BeforeCommentResolve: 'BeforeCommentResolve';
}
/**
* @ignore
*/
export class FSheetsThreadCommentEventNameMixin extends FEventName implements IFSheetsThreadCommentEventNameMixin {
override get CommentAdded(): 'CommentAdded' {
return 'CommentAdded' as const;
}
override get BeforeCommentAdd(): 'BeforeCommentAdd' {
return 'BeforeCommentAdd' as const;
}
override get CommentUpdated(): 'CommentUpdated' {
return 'CommentUpdated' as const;
}
override get BeforeCommentUpdate(): 'BeforeCommentUpdate' {
return 'BeforeCommentUpdate' as const;
}
override get CommentDeleted(): 'CommentDeleted' {
return 'CommentDeleted' as const;
}
override get BeforeCommentDelete(): 'BeforeCommentDelete' {
return 'BeforeCommentDelete' as const;
}
override get CommentResolved(): 'CommentResolved' {
return 'CommentResolved' as const;
}
override get BeforeCommentResolve(): 'BeforeCommentResolve' {
return 'BeforeCommentResolve' as const;
}
}
/**
* Event interface triggered after a comment is added to a sheet
* @interface ISheetCommentAddEventParams
* @augments {IEventBase}
*/
export interface ISheetCommentAddEventParams extends IEventBase {
/** The workbook instance */
workbook: FWorkbook;
/** The worksheet where the comment is added */
worksheet: FWorksheet;
/** Row index of the comment */
row: number;
/** Column index of the comment */
col: number;
/** The added comment object */
comment: FThreadComment;
}
/**
* Event interface triggered before a comment is added to a sheet
* @interface IBeforeSheetCommentAddEventParams
* @augments {IEventBase}
*/
export interface IBeforeSheetCommentAddEventParams extends IEventBase {
/** The workbook instance */
workbook: FWorkbook;
/** The worksheet where the comment will be added */
worksheet: FWorksheet;
/** Row index for the new comment */
row: number;
/** Column index for the new comment */
col: number;
/** The comment item to be added */
comment: FTheadCommentItem;
}
/**
* Event interface triggered after a comment is updated in a sheet
* @interface ISheetCommentUpdateEventParams
* @augments {IEventBase}
*/
export interface ISheetCommentUpdateEventParams extends IEventBase {
/** The workbook instance */
workbook: FWorkbook;
/** The worksheet containing the updated comment */
worksheet: FWorksheet;
/** Row index of the comment */
row: number;
/** Column index of the comment */
col: number;
/** The updated comment object */
comment: FThreadComment;
}
/**
* Event interface triggered before a comment is updated in a sheet
* @interface IBeforeSheetCommentUpdateEventParams
* @augments {IEventBase}
*/
export interface IBeforeSheetCommentUpdateEventParams extends IEventBase {
/** The workbook instance */
workbook: FWorkbook;
/** The worksheet containing the comment */
worksheet: FWorksheet;
/** Row index of the comment */
row: number;
/** Column index of the comment */
col: number;
/** The current comment object */
comment: FThreadComment;
/** The new content to replace the existing comment */
newContent: RichTextValue;
}
/**
* Event interface triggered before a comment is deleted from a sheet
* @interface IBeforeSheetCommentDeleteEventParams
* @augments {IEventBase}
*/
export interface IBeforeSheetCommentDeleteEventParams extends IEventBase {
/** The workbook instance */
workbook: FWorkbook;
/** The worksheet containing the comment */
worksheet: FWorksheet;
/** Row index of the comment */
row: number;
/** Column index of the comment */
col: number;
/** The comment to be deleted */
comment: FThreadComment;
}
/**
* Event interface triggered after a comment is deleted from a sheet
* @interface ISheetCommentDeleteEventParams
* @augments {IEventBase}
*/
export interface ISheetCommentDeleteEventParams extends IEventBase {
/** The workbook instance */
workbook: FWorkbook;
/** The worksheet that contained the comment */
worksheet: FWorksheet;
/** The ID of the deleted comment */
commentId: string;
}
/**
* Event interface triggered when a comment's resolve status changes
* @interface ISheetCommentResolveEventParams
* @augments {IEventBase}
*/
export interface ISheetCommentResolveEventParams extends IEventBase {
/** The workbook instance */
workbook: FWorkbook;
/** The worksheet containing the comment */
worksheet: FWorksheet;
/** Row index of the comment */
row: number;
/** Column index of the comment */
col: number;
/** The comment object */
comment: FThreadComment;
/** The new resolve status */
resolved: boolean;
}
/**
* @ignore
*/
export interface ISheetsThreadCommentEventParamConfig {
BeforeCommentAdd: IBeforeSheetCommentAddEventParams;
CommentAdded: ISheetCommentAddEventParams;
BeforeCommentUpdate: IBeforeSheetCommentUpdateEventParams;
CommentUpdated: ISheetCommentUpdateEventParams;
BeforeCommentDelete: IBeforeSheetCommentDeleteEventParams;
CommentDeleted: ISheetCommentDeleteEventParams;
BeforeCommentResolve: ISheetCommentResolveEventParams;
CommentResolved: ISheetCommentResolveEventParams;
}
FEventName.extend(FSheetsThreadCommentEventNameMixin);
declare module '@univerjs/core/facade' {
// eslint-disable-next-line ts/naming-convention
interface FEventName extends IFSheetsThreadCommentEventNameMixin {}
interface IEventParamConfig extends ISheetsThreadCommentEventParamConfig { }
}

View File

@@ -0,0 +1,254 @@
/**
* 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 * as UniverCore from '@univerjs/core';
import { SheetsThreadCommentModel } from '@univerjs/sheets-thread-comment';
import { FRange } from '@univerjs/sheets/facade';
import * as ThreadComment from '@univerjs/thread-comment';
import { FTheadCommentBuilder, FThreadComment } from './f-thread-comment';
export type ISheetCellCommentCreateOptions = Pick<
ThreadComment.ICreateThreadCommentOptions,
'attachments' | 'dateTime' | 'id' | 'personId' | 'threadId'
>;
interface IRangeCommentDependencies {
commandService: UniverCore.ICommandService;
model: SheetsThreadCommentModel;
userManagerService: UniverCore.UserManagerService;
}
function firstNonEmpty(...values: Array<string | undefined>): string {
for (const value of values) {
if (value) {
return value;
}
}
return UniverCore.generateRandomId();
}
/**
* @ignore
*/
export interface IFRangeSheetsThreadCommentMixin {
/**
* Get the comment of the start cell in the current range.
* @returns {FThreadComment | null} The comment of the start cell in the current range. If the cell does not have a comment, return `null`.
* @example
* ```ts
* const fWorksheet = univerAPI.getActiveWorkbook().getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const range = fWorksheet.getActiveRange();
* const comment = range.getComment();
* ```
*/
getComment(): UniverCore.Nullable<FThreadComment>;
/**
* Get the comments in the current range.
* @returns {FThreadComment[]} The comments in the current range.
* @example
* ```ts
* const fWorksheet = univerAPI.getActiveWorkbook().getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const range = fWorksheet.getActiveRange();
* const comments = range.getComments();
* comments.forEach((comment) => {
* console.log(comment.getRichText());
* });
* ```
*/
getComments(): FThreadComment[];
/**
* Add a comment to the start cell in the current range.
* @param content The content of the comment.
* @param [options] Optional stable IDs, author, attachments, and creation time.
* @returns {Promise<boolean>} Whether the comment is added successfully.
* @throws {TypeError} If the content is empty.
* @example
* ```ts
* await univerAPI.getActiveWorkbook()
* .getActiveSheet()
* .getRange('A1')
* .addCommentAsync('Verify this value.', { id: 'review-a1' });
*
* // Create a new comment
* const richText = univerAPI.newRichText().insertText('hello univer');
* const commentBuilder = univerAPI.newTheadComment()
* .setContent(richText);
* console.log(commentBuilder.content.toPlainText());
*
* // Add the comment to the cell A1
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const cell = fWorksheet.getRange('A1');
* const result = await cell.addCommentAsync(commentBuilder);
* console.log(result);
* ```
*/
addCommentAsync(
content: ThreadComment.ThreadCommentContent | FTheadCommentBuilder,
options?: ISheetCellCommentCreateOptions
): Promise<boolean>;
/**
* Clear the comment of the start cell in the current range.
* @returns {Promise<boolean>} Whether the comment is cleared successfully.
* @example
* ```ts
* const range = univerAPI.getActiveWorkbook().getActiveSheet().getRange('A1');
* const success = await range.clearCommentAsync();
* console.log(success);
* ```
*/
clearCommentAsync(): Promise<boolean>;
/**
* Clear all of the comments in the current range.
* @returns {Promise<boolean>} Whether the comments are cleared successfully.
* @example
* ```ts
* const fWorksheet = univerAPI.getActiveWorkbook().getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const range = fWorksheet.getActiveRange();
* const success = await range.clearCommentsAsync();
* ```
*/
clearCommentsAsync(): Promise<boolean>;
}
/**
* @ignore
*/
export class FRangeSheetsThreadCommentMixin extends FRange implements IFRangeSheetsThreadCommentMixin {
declare private _dependencies: IRangeCommentDependencies;
override _initialize(injector: UniverCore.Injector): void {
let dependencies: IRangeCommentDependencies | undefined;
Object.defineProperty(this, '_dependencies', {
get: () => dependencies ??= {
commandService: injector.get(UniverCore.ICommandService),
model: injector.get(SheetsThreadCommentModel),
userManagerService: injector.get(UniverCore.UserManagerService),
},
});
}
private _getCommentDataInRange(): ThreadComment.IThreadComment[] {
const model = this._dependencies.model;
const unitId = this._workbook.getUnitId();
const sheetId = this._worksheet.getSheetId();
const comments: ThreadComment.IThreadComment[] = [];
UniverCore.Range.foreach(this._range, (row, col) => {
const commentId = model.getByLocation(unitId, sheetId, row, col);
const comment = commentId ? model.getComment(unitId, sheetId, commentId) : null;
if (comment) {
comments.push(comment);
}
});
return comments;
}
private _getStartCellCommentData(): ThreadComment.IThreadComment | null {
const model = this._dependencies.model;
const unitId = this._workbook.getUnitId();
const sheetId = this._worksheet.getSheetId();
const commentId = model.getByLocation(unitId, sheetId, this._range.startRow, this._range.startColumn);
return commentId ? model.getComment(unitId, sheetId, commentId) ?? null : null;
}
override getComment(): UniverCore.Nullable<FThreadComment> {
const comment = this._getStartCellCommentData();
return comment ? this._injector.createInstance(FThreadComment, comment) : null;
}
override getComments(): FThreadComment[] {
return this._getCommentDataInRange().map((comment) => this._injector.createInstance(FThreadComment, comment));
}
override addCommentAsync(
content: ThreadComment.ThreadCommentContent | FTheadCommentBuilder,
options: ISheetCellCommentCreateOptions = {}
): Promise<boolean> {
const { commandService, userManagerService } = this._dependencies;
const currentComment = this._getStartCellCommentData();
const unitId = this._workbook.getUnitId();
const sheetId = this._worksheet.getSheetId();
const refStr = `${UniverCore.Tools.chatAtABC(this._range.startColumn)}${this._range.startRow + 1}`;
const currentUser = userManagerService.getCurrentUser();
let commentData: ThreadComment.IThreadComment | null = null;
let text: UniverCore.IDocumentBody;
if (content instanceof FTheadCommentBuilder) {
commentData = content.build();
text = commentData.text;
} else {
text = ThreadComment.normalizeThreadCommentContent(content);
}
return commandService.executeCommand(ThreadComment.AddCommentCommand.id, {
unitId,
subUnitId: sheetId,
comment: {
text,
dT: options.dateTime ? ThreadComment.getDT(options.dateTime) : commentData?.dT || ThreadComment.getDT(),
attachments: options.attachments ?? commentData?.attachments ?? [],
id: firstNonEmpty(options.id, commentData?.id),
ref: refStr,
personId: firstNonEmpty(options.personId, commentData?.personId, currentUser.userID),
parentId: currentComment?.id,
unitId,
subUnitId: sheetId,
threadId: firstNonEmpty(currentComment?.threadId, options.threadId, commentData?.threadId),
},
});
}
override clearCommentAsync(): Promise<boolean> {
const { commandService } = this._dependencies;
const currentComment = this._getStartCellCommentData();
const unitId = this._workbook.getUnitId();
const sheetId = this._worksheet.getSheetId();
if (currentComment) {
return commandService.executeCommand(ThreadComment.DeleteCommentTreeCommand.id, {
unitId,
subUnitId: sheetId,
threadId: currentComment.threadId,
commentId: currentComment.id,
});
}
return Promise.resolve(true);
}
override async clearCommentsAsync(): Promise<boolean> {
const { commandService } = this._dependencies;
const unitId = this._workbook.getUnitId();
const subUnitId = this._worksheet.getSheetId();
const results = await Promise.all(this._getCommentDataInRange().map((comment) => commandService.executeCommand(
ThreadComment.DeleteCommentTreeCommand.id,
{ unitId, subUnitId, threadId: comment.threadId, commentId: comment.id }
)));
return results.every(Boolean);
}
}
FRange.extend(FRangeSheetsThreadCommentMixin);
declare module '@univerjs/sheets/facade' {
interface FRange extends IFRangeSheetsThreadCommentMixin { }
}

View File

@@ -0,0 +1,602 @@
/**
* 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, IRange, Workbook } from '@univerjs/core';
import type { IAddCommentCommandParams, IBaseComment, IDeleteCommentCommandParams, IResolveCommentCommandParams, IThreadComment, IUpdateCommentCommandParams } from '@univerjs/thread-comment';
import { generateRandomId, ICommandService, Inject, Injector, IUniverInstanceService, RichTextBuilder, RichTextValue, Tools, UniverInstanceType, UserManagerService } from '@univerjs/core';
import { deserializeRangeWithSheet } from '@univerjs/engine-formula';
import { SheetsThreadCommentModel } from '@univerjs/sheets-thread-comment';
import { FRange } from '@univerjs/sheets/facade';
import { AddCommentCommand, DeleteCommentCommand, DeleteCommentTreeCommand, getDT, ResolveCommentCommand, UpdateCommentCommand } from '@univerjs/thread-comment';
/**
* An readonly class that represents a comment.
* @ignore
*/
export class FTheadCommentItem {
protected _comment: IThreadComment = {
id: generateRandomId(),
ref: '',
threadId: '',
dT: '',
personId: '',
text: RichTextBuilder.newEmptyData().body!,
attachments: [],
unitId: '',
subUnitId: '',
};
/**
* Create a new FTheadCommentItem
* @param {IThreadComment|undefined} [comment] The comment
* @returns {FTheadCommentItem} A new instance of FTheadCommentItem
* @example
* ```ts
* const commentBuilder = univerAPI.newTheadComment();
* console.log(commentBuilder);
* ```
*/
static create(comment?: IThreadComment): FTheadCommentItem {
return new FTheadCommentItem(comment);
}
constructor(comment?: IThreadComment) {
if (comment) {
this._comment = comment;
}
}
/**
* Get the person id of the comment
* @returns {string} The person id of the comment
* @example
* ```ts
* const commentBuilder = univerAPI.newTheadComment();
* console.log(commentBuilder.personId);
* ```
*/
get personId(): string {
return this._comment.personId;
}
/**
* Get the date time of the comment
* @returns {string} The date time of the comment
* @example
* ```ts
* const commentBuilder = univerAPI.newTheadComment();
* console.log(commentBuilder.dateTime);
* ```
*/
get dateTime(): string {
return this._comment.dT;
}
/**
* Get the content of the comment
* @returns {RichTextValue} The content of the comment
* @example
* ```ts
* const commentBuilder = univerAPI.newTheadComment();
* console.log(commentBuilder.content);
* ```
*/
get content(): RichTextValue {
return RichTextValue.createByBody(this._comment.text);
}
/**
* Get the id of the comment
* @returns {string} The id of the comment
* @example
* ```ts
* const commentBuilder = univerAPI.newTheadComment();
* console.log(commentBuilder.id);
* ```
*/
get id(): string {
return this._comment.id;
}
/**
* Get the thread id of the comment
* @returns {string} The thread id of the comment
* @example
* ```ts
* const commentBuilder = univerAPI.newTheadComment();
* console.log(commentBuilder.threadId);
* ```
*/
get threadId(): string {
return this._comment.threadId;
}
/**
* Copy the comment
* @returns {FTheadCommentBuilder} The comment builder
* @example
* ```ts
* const commentBuilder = univerAPI.newTheadComment();
* const newCommentBuilder = commentBuilder.copy();
* console.log(newCommentBuilder);
* ```
*/
copy(): FTheadCommentBuilder {
return FTheadCommentBuilder.create(Tools.deepClone(this._comment));
}
}
/**
* A builder for thread comment. use {@link FUniver} `univerAPI.newTheadComment()` to create a new builder.
*/
export class FTheadCommentBuilder extends FTheadCommentItem {
static override create(comment?: IThreadComment): FTheadCommentBuilder {
return new FTheadCommentBuilder(comment);
}
/**
* Set the content of the comment
* @param {IDocumentBody | RichTextValue} content The content of the comment
* @returns {FTheadCommentBuilder} The comment builder for chaining
* @example
* ```ts
* // Create a new comment
* const richText = univerAPI.newRichText().insertText('hello univer');
* const commentBuilder = univerAPI.newTheadComment()
* .setContent(richText);
* console.log(commentBuilder.content);
*
* // Add the comment to the cell A1
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const cell = fWorksheet.getRange('A1');
* const result = await cell.addCommentAsync(commentBuilder);
* console.log(result);
* ```
*/
setContent(content: IDocumentBody | RichTextValue): FTheadCommentBuilder {
if (content instanceof RichTextValue) {
this._comment.text = content.getData().body!;
} else {
this._comment.text = content;
}
return this;
}
/**
* Set the person id of the comment
* @param {string} userId The person id of the comment
* @returns {FTheadCommentBuilder} The comment builder for chaining
* @example
* ```ts
* // Create a new comment
* const richText = univerAPI.newRichText().insertText('hello univer');
* const commentBuilder = univerAPI.newTheadComment()
* .setContent(richText)
* .setPersonId('mock-user-id');
* console.log(commentBuilder.personId);
*
* // Add the comment to the cell A1
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const cell = fWorksheet.getRange('A1');
* const result = await cell.addCommentAsync(commentBuilder);
* console.log(result);
* ```
*/
setPersonId(userId: string): FTheadCommentBuilder {
this._comment.personId = userId;
return this;
}
/**
* Set the date time of the comment
* @param {Date} date The date time of the comment
* @returns {FTheadCommentBuilder} The comment builder for chaining
* @example
* ```ts
* // Create a new comment
* const richText = univerAPI.newRichText().insertText('hello univer');
* const commentBuilder = univerAPI.newTheadComment()
* .setContent(richText)
* .setDateTime(new Date('2025-02-21 14:22:22'));
* console.log(commentBuilder.dateTime);
*
* // Add the comment to the cell A1
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const cell = fWorksheet.getRange('A1');
* const result = await cell.addCommentAsync(commentBuilder);
* console.log(result);
* ```
*/
setDateTime(date: Date): FTheadCommentBuilder {
this._comment.dT = getDT(date);
return this;
}
/**
* Set the id of the comment
* @param {string} id The id of the comment
* @returns {FTheadCommentBuilder} The comment builder for chaining
* @example
* ```ts
* // Create a new comment
* const richText = univerAPI.newRichText().insertText('hello univer');
* const commentBuilder = univerAPI.newTheadComment()
* .setContent(richText)
* .setId('mock-comment-id');
* console.log(commentBuilder.id);
*
* // Add the comment to the cell A1
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const cell = fWorksheet.getRange('A1');
* const result = await cell.addCommentAsync(commentBuilder);
* console.log(result);
* ```
*/
setId(id: string): FTheadCommentBuilder {
this._comment.id = id;
return this;
}
/**
* Set the thread id of the comment
* @param {string} threadId The thread id of the comment
* @returns {FTheadCommentBuilder} The comment builder
* @example
* ```ts
* // Create a new comment
* const richText = univerAPI.newRichText().insertText('hello univer');
* const commentBuilder = univerAPI.newTheadComment()
* .setContent(richText)
* .setThreadId('mock-thread-id');
* console.log(commentBuilder.threadId);
*
* // Add the comment to the cell A1
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const cell = fWorksheet.getRange('A1');
* const result = await cell.addCommentAsync(commentBuilder);
* console.log(result);
* ```
*/
setThreadId(threadId: string): FTheadCommentBuilder {
this._comment.threadId = threadId;
return this;
}
/**
* Build the comment
* @returns {IThreadComment} The comment
* @example
* ```ts
* const richText = univerAPI.newRichText().insertText('hello univer');
* const comment = univerAPI.newTheadComment()
* .setContent(richText)
* .setPersonId('mock-user-id')
* .setDateTime(new Date('2025-02-21 14:22:22'))
* .setId('mock-comment-id')
* .setThreadId('mock-thread-id')
* .build();
* console.log(comment);
* ```
*/
build(): IThreadComment {
return this._comment;
}
}
/**
* A class that represents a thread comment already in the sheet.
*/
export class FThreadComment {
/**
* @ignore
*/
constructor(
private readonly _thread: IThreadComment | IBaseComment,
private readonly _parent: IThreadComment | undefined,
@Inject(Injector) private readonly _injector: Injector,
@ICommandService private readonly _commandService: ICommandService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@Inject(SheetsThreadCommentModel) private readonly _threadCommentModel: SheetsThreadCommentModel,
@Inject(UserManagerService) private readonly _userManagerService: UserManagerService
) {
}
private _getRef(): IRange {
const ref = this._parent?.ref || (this._thread as IThreadComment).ref;
const range = deserializeRangeWithSheet(ref);
return range.range;
}
/**
* Whether the comment is a root comment
* @returns {boolean} Whether the comment is a root comment
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const comments = fWorksheet.getComments();
* comments.forEach((comment) => {
* console.log(comment.getIsRoot());
* });
* ```
*/
getIsRoot(): boolean {
return !this._parent;
}
/**
* Get the comment data
* @returns {IBaseComment} The comment data
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const comments = fWorksheet.getComments();
* comments.forEach((comment) => {
* console.log(comment.getCommentData());
* });
* ```
*/
getCommentData(): IBaseComment {
const { children, ...comment } = this._thread;
return comment;
}
/**
* Get the replies of the comment
* @returns {FThreadComment[] | undefined} The loaded replies, or `undefined` when the thread or its replies are unavailable.
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const comments = fWorksheet.getComments();
* comments.forEach((comment) => {
* if (comment.getIsRoot()) {
* const replies = comment.getReplies();
* replies.forEach((reply) => {
* console.log(reply.getCommentData());
* });
* }
* });
* ```
*/
getReplies(): FThreadComment[] | undefined {
const range = this._getRef();
const comments = this._threadCommentModel.getCommentWithChildren(this._thread.unitId, this._thread.subUnitId, range.startRow, range.startColumn);
return comments?.children?.map((child) => this._injector.createInstance(FThreadComment, child));
}
/**
* Get the range of the comment
* @returns {FRange | null} The range of the comment
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const comments = fWorksheet.getComments();
* comments.forEach((comment) => {
* console.log(comment.getRange().getA1Notation());
* });
* ```
*/
getRange(): FRange | null {
const workbook = this._univerInstanceService.getUnit<Workbook>(this._thread.unitId, UniverInstanceType.UNIVER_SHEET);
if (!workbook) {
return null;
}
const worksheet = workbook.getSheetBySheetId(this._thread.subUnitId);
if (!worksheet) {
return null;
}
const range = this._getRef();
return this._injector.createInstance(FRange, workbook, worksheet, range);
}
/**
* Get the rich text of the comment
* @returns {RichTextValue} The rich text of the comment
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const comments = fWorksheet.getComments();
* comments.forEach((comment) => {
* console.log(comment.getRichText());
* });
* ```
*/
getRichText(): RichTextValue {
const body = this._thread.text;
return RichTextValue.create({ body, documentStyle: {}, id: 'd' });
}
/**
* Delete the comment and it's replies
* @returns {Promise<boolean>} Whether the comment is deleted successfully
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const comments = fWorksheet.getComments();
*
* // Delete the first comment
* const result = await comments[0]?.deleteAsync();
* console.log(result);
* ```
*/
deleteAsync(): Promise<boolean> {
return this._commandService.executeCommand(
this.getIsRoot() ? DeleteCommentTreeCommand.id : DeleteCommentCommand.id,
{
commentId: this._thread.id,
unitId: this._thread.unitId,
subUnitId: this._thread.subUnitId,
} as IDeleteCommentCommandParams
);
}
/**
* Update the comment content
* @param {IDocumentBody | RichTextValue} content The new content of the comment
* @returns {Promise<boolean>} Whether the comment is updated successfully
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Create a new comment
* const richText = univerAPI.newRichText().insertText('hello univer');
* const commentBuilder = univerAPI.newTheadComment()
* .setContent(richText)
* .setId('mock-comment-id');
* const cell = fWorksheet.getRange('A1');
* await cell.addCommentAsync(commentBuilder);
*
* // Update the comment after 3 seconds
* setTimeout(async () => {
* const comment = fWorksheet.getCommentById('mock-comment-id');
* const newRichText = univerAPI.newRichText().insertText('Hello Univer AI');
* const result = await comment.updateAsync(newRichText);
* console.log(result);
* }, 3000);
* ```
*/
async updateAsync(content: IDocumentBody | RichTextValue): Promise<boolean> {
const body = content instanceof RichTextValue ? content.getData().body : content;
const dt = getDT();
const res = await this._commandService.executeCommand(
UpdateCommentCommand.id,
{
unitId: this._thread.unitId,
subUnitId: this._thread.subUnitId,
payload: {
commentId: this._thread.id,
text: body,
updated: true,
updateT: dt,
},
} as IUpdateCommentCommandParams
);
return res;
}
/**
* Resolve the comment
* @param {boolean} [resolved] Whether the comment is resolved
* @returns {Promise<boolean>} Set the comment to resolved or not operation result
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Create a new comment
* const richText = univerAPI.newRichText().insertText('hello univer');
* const commentBuilder = univerAPI.newTheadComment()
* .setContent(richText)
* .setId('mock-comment-id');
* const cell = fWorksheet.getRange('A1');
* await cell.addCommentAsync(commentBuilder);
*
* // Resolve the comment after 3 seconds
* setTimeout(async () => {
* const comment = fWorksheet.getCommentById('mock-comment-id');
* const result = await comment.resolveAsync(true);
* console.log(result);
* }, 3000);
* ```
*/
resolveAsync(resolved?: boolean): Promise<boolean> {
return this._commandService.executeCommand(
ResolveCommentCommand.id,
{
unitId: this._thread.unitId,
subUnitId: this._thread.subUnitId,
commentId: this._thread.id,
resolved: resolved ?? !this._thread.resolved,
} as IResolveCommentCommandParams
);
}
/**
* Reply to the comment
* @param {FTheadCommentBuilder} comment The comment to reply to
* @returns {Promise<boolean>} Whether the comment is replied successfully
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Create a new comment
* const richText = univerAPI.newRichText().insertText('hello univer');
* const commentBuilder = univerAPI.newTheadComment()
* .setContent(richText)
* .setId('mock-comment-id');
* const cell = fWorksheet.getRange('A1');
* await cell.addCommentAsync(commentBuilder);
*
* // Reply to the comment
* const replyText = univerAPI.newRichText().insertText('Hello Univer AI');
* const reply = univerAPI.newTheadComment().setContent(replyText);
* const comment = fWorksheet.getCommentById('mock-comment-id');
* const result = await comment.replyAsync(reply);
* console.log(result);
* ```
*/
replyAsync(comment: FTheadCommentBuilder): Promise<boolean> {
const commentData = comment.build();
return this._commandService.executeCommand(
AddCommentCommand.id,
{
unitId: this._thread.unitId,
subUnitId: this._thread.subUnitId,
comment: {
id: generateRandomId(),
parentId: this._thread.id,
threadId: this._thread.threadId,
ref: this._parent?.ref || (this._thread as IThreadComment).ref,
unitId: this._thread.unitId,
subUnitId: this._thread.subUnitId,
text: commentData.text,
attachments: commentData.attachments,
dT: commentData.dT || getDT(),
personId: commentData.personId || this._userManagerService.getCurrentUser().userID,
},
} as IAddCommentCommandParams
);
}
}

View File

@@ -0,0 +1,325 @@
/**
* 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 type { IAddCommentCommandParams, IDeleteCommentCommandParams, IResolveCommentCommandParams, IThreadComment, IUpdateCommentCommandParams } from '@univerjs/thread-comment';
import type {
IBeforeSheetCommentAddEventParams,
IBeforeSheetCommentDeleteEventParams,
IBeforeSheetCommentUpdateEventParams,
ISheetCommentAddEventParams,
ISheetCommentDeleteEventParams,
ISheetCommentResolveEventParams,
ISheetCommentUpdateEventParams,
} from './f-event';
import { CanceledError, ICommandService, RichTextValue } from '@univerjs/core';
import { FUniver } from '@univerjs/core/facade';
import { deserializeRangeWithSheet } from '@univerjs/engine-formula';
import { AddCommentCommand, DeleteCommentCommand, DeleteCommentTreeCommand, ResolveCommentCommand, UpdateCommentCommand } from '@univerjs/thread-comment';
import { FTheadCommentBuilder, FTheadCommentItem } from './f-thread-comment';
/**
* @ignore
*/
export interface IFUniverSheetsThreadCommentMixin {
/**
* Create a new thread comment
* @returns {FTheadCommentBuilder} The thead comment builder
* @example
* ```ts
* // Create a new comment
* const richText = univerAPI.newRichText().insertText('hello univer');
* const commentBuilder = univerAPI.newTheadComment()
* .setContent(richText)
* .setPersonId('mock-user-id')
* .setDateTime(new Date('2025-02-21 14:22:22'))
* .setId('mock-comment-id')
* .setThreadId('mock-thread-id');
*
* // Add the comment to the cell A1
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const cell = fWorksheet.getRange('A1');
* const result = await cell.addCommentAsync(commentBuilder);
* console.log(result);
* ```
*/
newTheadComment(): FTheadCommentBuilder;
}
/**
* @ignore
*/
export class FUniverSheetsThreadCommentMixin extends FUniver implements IFUniverSheetsThreadCommentMixin {
// eslint-disable-next-line max-lines-per-function
override _initialize(injector: Injector): void {
const commandService = injector.get(ICommandService);
// After command events
this.disposeWithMe(
this.registerEventHandler(
this.Event.CommentAdded,
() => commandService.onCommandExecuted((commandInfo) => {
if (commandInfo.id !== AddCommentCommand.id) return;
const params = commandInfo.params as IAddCommentCommandParams;
const target = this.getSheetCommandTarget(params);
if (!target) return;
const { workbook, worksheet } = target;
const { comment } = params;
const threadComment = worksheet.getCommentById(comment.id);
if (!threadComment) return;
const eventParams: ISheetCommentAddEventParams = {
workbook,
worksheet,
row: threadComment.getRange()?.getRow() ?? 0,
col: threadComment.getRange()?.getColumn() ?? 0,
comment: threadComment,
};
this.fireEvent(this.Event.CommentAdded, eventParams);
})
)
);
this.disposeWithMe(
this.registerEventHandler(
this.Event.CommentUpdated,
() => commandService.onCommandExecuted((commandInfo) => {
if (commandInfo.id !== UpdateCommentCommand.id) return;
const params = commandInfo.params as IUpdateCommentCommandParams;
const target = this.getSheetCommandTarget(params);
if (!target) return;
const { workbook, worksheet } = target;
const { payload } = params;
const threadComment = worksheet.getCommentById(payload.commentId);
if (!threadComment) return;
const eventParams: ISheetCommentUpdateEventParams = {
workbook,
worksheet,
row: threadComment.getRange()?.getRow() ?? 0,
col: threadComment.getRange()?.getColumn() ?? 0,
comment: threadComment,
};
this.fireEvent(this.Event.CommentUpdated, eventParams);
})
)
);
this.disposeWithMe(
this.registerEventHandler(
this.Event.CommentDeleted,
() => commandService.onCommandExecuted((commandInfo) => {
if (commandInfo.id !== DeleteCommentCommand.id && commandInfo.id !== DeleteCommentTreeCommand.id) return;
const params = commandInfo.params as IDeleteCommentCommandParams;
const target = this.getSheetCommandTarget(params);
if (!target) return;
const { workbook, worksheet } = target;
const { commentId } = params;
const eventParams: ISheetCommentDeleteEventParams = {
workbook,
worksheet,
commentId,
};
this.fireEvent(this.Event.CommentDeleted, eventParams);
})
)
);
this.disposeWithMe(
this.registerEventHandler(
this.Event.CommentResolved,
() => commandService.onCommandExecuted((commandInfo) => {
if (commandInfo.id !== ResolveCommentCommand.id) return;
const params = commandInfo.params as IResolveCommentCommandParams;
const target = this.getSheetCommandTarget(params);
if (!target) return;
const { workbook, worksheet } = target;
const { commentId, resolved } = params;
const threadComment = worksheet.getCommentById(commentId);
if (!threadComment) return;
const eventParams: ISheetCommentResolveEventParams = {
workbook,
worksheet,
row: threadComment.getRange()?.getRow() ?? 0,
col: threadComment.getRange()?.getColumn() ?? 0,
comment: threadComment,
resolved,
};
this.fireEvent(this.Event.CommentResolved, eventParams);
})
)
);
// Before command events
this.disposeWithMe(
this.registerEventHandler(
this.Event.BeforeCommentAdd,
() => commandService.beforeCommandExecuted((commandInfo) => {
if (commandInfo.id !== AddCommentCommand.id) return;
const params = commandInfo.params as IAddCommentCommandParams;
const target = this.getSheetCommandTarget(params);
if (!target) return;
const { workbook, worksheet } = target;
const { comment } = params;
const { range } = deserializeRangeWithSheet(comment.ref);
const eventParams: IBeforeSheetCommentAddEventParams = {
workbook,
worksheet,
row: range.startRow,
col: range.startColumn,
comment: FTheadCommentItem.create(comment),
};
this.fireEvent(this.Event.BeforeCommentAdd, eventParams);
if (eventParams.cancel) {
throw new CanceledError();
}
})
)
);
this.disposeWithMe(
this.registerEventHandler(
this.Event.BeforeCommentUpdate,
() => commandService.beforeCommandExecuted((commandInfo) => {
if (commandInfo.id !== UpdateCommentCommand.id) return;
const params = commandInfo.params as IUpdateCommentCommandParams;
const target = this.getSheetCommandTarget(params);
if (!target) return;
const { workbook, worksheet } = target;
const { payload } = params;
const threadComment = worksheet.getCommentById(payload.commentId);
if (!threadComment) return;
const eventParams: IBeforeSheetCommentUpdateEventParams = {
workbook,
worksheet,
row: threadComment.getRange()?.getRow() ?? 0,
col: threadComment.getRange()?.getColumn() ?? 0,
comment: threadComment,
newContent: RichTextValue.createByBody(payload.text),
};
this.fireEvent(this.Event.BeforeCommentUpdate, eventParams);
if (eventParams.cancel) {
throw new CanceledError();
}
})
)
);
this.disposeWithMe(
this.registerEventHandler(
this.Event.BeforeCommentDelete,
() => commandService.beforeCommandExecuted((commandInfo) => {
if (commandInfo.id !== DeleteCommentCommand.id && commandInfo.id !== DeleteCommentTreeCommand.id) return;
const params = commandInfo.params as IDeleteCommentCommandParams;
const target = this.getSheetCommandTarget(params);
if (!target) return;
const { workbook, worksheet } = target;
const { commentId } = params;
const threadComment = worksheet.getCommentById(commentId);
if (!threadComment) return;
const eventParams: IBeforeSheetCommentDeleteEventParams = {
workbook,
worksheet,
row: threadComment.getRange()?.getRow() ?? 0,
col: threadComment.getRange()?.getColumn() ?? 0,
comment: threadComment,
};
this.fireEvent(this.Event.BeforeCommentDelete, eventParams);
if (eventParams.cancel) {
throw new CanceledError();
}
})
)
);
this.disposeWithMe(
this.registerEventHandler(
this.Event.BeforeCommentResolve,
() => commandService.beforeCommandExecuted((commandInfo) => {
if (commandInfo.id !== ResolveCommentCommand.id) return;
const params = commandInfo.params as IResolveCommentCommandParams;
const target = this.getSheetCommandTarget(params);
if (!target) return;
const { workbook, worksheet } = target;
const { commentId, resolved } = params;
const threadComment = worksheet.getCommentById(commentId);
if (!threadComment) return;
const eventParams: ISheetCommentResolveEventParams = {
workbook,
worksheet,
row: threadComment.getRange()!.getRow() ?? 0,
col: threadComment.getRange()!.getColumn() ?? 0,
comment: threadComment,
resolved,
};
this.fireEvent(this.Event.BeforeCommentResolve, eventParams);
if (eventParams.cancel) {
throw new CanceledError();
}
})
)
);
}
/**
* @ignore
* @returns {FTheadCommentBuilder} The thread comment builder.
*/
override newTheadComment(comment?: IThreadComment): FTheadCommentBuilder {
return new FTheadCommentBuilder(comment);
}
}
FUniver.extend(FUniverSheetsThreadCommentMixin);
declare module '@univerjs/core/facade' {
// eslint-disable-next-line ts/naming-convention
interface FUniver extends IFUniverSheetsThreadCommentMixin {}
}

View File

@@ -0,0 +1,94 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { FWorkbook } from '@univerjs/sheets/facade';
import { ThreadCommentModel } from '@univerjs/thread-comment';
import { FThreadComment } from './f-thread-comment';
/**
* @ignore
*/
export interface IFWorkbookSheetsThreadCommentMixin {
/**
* Get all comments in the current workbook
* @returns {FThreadComment[]} All comments in the current workbook
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const comments = fWorkbook.getComments();
* comments.forEach((comment) => {
* const isRoot = comment.getIsRoot();
*
* if (isRoot) {
* console.log('root comment:', comment.getCommentData());
*
* const replies = comment.getReplies();
* replies.forEach((reply) => {
* console.log('reply comment:', reply.getCommentData());
* });
* }
* });
* ```
*/
getComments(): FThreadComment[];
/**
* Clear all comments in the current workbook
* @returns {Promise<boolean>} Whether the comments are cleared successfully.
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const result = await fWorkbook.clearComments();
* console.log(result);
* ```
*/
clearComments(): Promise<boolean>;
}
/**
* @ignore
*/
export class FWorkbookSheetsThreadCommentMixin extends FWorkbook implements IFWorkbookSheetsThreadCommentMixin {
declare _threadCommentModel: ThreadCommentModel;
/**
* @ignore
*/
override _initialize(): void {
Object.defineProperty(this, '_threadCommentModel', {
get() {
return this._injector.get(ThreadCommentModel);
},
});
}
override getComments(): FThreadComment[] {
return this._threadCommentModel.getUnit(this._workbook.getUnitId()).map((i) => this._injector.createInstance(FThreadComment, i.root));
}
override clearComments(): Promise<boolean> {
const comments = this.getComments();
const promises = comments.map((comment) => comment.deleteAsync());
return Promise.all(promises).then(() => true);
}
}
FWorkbook.extend(FWorkbookSheetsThreadCommentMixin);
declare module '@univerjs/sheets/facade' {
// eslint-disable-next-line ts/naming-convention
interface FWorkbook extends IFWorkbookSheetsThreadCommentMixin {}
}

View File

@@ -0,0 +1,136 @@
/**
* 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 { IDisposable } from '@univerjs/core';
import type { IAddCommentCommandParams } from '@univerjs/thread-comment';
import { ICommandService } from '@univerjs/core';
import { SheetsThreadCommentModel } from '@univerjs/sheets-thread-comment';
import { FWorksheet } from '@univerjs/sheets/facade';
import { AddCommentCommand } from '@univerjs/thread-comment';
import { FThreadComment } from './f-thread-comment';
/**
* @ignore
*/
export interface IFWorksheetCommentMixin {
/**
* Get all comments in the current sheet
* @returns {FThreadComment[]} All comments in the current sheet
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const comments = fWorksheet.getComments();
* comments.forEach((comment) => {
* const isRoot = comment.getIsRoot();
*
* if (isRoot) {
* console.log('root comment:', comment.getCommentData());
*
* const replies = comment.getReplies();
* replies.forEach((reply) => {
* console.log('reply comment:', reply.getCommentData());
* });
* }
* });
* ```
*/
getComments(): FThreadComment[];
/**
* Clear all comments in the current sheet
* @returns {Promise<boolean>} Whether the comments are cleared successfully.
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
* const result = await fWorksheet.clearComments();
* console.log(result);
* ```
*/
clearComments(): Promise<boolean>;
/**
* get comment by comment id
* @param {string} commentId comment id
* @example
* ```ts
* const fWorkbook = univerAPI.getActiveWorkbook();
* const fWorksheet = fWorkbook.getSheetByName('Sheet1');
* if (!fWorksheet) return;
*
* // Create a new comment
* const richText = univerAPI.newRichText().insertText('hello univer');
* const commentBuilder = univerAPI.newTheadComment()
* .setContent(richText)
* .setId('mock-comment-id');
* const cell = fWorksheet.getRange('A1');
* await cell.addCommentAsync(commentBuilder);
*
* const comment = fWorksheet.getCommentById('mock-comment-id');
* console.log(comment, comment?.getCommentData());
* ```
*/
getCommentById(commentId: string): FThreadComment | undefined;
}
/**
* @ignore
*/
export class FWorksheetCommentMixin extends FWorksheet implements IFWorksheetCommentMixin {
override getComments(): FThreadComment[] {
const sheetsTheadCommentModel = this._injector.get(SheetsThreadCommentModel);
const comments = sheetsTheadCommentModel.getSubUnitAll(this._workbook.getUnitId(), this._worksheet.getSheetId());
return comments.map((comment) => this._injector.createInstance(FThreadComment, comment));
}
override clearComments(): Promise<boolean> {
const comments = this.getComments();
const promises = comments.map((comment) => comment.deleteAsync());
return Promise.all(promises).then(() => true);
}
/**
* Subscribe to comment events.
* @param callback Callback function, param contains comment info and target cell.
* @returns {IDisposable} A disposable used to remove the listener.
*/
onCommented(callback: (params: IAddCommentCommandParams) => void): IDisposable {
const commandService = this._injector.get(ICommandService);
return commandService.onCommandExecuted((command) => {
if (command.id === AddCommentCommand.id) {
const params = command.params as IAddCommentCommandParams;
callback(params);
}
});
}
override getCommentById(commentId: string): FThreadComment | undefined {
const sheetsTheadCommentModel = this._injector.get(SheetsThreadCommentModel);
const comment = sheetsTheadCommentModel.getComment(this._workbook.getUnitId(), this._worksheet.getSheetId(), commentId);
if (comment) {
return this._injector.createInstance(FThreadComment, comment);
}
}
}
FWorksheet.extend(FWorksheetCommentMixin);
declare module '@univerjs/sheets/facade' {
interface FWorksheet extends IFWorksheetCommentMixin {}
}

View File

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

View File

@@ -0,0 +1,23 @@
/**
* 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 type { IUniverSheetsThreadCommentConfig } from './config/config';
export { SheetsThreadCommentRefRangeController } from './controllers/sheets-thread-comment-ref-range.controller';
export { SheetsThreadCommentResourceController } from './controllers/sheets-thread-comment-resource.controller';
export { SheetsThreadCommentModel } from './models/sheets-thread-comment.model';
export { UniverSheetsThreadCommentPlugin } from './plugin';
export { SHEET_UNIVER_THREAD_COMMENT_PLUGIN } from './types/const';
export type { ISheetThreadComment } from './types/interfaces/i-sheet-thread-comment';

View File

@@ -0,0 +1,170 @@
/**
* 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 { UniverInstanceType } from '@univerjs/core';
import { Subject } from 'rxjs';
import { describe, expect, it } from 'vitest';
import { SheetsThreadCommentModel } from '../sheets-thread-comment.model';
function createComment(overrides: Record<string, unknown> = {}) {
return {
id: 'comment-1',
threadId: 'thread-1',
ref: 'A1',
dT: '2025-01-01T00:00:00.000Z',
personId: 'user-1',
text: { dataStream: 'hello\r\n', textRuns: [] },
attachments: [],
unitId: 'unit-1',
subUnitId: 'sheet-1',
resolved: false,
...overrides,
} as any;
}
describe('SheetsThreadCommentModel', () => {
it('keeps the newer unresolved thread as the default when an older thread is reopened', () => {
const olderComment = createComment({ id: 'older-comment', threadId: 'older-thread', resolved: true });
const newerComment = createComment({ id: 'newer-comment', threadId: 'newer-thread' });
const comments = new Map([
[olderComment.id, olderComment],
[newerComment.id, newerComment],
]);
const updates$ = new Subject<unknown>();
const threadCommentModel = {
getAll: () => [{ threads: [
{ unitId: 'unit-1', subUnitId: 'sheet-1', root: olderComment },
{ unitId: 'unit-1', subUnitId: 'sheet-1', root: newerComment },
] }],
commentUpdate$: updates$,
getComment: (_unitId: string, _subUnitId: string, commentId: string) => comments.get(commentId),
};
const univerInstanceService = {
getUnitType: () => UniverInstanceType.UNIVER_SHEET,
};
const model = new SheetsThreadCommentModel(threadCommentModel as never, univerInstanceService as never);
expect(model.getByLocation('unit-1', 'sheet-1', 0, 0)).toBe(newerComment.id);
olderComment.resolved = false;
updates$.next({
type: 'resolve',
unitId: 'unit-1',
subUnitId: 'sheet-1',
payload: { commentId: olderComment.id, resolved: false },
});
expect(model.getByLocation('unit-1', 'sheet-1', 0, 0)).toBe(newerComment.id);
model.dispose();
});
it('should build location indexes from existing root comments and expose thread data', () => {
const root = createComment();
const reply = createComment({ id: 'reply-1', parentId: root.id, ref: '', resolved: false });
const thread = { ...root, children: [reply] };
const updates$ = new Subject<any>();
const threadCommentModel = {
getAll: () => [{ threads: [{ unitId: 'unit-1', subUnitId: 'sheet-1', root }] }],
commentUpdate$: updates$,
getComment: (_unitId: string, _subUnitId: string, commentId: string) => {
if (commentId === root.id) {
return root;
}
if (commentId === reply.id) {
return reply;
}
return null;
},
getThread: () => thread,
getUnit: () => [{ subUnitId: 'sheet-1', root }],
};
const univerInstanceService = {
getUnitType: () => UniverInstanceType.UNIVER_SHEET,
};
const model = new SheetsThreadCommentModel(threadCommentModel as any, univerInstanceService as any);
expect(model.getByLocation('unit-1', 'sheet-1', 0, 0)).toBe(root.id);
expect(model.getAllByLocation('unit-1', 'sheet-1', 0, 0)).toEqual([root]);
expect(model.getCommentWithChildren('unit-1', 'sheet-1', 0, 0)).toEqual(thread);
expect(model.showCommentMarker('unit-1', 'sheet-1', 0, 0)).toBe(true);
expect(model.getSubUnitAll('unit-1', 'sheet-1')).toEqual([root]);
model.dispose();
});
it('should transform thread updates into sheet-aware locations and keep indexes in sync', () => {
const addComment = createComment();
const movedComment = createComment({ ref: 'D4' });
const updates$ = new Subject<any>();
const commentStore = new Map<string, any>();
const threadCommentModel = {
getAll: () => [],
commentUpdate$: updates$,
getComment: (_unitId: string, _subUnitId: string, commentId: string) => commentStore.get(commentId),
getThread: () => null,
getUnit: () => [],
};
const univerInstanceService = {
getUnitType: (unitId: string) => (unitId === 'doc-unit' ? UniverInstanceType.UNIVER_DOC : UniverInstanceType.UNIVER_SHEET),
};
const model = new SheetsThreadCommentModel(threadCommentModel as any, univerInstanceService as any);
const received: Array<any> = [];
const subscription = model.commentUpdate$.subscribe((update) => received.push(update));
commentStore.set(addComment.id, addComment);
updates$.next({ type: 'add', unitId: 'unit-1', subUnitId: 'sheet-1', payload: addComment });
expect(model.getByLocation('unit-1', 'sheet-1', 0, 0)).toBe(addComment.id);
expect(received.at(-1)).toEqual(expect.objectContaining({ type: 'add', row: 0, column: 0, isRoot: true }));
updates$.next({ type: 'update', unitId: 'unit-1', subUnitId: 'sheet-1', payload: { commentId: addComment.id } });
expect(received.at(-1)).toEqual(expect.objectContaining({ type: 'update', row: 0, column: 0 }));
commentStore.set(addComment.id, movedComment);
updates$.next({ type: 'updateRef', unitId: 'unit-1', subUnitId: 'sheet-1', payload: { commentId: addComment.id, ref: 'D4' } });
expect(model.getByLocation('unit-1', 'sheet-1', 0, 0)).toBeUndefined();
expect(model.getByLocation('unit-1', 'sheet-1', 3, 3)).toBe(addComment.id);
expect(received.at(-1)).toEqual(expect.objectContaining({ type: 'updateRef', row: 3, column: 3 }));
commentStore.set(addComment.id, { ...movedComment, resolved: true });
updates$.next({ type: 'resolve', unitId: 'unit-1', subUnitId: 'sheet-1', payload: { commentId: addComment.id, resolved: true } });
expect(received.at(-1)).toEqual(expect.objectContaining({ type: 'resolve', row: 3, column: 3 }));
expect(model.getByLocation('unit-1', 'sheet-1', 3, 3)).toBeUndefined();
expect(model.showCommentMarker('unit-1', 'sheet-1', 3, 3)).toBe(false);
updates$.next({
type: 'delete',
unitId: 'unit-1',
subUnitId: 'sheet-1',
payload: { isRoot: true, comment: movedComment },
});
expect(model.getAllByLocation('unit-1', 'sheet-1', 3, 3)).toEqual([]);
expect(received.at(-1)).toEqual(expect.objectContaining({ type: 'delete', row: 3, column: 3 }));
updates$.next({ type: 'add', unitId: 'doc-unit', subUnitId: 'sheet-1', payload: createComment({ id: 'doc-comment', ref: 'B2' }) });
expect(model.getByLocation('doc-unit', 'sheet-1', 1, 1)).toBeUndefined();
subscription.unsubscribe();
model.dispose();
});
});

View File

@@ -0,0 +1,298 @@
/**
* 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 { CommentUpdate, IThreadComment } from '@univerjs/thread-comment';
import { Disposable, Inject, IUniverInstanceService, ObjectMatrix, UniverInstanceType } from '@univerjs/core';
import { singleReferenceToGrid } from '@univerjs/engine-formula';
import { deserializeThreadCommentAnchor, ThreadCommentModel } from '@univerjs/thread-comment';
import { Subject } from 'rxjs';
export type SheetCommentUpdate = CommentUpdate & {
row: number;
column: number;
};
export class SheetsThreadCommentModel extends Disposable {
private _matrixMap: Map<string, Map<string, ObjectMatrix<Set<string>>>> = new Map();
private _locationMap: Map<string, Map<string, Map<string, { row: number; column: number }>>> = new Map();
private _commentUpdate$ = new Subject<SheetCommentUpdate>();
commentUpdate$ = this._commentUpdate$.asObservable();
constructor(
@Inject(ThreadCommentModel) private readonly _threadCommentModel: ThreadCommentModel,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService
) {
super();
this._init();
this.disposeWithMe(() => {
this._commentUpdate$.complete();
});
}
private _init() {
this._initData();
this._initUpdateTransform();
}
private _ensureCommentMatrix(unitId: string, subUnitId: string) {
let unitMap = this._matrixMap.get(unitId);
if (!unitMap) {
unitMap = new Map();
this._matrixMap.set(unitId, unitMap);
}
let subUnitMap = unitMap.get(subUnitId);
if (!subUnitMap) {
subUnitMap = new ObjectMatrix();
unitMap.set(subUnitId, subUnitMap);
}
return subUnitMap;
}
private _ensureCommentLocationMap(unitId: string, subUnitId: string) {
let unitMap = this._locationMap.get(unitId);
if (!unitMap) {
unitMap = new Map();
this._locationMap.set(unitId, unitMap);
}
let subUnitMap = unitMap.get(subUnitId);
if (!subUnitMap) {
subUnitMap = new Map();
unitMap.set(subUnitId, subUnitMap);
}
return subUnitMap;
}
private _addCommentToMatrix(matrix: ObjectMatrix<Set<string>>, row: number, column: number, commentId: string) {
const current = matrix.getValue(row, column) ?? new Set();
current.add(commentId);
matrix.setValue(row, column, current);
}
private _deleteCommentFromMatrix(matrix: ObjectMatrix<Set<string>>, row: number, column: number, commentId: string) {
if (row >= 0 && column >= 0) {
const current = matrix.getValue(row, column);
if (current && current.has(commentId)) {
current.delete(commentId);
if (current.size === 0) {
matrix.realDeleteValue(row, column);
}
}
}
}
private _ensure(unitId: string, subUnitId: string) {
const matrix = this._ensureCommentMatrix(unitId, subUnitId);
const locationMap = this._ensureCommentLocationMap(unitId, subUnitId);
return { matrix, locationMap };
}
private _initData() {
const datas = this._threadCommentModel.getAll();
for (const data of datas) {
for (const thread of data.threads) {
const { unitId, subUnitId, root } = thread;
this._addComment(unitId, subUnitId, root);
}
}
}
private _addComment(unitId: string, subUnitId: string, comment: IThreadComment) {
if (deserializeThreadCommentAnchor(comment.ref)) {
return;
}
const location = singleReferenceToGrid(comment.ref);
const parentId = comment.parentId;
const { row, column } = location;
const commentId = comment.id;
const { matrix, locationMap } = this._ensure(unitId, subUnitId);
if (!parentId && row >= 0 && column >= 0) {
this._addCommentToMatrix(matrix, row, column, commentId);
locationMap.set(commentId, { row, column });
}
if (!parentId) {
this._commentUpdate$.next({
unitId,
subUnitId,
payload: comment,
type: 'add',
isRoot: !parentId,
...location,
});
}
}
// eslint-disable-next-line max-lines-per-function
private _initUpdateTransform() {
// eslint-disable-next-line max-lines-per-function
this.disposeWithMe(this._threadCommentModel.commentUpdate$.subscribe((update) => {
const { unitId, subUnitId } = update;
try {
const type = this._univerInstanceService.getUnitType(unitId);
if (type !== UniverInstanceType.UNIVER_SHEET) {
return;
}
} catch (error) {
// do nothing
}
const { matrix, locationMap } = this._ensure(unitId, subUnitId);
switch (update.type) {
case 'add': {
this._addComment(update.unitId, update.subUnitId, update.payload);
break;
}
case 'delete': {
const { isRoot, comment } = update.payload;
if (deserializeThreadCommentAnchor(comment.ref)) {
return;
}
if (isRoot) {
const location = singleReferenceToGrid(comment.ref);
const { row, column } = location;
this._deleteCommentFromMatrix(matrix, row, column, comment.id);
this._commentUpdate$.next({
...update,
...location,
});
}
break;
}
case 'update': {
const { commentId } = update.payload;
const comment = this._threadCommentModel.getComment(unitId, subUnitId, commentId);
if (!comment) {
return;
}
if (deserializeThreadCommentAnchor(comment.ref)) {
return;
}
const location = singleReferenceToGrid(comment.ref);
this._commentUpdate$.next({
...update,
...location,
});
break;
}
case 'updateRef': {
if (deserializeThreadCommentAnchor(update.payload.ref)) {
return;
}
const location = singleReferenceToGrid(update.payload.ref);
const { commentId } = update.payload;
const currentLoc = locationMap.get(commentId);
if (!currentLoc) {
return;
}
const { row, column } = currentLoc;
this._deleteCommentFromMatrix(matrix, row, column, commentId);
locationMap.delete(commentId);
if (location.row >= 0 && location.column >= 0) {
this._addCommentToMatrix(matrix, location.row, location.column, commentId);
locationMap.set(commentId, { row: location.row, column: location.column });
}
this._commentUpdate$.next({
...update,
...location,
});
break;
}
case 'resolve': {
const { unitId, subUnitId, payload } = update;
const { locationMap } = this._ensure(unitId, subUnitId);
const location = locationMap.get(payload.commentId);
if (location) {
this._commentUpdate$.next({
...update,
...location,
});
}
break;
}
default:
break;
}
}));
}
getByLocation(unitId: string, subUnitId: string, row: number, column: number): string | undefined {
const comments = this.getAllByLocation(unitId, subUnitId, row, column);
const activeComments = comments.filter((comment) => !comment.resolved);
return activeComments[activeComments.length - 1]?.id;
}
getAllByLocation(unitId: string, subUnitId: string, row: number, column: number): IThreadComment[] {
const matrix = this._ensureCommentMatrix(unitId, subUnitId);
const current = matrix.getValue(row, column);
if (!current) {
return [];
}
const comments: IThreadComment[] = [];
for (const id of current) {
const comment = this.getComment(unitId, subUnitId, id);
if (comment) {
comments.push(comment);
}
}
return comments;
}
getComment(unitId: string, subUnitId: string, commentId: string) {
return this._threadCommentModel.getComment(unitId, subUnitId, commentId);
}
getCommentWithChildren(unitId: string, subUnitId: string, row: number, column: number) {
const commentId = this.getByLocation(unitId, subUnitId, row, column);
if (!commentId) {
return undefined;
}
const comment = this.getComment(unitId, subUnitId, commentId);
if (!comment) {
return undefined;
}
return this._threadCommentModel.getThread(unitId, subUnitId, comment.threadId);
}
showCommentMarker(unitId: string, subUnitId: string, row: number, column: number) {
const commentId = this.getByLocation(unitId, subUnitId, row, column);
if (!commentId) {
return false;
}
const comment = this.getComment(unitId, subUnitId, commentId);
if (comment && !comment.resolved) {
return true;
}
return false;
}
getSubUnitAll(unitId: string, subUnitId: string) {
return this._threadCommentModel.getUnit(unitId).filter((i) => i.subUnitId === subUnitId).map((i) => i.root);
}
}

View File

@@ -0,0 +1,76 @@
/**
* 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 { IUniverSheetsThreadCommentConfig } from './config/config';
import {
DependentOn,
ICommandService,
IConfigService,
Inject,
Injector,
merge,
Plugin,
touchDependencies,
UniverInstanceType,
} from '@univerjs/core';
import { UniverSheetsPlugin } from '@univerjs/sheets';
import { UniverThreadCommentPlugin } from '@univerjs/thread-comment';
import pkg from '../package.json';
import { defaultPluginConfig, SHEETS_THREAD_COMMENT_PLUGIN_CONFIG_KEY } from './config/config';
import { SheetsThreadCommentRefRangeController } from './controllers/sheets-thread-comment-ref-range.controller';
import { SheetsThreadCommentResourceController } from './controllers/sheets-thread-comment-resource.controller';
import { SheetsThreadCommentModel } from './models/sheets-thread-comment.model';
import { SHEET_THREAD_COMMENT_BASE } from './types/const';
@DependentOn(UniverThreadCommentPlugin, UniverSheetsPlugin)
export class UniverSheetsThreadCommentPlugin extends Plugin {
static override pluginName = SHEET_THREAD_COMMENT_BASE;
static override packageName = pkg.name;
static override version = pkg.version;
static override type = UniverInstanceType.UNIVER_SHEET;
constructor(
private readonly _config: Partial<IUniverSheetsThreadCommentConfig> = defaultPluginConfig,
@Inject(Injector) protected override _injector: Injector,
@Inject(ICommandService) protected _commandService: ICommandService,
@IConfigService private readonly _configService: IConfigService
) {
super();
const { ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
this._configService.setConfig(SHEETS_THREAD_COMMENT_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
([
[SheetsThreadCommentModel],
[SheetsThreadCommentRefRangeController],
[SheetsThreadCommentResourceController],
] as Dependency[]).forEach((dep) => {
this._injector.add(dep);
});
touchDependencies(this._injector, [
[SheetsThreadCommentRefRangeController],
[SheetsThreadCommentResourceController],
]);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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