feat(thread-comment): support unified comment anchors and facade APIs (#7585)

This commit is contained in:
Univer
2026-08-26 12:36:15 +08:00
committed by GitHub
parent 8b2c5ad46d
commit 3fbd045d25
166 changed files with 6386 additions and 642 deletions
+7
View File
@@ -875,6 +875,13 @@ export type BaseHitTestResult =
viewId: ViewId;
recordId: RecordId;
}
| {
type: 'base-record-action' | 'base-record-action-badge';
tableId: TableId;
viewId: ViewId;
recordId: RecordId;
actionId: string;
}
| {
type: 'grid-hierarchy-toggle' | 'grid-hierarchy-add-child';
tableId: TableId;
@@ -39,6 +39,7 @@ import {
OpenImageCropOperation,
} from '@univerjs/drawing-ui';
import { IRenderManagerService } from '@univerjs/engine-render';
import { FloatingObjectToolbarPosition, IMenuManagerService, MenuItemType } from '@univerjs/ui';
import { takeUntil } from 'rxjs';
import { EditDocDrawingOperation } from '../commands/operations/edit-doc-drawing.operation';
import { SidebarDocDrawingOperation } from '../commands/operations/open-drawing-panel.operation';
@@ -59,7 +60,8 @@ export class DocDrawingPopupMenuController extends RxDisposable {
@IContextService private readonly _contextService: IContextService,
@IDocDrawingAdapterService private readonly _drawingAdapterService: IDocDrawingAdapterService,
@Inject(DocDrawingFloatingToolbarAdapterService) private readonly _floatingToolbarAdapterService: DocDrawingFloatingToolbarAdapterService,
@ICommandService private readonly _commandService: ICommandService
@ICommandService private readonly _commandService: ICommandService,
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService
) {
super();
@@ -265,15 +267,11 @@ export class DocDrawingPopupMenuController extends RxDisposable {
const floatingToolbarMenuItems = drawing
? this._floatingToolbarAdapterService.getItems({ unitId, subUnitId, drawing })
: null;
if (floatingToolbarMenuItems) {
return floatingToolbarMenuItems;
}
const editCommandInfo = drawing
? this._drawingAdapterService.getEditDrawingCommandInfo({ unitId, subUnitId, drawing })
: null;
return [
const defaultItems = [
{
label: editCommandInfo?.label ?? 'docs-drawing-ui.image-popup.edit',
index: 0,
@@ -307,5 +305,30 @@ export class DocDrawingPopupMenuController extends RxDisposable {
disable: true, // TODO: @JOCS, feature is not ready.
},
];
return [
...(floatingToolbarMenuItems ?? defaultItems),
...this._getFloatingObjectMenuItems(),
];
}
private _getFloatingObjectMenuItems() {
return this._menuManagerService
.getFlatMenuByPositionKey(FloatingObjectToolbarPosition.DOC)
.flatMap(({ item }, index) => {
if (!item || item.type !== MenuItemType.BUTTON || !item.title || typeof item.icon !== 'string') {
return [];
}
return [{
type: 'button' as const,
label: item.title,
index: 100 + index,
commandId: item.commandId ?? item.id,
commandParams: typeof item.params === 'function' ? item.params() : item.params,
disable: false,
icon: item.icon,
}];
});
}
}
@@ -22,6 +22,7 @@ import {
DrawingTypeEnum,
ICommandService,
} from '@univerjs/core';
import { FBase } from '@univerjs/core/facade';
import {
RemoveDocDrawingCommand,
SetDocDrawingArrangeCommand,
@@ -33,12 +34,14 @@ import {
* Facade API for an image in a document.
* @hideconstructor
*/
export class FDocumentImage {
export class FDocumentImage extends FBase {
constructor(
private readonly _document: FDocument,
private readonly _imageId: string,
private readonly _injector: Injector
) {}
protected readonly _injector: Injector
) {
super();
}
/**
* Gets the id of the document containing the image.
+7 -3
View File
@@ -15,9 +15,9 @@
## Installation
```sh
pnpm add @univerjs/docs-thread-comment-ui
pnpm add @univerjs/docs-thread-comment @univerjs/docs-thread-comment-ui
# or
npm install @univerjs/docs-thread-comment-ui
npm install @univerjs/docs-thread-comment @univerjs/docs-thread-comment-ui
```
Keep all `@univerjs/*` packages on the same version.
@@ -26,14 +26,18 @@ Keep all `@univerjs/*` packages on the same version.
```ts
import '@univerjs/docs-thread-comment-ui/lib/index.css';
import '@univerjs/docs-thread-comment/facade';
import { UniverDocsThreadCommentUIPlugin } from '@univerjs/docs-thread-comment-ui';
univer.registerPlugin(UniverDocsThreadCommentUIPlugin);
const range = univerAPI.getActiveDocument()?.getTextRange(0, 12);
await range?.createCommentAsync('Verify this introduction.', { id: 'review-intro' });
```
## Integration Notes
Use this package with `@univerjs/thread-comment` and `@univerjs/thread-comment-ui` for shared comment behavior.
Use `@univerjs/docs-thread-comment` for headless model commands and Facade APIs. This UI package adds menus, rendering, and the shared side panel.
## Resources
@@ -76,7 +76,9 @@
"dependencies": {
"@univerjs/core": "workspace:*",
"@univerjs/docs": "workspace:*",
"@univerjs/docs-thread-comment": "workspace:*",
"@univerjs/docs-ui": "workspace:*",
"@univerjs/drawing": "workspace:*",
"@univerjs/engine-render": "workspace:*",
"@univerjs/icons": "1.38.0",
"@univerjs/thread-comment": "workspace:*",
@@ -14,22 +14,29 @@
* limitations under the License.
*/
import type { DocumentDataModel, ICommand, IDocumentData, Injector } from '@univerjs/core';
import type { DocumentDataModel, IDocumentData, Injector } from '@univerjs/core';
import type { IThreadComment } from '@univerjs/thread-comment';
import { ICommandService, IUniverInstanceService, Univer, UniverInstanceType } from '@univerjs/core';
import { DocSelectionManagerService, DocStateEmitService, RichTextEditingMutation } from '@univerjs/docs';
import {
AddDocCommentDecorationMutation,
CreateDocTextRangeCommentCommand,
DEFAULT_DOC_SUBUNIT_ID,
} from '@univerjs/docs-thread-comment';
import { FDocument } from '@univerjs/docs/facade';
import { IRenderManagerService, RenderManagerService } from '@univerjs/engine-render';
import {
AddCommentMutation,
IThreadCommentDataSourceService,
ThreadCommentDataSourceService,
ThreadCommentFacadeService,
ThreadCommentModel,
} from '@univerjs/thread-comment';
import { SetActiveCommentOperation, ThreadCommentPanelService } from '@univerjs/thread-comment-ui';
import { DesktopSidebarService, ISidebarService } from '@univerjs/ui';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DEFAULT_DOC_SUBUNIT_ID } from '../../../common/const';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { AddDocCommentComment } from '../add-doc-comment.command';
import '@univerjs/docs-thread-comment/facade';
const DOC_ID = 'doc-add-comment-test';
@@ -74,6 +81,7 @@ function createComment(id = 'comment-1'): IThreadComment {
describe('AddDocCommentComment', () => {
let univer: Univer;
let injector: Injector;
let get: Injector['get'];
let commandService: ICommandService;
@@ -84,7 +92,7 @@ describe('AddDocCommentComment', () => {
beforeEach(() => {
univer = new Univer();
const injector = univer.__getInjector();
injector = univer.__getInjector();
get = injector.get.bind(injector);
injector.add([DocSelectionManagerService]);
@@ -92,6 +100,7 @@ describe('AddDocCommentComment', () => {
injector.add([IRenderManagerService, { useClass: RenderManagerService }]);
injector.add([IThreadCommentDataSourceService, { useClass: ThreadCommentDataSourceService }]);
injector.add([ThreadCommentModel]);
injector.add([ThreadCommentFacadeService]);
injector.add([ISidebarService, { useClass: DesktopSidebarService }]);
injector.add([ThreadCommentPanelService]);
@@ -100,32 +109,18 @@ describe('AddDocCommentComment', () => {
commandService = get(ICommandService);
commandService.registerCommand(AddDocCommentComment);
commandService.registerCommand(CreateDocTextRangeCommentCommand);
commandService.registerCommand(AddDocCommentDecorationMutation);
commandService.registerCommand(AddCommentMutation);
commandService.registerCommand(SetActiveCommentOperation);
commandService.registerCommand(RichTextEditingMutation as unknown as ICommand);
commandService.registerCommand(RichTextEditingMutation);
});
afterEach(() => {
univer.dispose();
});
it('adds a thread comment to the selected document text and makes it active', async () => {
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({
unitId: DOC_ID,
subUnitId: DOC_ID,
});
selectionManager.__TEST_ONLY_add([
{
startOffset: 0,
endOffset: 5,
collapsed: false,
isActive: true,
segmentId: '',
style: null as never,
},
]);
it('adds a thread comment to an explicit document range and makes it active', async () => {
const comment = createComment();
const result = await commandService.executeCommand(AddDocCommentComment.id, {
unitId: DOC_ID,
@@ -133,6 +128,7 @@ describe('AddDocCommentComment', () => {
range: {
startOffset: 0,
endOffset: 5,
collapsed: false,
},
});
@@ -165,21 +161,142 @@ describe('AddDocCommentComment', () => {
});
});
it('does not attach a comment when the document has no selected text', async () => {
const comment = createComment('comment-without-selection');
it('uses the server-assigned id for the document anchor', async () => {
const selectionManager = get(DocSelectionManagerService);
selectionManager.__TEST_ONLY_setCurrentSelection({
unitId: DOC_ID,
subUnitId: DOC_ID,
});
selectionManager.__TEST_ONLY_add([{
startOffset: 0,
endOffset: 5,
collapsed: false,
isActive: true,
segmentId: '',
style: null as never,
}]);
get(IThreadCommentDataSourceService).dataSource = {
addComment: vi.fn(async (comment: IThreadComment) => ({
...comment,
id: 'server-comment-id',
threadId: 'server-comment-id',
})),
updateComment: vi.fn(),
resolveComment: vi.fn(),
deleteComment: vi.fn(),
listComments: vi.fn(),
saveCommentToSnapshot: (value) => value,
};
const result = await commandService.executeCommand(AddDocCommentComment.id, {
unitId: DOC_ID,
comment: createComment('client-comment-id'),
range: { startOffset: 0, endOffset: 5, collapsed: false },
});
expect(result).toBe(true);
expect(getDocBody()?.customDecorations).toEqual([
expect.objectContaining({ id: 'server-comment-id' }),
]);
expect(get(ThreadCommentModel).getThread(
DOC_ID,
DEFAULT_DOC_SUBUNIT_ID,
'server-comment-id'
)?.root.id).toBe('server-comment-id');
expect(get(ThreadCommentPanelService).activeCommentId?.commentId).toBe('server-comment-id');
});
it('rejects a collapsed explicit range before writing to the data source', async () => {
const comment = createComment('comment-without-range');
const addComment = vi.fn(async (value: IThreadComment) => value);
get(IThreadCommentDataSourceService).dataSource = {
addComment,
updateComment: vi.fn(),
resolveComment: vi.fn(),
deleteComment: vi.fn(),
listComments: vi.fn(),
saveCommentToSnapshot: (value) => value,
};
const result = await commandService.executeCommand(AddDocCommentComment.id, {
unitId: DOC_ID,
comment,
range: {
startOffset: 0,
startOffset: 5,
endOffset: 5,
collapsed: true,
},
});
const outOfBounds = await commandService.executeCommand(AddDocCommentComment.id, {
unitId: DOC_ID,
comment,
range: {
startOffset: 0,
endOffset: 100,
collapsed: false,
},
});
expect(result).toBe(false);
expect(outOfBounds).toBe(false);
expect(get(ThreadCommentModel).getThread(DOC_ID, DEFAULT_DOC_SUBUNIT_ID, comment.id)).toBeUndefined();
expect(getDocBody()?.customDecorations).toEqual([]);
expect(get(ThreadCommentPanelService).activeCommentId).toBeUndefined();
expect(addComment).not.toHaveBeenCalled();
});
it('creates an agent-supplied text-range comment with queryable offsets', async () => {
const result = await commandService.executeCommand(CreateDocTextRangeCommentCommand.id, {
unitId: DOC_ID,
range: { startOffset: 0, endOffset: 5, collapsed: false },
content: 'Check the greeting.',
id: 'agent-comment-1',
personId: 'agent-user-1',
});
expect(result).toBe(true);
expect(get(ThreadCommentModel).getThread(
DOC_ID,
DEFAULT_DOC_SUBUNIT_ID,
'agent-comment-1'
)?.root).toMatchObject({
id: 'agent-comment-1',
threadId: 'agent-comment-1',
personId: 'agent-user-1',
ref: 'Hello',
startOffset: 0,
endOffset: 5,
collapsed: false,
text: { dataStream: 'Check the greeting.\r\n' },
});
expect(getDocBody()?.customDecorations).toEqual([
expect.objectContaining({ id: 'agent-comment-1', startIndex: 0, endIndex: 4 }),
]);
});
it('creates and queries a comment through FDocumentTextRange', async () => {
const documentModel = get(IUniverInstanceService).getUnit<DocumentDataModel>(DOC_ID, UniverInstanceType.UNIVER_DOC);
if (!documentModel) {
throw new Error('Test document was not created.');
}
const document = injector.createInstance(FDocument, documentModel);
const range = document.getTextRange(6, 11);
await expect(range.createCommentAsync('Review the noun.', {
id: 'facade-comment-1',
personId: 'agent-user-2',
})).resolves.toBe(true);
expect(range.getComments()).toHaveLength(1);
expect(range.getComments()[0]).toMatchObject({
threadId: 'facade-comment-1',
root: {
ref: 'world',
startOffset: 6,
endOffset: 11,
personId: 'agent-user-2',
},
});
expect(document.getTextRange(0, 5).getComments()).toEqual([]);
});
it('rejects an incomplete add-comment request without changing the document', async () => {
@@ -14,20 +14,19 @@
* limitations under the License.
*/
import type { ICommand, ITextRange } from '@univerjs/core';
import type { IThreadComment } from '@univerjs/thread-comment';
import { CommandType, CustomDecorationType, ICommandService, sequenceExecute } from '@univerjs/core';
import { addCustomDecorationBySelectionFactory } from '@univerjs/docs-ui';
import { AddCommentMutation, IThreadCommentDataSourceService } from '@univerjs/thread-comment';
import type { ICommand } from '@univerjs/core';
import type { IAddDocTextRangeCommentParams } from '@univerjs/docs-thread-comment';
import { CommandType, ICommandService, sequenceExecute } from '@univerjs/core';
import { RichTextEditingMutation } from '@univerjs/docs';
import {
prepareDocTextRangeComment,
} from '@univerjs/docs-thread-comment';
import { SetActiveCommentOperation } from '@univerjs/thread-comment-ui';
import { DEFAULT_DOC_SUBUNIT_ID } from '../../common/const';
export interface IAddDocCommentComment {
unitId: string;
comment: IThreadComment;
range: ITextRange;
}
export type IAddDocCommentComment = IAddDocTextRangeCommentParams;
/** Adds a document comment and activates it in the UI comment panel. */
export const AddDocCommentComment: ICommand<IAddDocCommentComment> = {
id: 'docs.command.add-comment',
type: CommandType.COMMAND,
@@ -35,41 +34,31 @@ export const AddDocCommentComment: ICommand<IAddDocCommentComment> = {
if (!params) {
return false;
}
const { comment: originComment, unitId } = params;
const dataSourceService = accessor.get(IThreadCommentDataSourceService);
const comment = await dataSourceService.addComment(originComment);
const commandService = accessor.get(ICommandService);
const doMutation = addCustomDecorationBySelectionFactory(
accessor,
{
id: comment.threadId,
type: CustomDecorationType.COMMENT,
unitId,
}
);
if (doMutation) {
const addComment = {
id: AddCommentMutation.id,
params: {
unitId,
subUnitId: DEFAULT_DOC_SUBUNIT_ID,
comment,
},
};
const activeOperation = {
id: SetActiveCommentOperation.id,
params: {
unitId,
subUnitId: DEFAULT_DOC_SUBUNIT_ID,
commentId: comment.id,
},
};
return (await sequenceExecute([addComment, doMutation, activeOperation], commandService)).result;
const prepared = await prepareDocTextRangeComment(accessor, params);
if (!prepared) {
return false;
}
return false;
const activeOperation = {
id: SetActiveCommentOperation.id,
params: {
unitId: prepared.comment.unitId,
subUnitId: prepared.comment.subUnitId,
commentId: prepared.comment.id,
},
};
const decorationMutation = {
id: RichTextEditingMutation.id,
params: {
...prepared.decorationMutationParams,
textRanges: null,
noNeedSetTextRange: true,
},
};
return (await sequenceExecute([
prepared.commentMutation,
decorationMutation,
activeOperation,
], accessor.get(ICommandService))).result;
},
};
@@ -19,6 +19,7 @@ import type { IRender } from '@univerjs/engine-render';
import type { ISidebarMethodOptions } from '@univerjs/ui';
import {
Disposable,
DrawingTypeEnum,
ICommandService,
IUniverInstanceService,
toDisposable,
@@ -26,14 +27,17 @@ import {
UniverInstanceType,
} from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { DEFAULT_DOC_SUBUNIT_ID } from '@univerjs/docs-thread-comment';
import { DrawingManagerService, IDrawingManagerService } from '@univerjs/drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import { ThreadCommentPanelService } from '@univerjs/thread-comment-ui';
import { ISidebarService } from '@univerjs/ui';
import { Subject } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DEFAULT_DOC_SUBUNIT_ID, DOCS_THREAD_COMMENT_PANEL } from '../../../common/const';
import { DOCS_THREAD_COMMENT_PANEL } from '../../../common/const';
import { DocThreadCommentService } from '../../../services/doc-thread-comment.service';
import {
AddDocDrawingCommentOperation,
ShowCommentPanelOperation,
StartAddCommentOperation,
ToggleCommentPanelOperation,
@@ -218,4 +222,25 @@ describe('doc thread comment panel operations', () => {
expect(docThreadCommentService.addingComment).toBeUndefined();
expect(panelService.panelVisible).toBe(false);
});
it('rejects a focused drawing left over from another document', () => {
const injector = univer.__getInjector();
const drawing = {
unitId: 'other-doc',
subUnitId: 'other-doc',
drawingId: 'drawing-1',
drawingType: DrawingTypeEnum.DRAWING_SHAPE,
};
const drawingManager = new DrawingManagerService();
drawingManager.registerDrawingData(drawing.unitId, {
[drawing.subUnitId]: { data: { [drawing.drawingId]: drawing }, order: [drawing.drawingId] },
});
drawingManager.focusDrawing([drawing]);
injector.add([IDrawingManagerService, {
useValue: drawingManager,
}]);
expect(AddDocDrawingCommentOperation.handler(injector)).toBe(false);
drawingManager.dispose();
});
});
@@ -18,12 +18,14 @@ import type { DocumentDataModel, ICommand } from '@univerjs/core';
import type { ActiveCommentInfo } from '@univerjs/thread-comment-ui';
import { BuildTextUtils, CommandType, ICommandService, IUniverInstanceService, UniverInstanceType, UserManagerService } from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { DEFAULT_DOC_SUBUNIT_ID } from '@univerjs/docs-thread-comment';
import { DocSelectionRenderService } from '@univerjs/docs-ui';
import { IDrawingManagerService } from '@univerjs/drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import { getDT } from '@univerjs/thread-comment';
import { ThreadCommentPanelService } from '@univerjs/thread-comment-ui';
import { getDT, ThreadCommentAnchorKind } from '@univerjs/thread-comment';
import { ThreadCommentDraftService, ThreadCommentPanelService } from '@univerjs/thread-comment-ui';
import { ISidebarService } from '@univerjs/ui';
import { DEFAULT_DOC_SUBUNIT_ID, DOCS_THREAD_COMMENT_PANEL } from '../../common/const';
import { DOCS_THREAD_COMMENT_PANEL } from '../../common/const';
import { DocThreadCommentService } from '../../services/doc-thread-comment.service';
export interface IShowCommentPanelOperationParams {
@@ -142,3 +144,34 @@ export const StartAddCommentOperation: ICommand = {
return true;
},
};
export const AddDocDrawingCommentOperation: ICommand = {
id: 'docs.operation.add-drawing-comment',
type: CommandType.OPERATION,
handler(accessor) {
const drawing = accessor.get(IDrawingManagerService).getFocusDrawings()[0];
const doc = accessor.get(IUniverInstanceService)
.getCurrentUnitOfType<DocumentDataModel>(UniverInstanceType.UNIVER_DOC);
if (!drawing || !doc || drawing.unitId !== doc.getUnitId()) {
return false;
}
accessor.get(ThreadCommentDraftService).place({
unitId: drawing.unitId,
subUnitId: drawing.subUnitId,
anchor: {
kind: ThreadCommentAnchorKind.DOC_DRAWING,
pageId: drawing.subUnitId,
elementId: drawing.drawingId,
},
});
const panelService = accessor.get(ThreadCommentPanelService);
accessor.get(ISidebarService).open({
header: { title: 'docs-thread-comment-ui.panel.title' },
children: { label: DOCS_THREAD_COMMENT_PANEL },
width: 320,
onClose: () => panelService.setPanelVisible(false),
});
panelService.setPanelVisible(true);
return true;
},
};
@@ -18,6 +18,4 @@ export const DOCS_THREAD_COMMENT_PANEL = 'univer.doc.thread-comment-panel';
export const PLUGIN_NAME = 'DOC_THREAD_COMMENT_UI_PLUGIN';
export const DEFAULT_DOC_SUBUNIT_ID = 'default_doc';
export const DEFAULT_TEMP_COMMENT_ID = 'default_comment';
@@ -14,12 +14,13 @@
* limitations under the License.
*/
import { CustomDecorationType } from '@univerjs/core';
import { SetTextSelectionsOperation } from '@univerjs/docs';
import { DEFAULT_DOC_SUBUNIT_ID } from '@univerjs/docs-thread-comment';
import { SetActiveCommentOperation } from '@univerjs/thread-comment-ui';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { ShowCommentPanelOperation } from '../../commands/operations/show-comment-panel.operation';
import { DEFAULT_DOC_SUBUNIT_ID } from '../../common/const';
import { DocThreadCommentSelectionController } from '../doc-thread-comment-selection.controller';
describe('DocThreadCommentSelectionController', () => {
@@ -31,7 +32,14 @@ describe('DocThreadCommentSelectionController', () => {
};
const doc = {
getBody: () => ({ customDecorations: [{ id: 'c1', startIndex: 0, endIndex: 5 }] }),
getBody: () => ({
customDecorations: [
{ id: 'outer', type: CustomDecorationType.COMMENT, startIndex: 0, endIndex: 10 },
{ id: 'older', type: CustomDecorationType.COMMENT, startIndex: 0, endIndex: 5 },
{ id: 'not-comment', type: CustomDecorationType.DELETED, startIndex: 0, endIndex: 5 },
{ id: 'newest', type: CustomDecorationType.COMMENT, startIndex: 0, endIndex: 5 },
],
}),
};
const univerInstanceService = {
@@ -70,7 +78,7 @@ describe('DocThreadCommentSelectionController', () => {
});
expect(executeCommand).toHaveBeenCalledWith(ShowCommentPanelOperation.id, {
activeComment: { unitId: 'doc-1', subUnitId: DEFAULT_DOC_SUBUNIT_ID, commentId: 'c1' },
activeComment: { unitId: 'doc-1', subUnitId: DEFAULT_DOC_SUBUNIT_ID, commentId: 'newest' },
});
controller.dispose();
@@ -84,7 +92,7 @@ describe('DocThreadCommentSelectionController', () => {
};
const doc = {
getBody: () => ({ customDecorations: [{ id: 'c1', startIndex: 0, endIndex: 5 }] }),
getBody: () => ({ customDecorations: [{ id: 'c1', type: CustomDecorationType.COMMENT, startIndex: 0, endIndex: 5 }] }),
};
const univerInstanceService = { getUnit: vi.fn(() => doc) };
@@ -15,7 +15,7 @@
*/
import { Disposable, Inject } from '@univerjs/core';
import { CommentIcon } from '@univerjs/icons';
import { CommentIcon, InsertCommentDoubleIcon } from '@univerjs/icons';
import { ComponentManager, IconManager } from '@univerjs/ui';
import { DOCS_THREAD_COMMENT_PANEL } from '../common/const';
import { DocThreadCommentPanel } from '../views/DocThreadCommentPanel';
@@ -44,6 +44,7 @@ export class ComponentsController extends Disposable {
private _registerIcons(): void {
this.disposeWithMe(this._iconManager.register({
CommentIcon,
InsertCommentDoubleIcon,
}));
}
}
@@ -14,10 +14,11 @@
* limitations under the License.
*/
import type { DocumentDataModel, ITextRange } from '@univerjs/core';
import type { DocumentDataModel, ICustomDecoration, ITextRange } from '@univerjs/core';
import type { ISetTextSelectionsOperationParams } from '@univerjs/docs';
import type { ITextRangeWithStyle } from '@univerjs/engine-render';
import {
CustomDecorationType,
Disposable,
ICommandService,
Inject,
@@ -26,12 +27,12 @@ import {
UniverInstanceType,
} from '@univerjs/core';
import { SetTextSelectionsOperation } from '@univerjs/docs';
import { DEFAULT_DOC_SUBUNIT_ID } from '@univerjs/docs-thread-comment';
import { DocBackScrollRenderController } from '@univerjs/docs-ui';
import { IRenderManagerService } from '@univerjs/engine-render';
import { ThreadCommentModel } from '@univerjs/thread-comment';
import { SetActiveCommentOperation, ThreadCommentPanelService } from '@univerjs/thread-comment-ui';
import { ShowCommentPanelOperation } from '../commands/operations/show-comment-panel.operation';
import { DEFAULT_DOC_SUBUNIT_ID } from '../common/const';
import { DocThreadCommentService } from '../services/doc-thread-comment.service';
export class DocThreadCommentSelectionController extends Disposable {
@@ -65,25 +66,17 @@ export class DocThreadCommentSelectionController extends Disposable {
lastSelection = primary;
if (primary && doc) {
const { startOffset, endOffset, collapsed } = primary;
let customRange;
if (collapsed) { // cursor
customRange = doc.getBody()?.customDecorations?.find((value) => value.startIndex <= startOffset && value.endIndex >= (endOffset - 1));
} else { // range
customRange = doc.getBody()?.customDecorations?.find((value) => value.startIndex <= startOffset && value.endIndex >= (endOffset - 1));
}
const selectionEnd = collapsed ? startOffset : endOffset - 1;
const customRange = this._findActiveCommentDecoration(doc, unitId, startOffset, selectionEnd);
if (customRange) {
const comment = this._threadCommentModel.getComment(unitId, DEFAULT_DOC_SUBUNIT_ID, customRange.id);
if (comment && !comment.resolved) {
this._commandService.executeCommand(ShowCommentPanelOperation.id, {
activeComment: {
unitId,
subUnitId: DEFAULT_DOC_SUBUNIT_ID,
commentId: customRange.id,
},
});
}
this._commandService.executeCommand(ShowCommentPanelOperation.id, {
activeComment: {
unitId,
subUnitId: DEFAULT_DOC_SUBUNIT_ID,
commentId: customRange.id,
},
});
return;
}
}
@@ -109,6 +102,24 @@ export class DocThreadCommentSelectionController extends Disposable {
);
}
private _findActiveCommentDecoration(
doc: DocumentDataModel,
unitId: string,
selectionStart: number,
selectionEnd: number
): ICustomDecoration | undefined {
return [...(doc.getBody()?.customDecorations ?? [])]
.reverse()
.filter((decoration) => decoration.type === CustomDecorationType.COMMENT
&& decoration.startIndex <= selectionStart
&& decoration.endIndex >= selectionEnd)
.sort((left, right) => (left.endIndex - left.startIndex) - (right.endIndex - right.startIndex))
.find((decoration) => {
const comment = this._threadCommentModel.getComment(unitId, DEFAULT_DOC_SUBUNIT_ID, decoration.id);
return comment && !comment.resolved;
});
}
private _initActiveCommandChange() {
this.disposeWithMe(this._threadCommentPanelService.activeCommentId$.subscribe((activeComment) => {
if (activeComment) {
@@ -16,9 +16,12 @@
import { CustomDecorationType } from '@univerjs/core';
import { DOC_INTERCEPTOR_POINT, RichTextEditingMutation } from '@univerjs/docs';
import { Subject } from 'rxjs';
import { DEFAULT_DOC_SUBUNIT_ID } from '@univerjs/docs-thread-comment';
import { getDrawingShapeKeyByDrawingSearch } from '@univerjs/drawing';
import { Vector2 } from '@univerjs/engine-render';
import { serializeThreadCommentAnchor, ThreadCommentAnchorKind } from '@univerjs/thread-comment';
import { BehaviorSubject, Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { DEFAULT_DOC_SUBUNIT_ID } from '../../../common/const';
import { DocThreadCommentRenderController } from '../render.controller';
describe('DocThreadCommentRenderController', () => {
@@ -37,9 +40,12 @@ describe('DocThreadCommentRenderController', () => {
const docRenderController = { reRender };
const activeCommentId$ = new Subject<any>();
const hoveredCommentId$ = new Subject<any>();
const threadCommentPanelService = {
activeCommentId: { unitId: 'doc-1', subUnitId: DEFAULT_DOC_SUBUNIT_ID, commentId: 'c2' },
activeCommentId$,
hoveredCommentId: undefined as any,
hoveredCommentId$,
};
const univerInstanceService = {
@@ -49,7 +55,8 @@ describe('DocThreadCommentRenderController', () => {
const commentUpdate$ = new Subject<any>();
const threadCommentModel = {
commentUpdate$,
getComment: vi.fn((_unitId: string, _subUnitId: string, id: string) => (id === 'c1' ? null : { id, resolved: false })),
getComment: vi.fn((_unitId: string, _subUnitId: string, id: string) => (id === 'c1' ? null : { id, ref: 'text', resolved: false })),
query: vi.fn(() => []),
addComment: vi.fn(),
syncThreadComments: vi.fn(),
};
@@ -66,7 +73,29 @@ describe('DocThreadCommentRenderController', () => {
getUnitId: () => 'doc-1',
getBody: () => ({ customDecorations: [{ id: 'c1', type: CustomDecorationType.COMMENT }, { id: 'c2', type: CustomDecorationType.COMMENT }] }),
};
const context = { unit };
const context = {
unit,
unitId: 'doc-1',
scene: {
addObject: vi.fn(),
getObject: vi.fn(),
getObjectIncludeInGroup: vi.fn(),
},
engine: {
onTransformChange$: {
subscribeEvent: vi.fn(() => ({ dispose: vi.fn() })),
},
},
};
const drawingManagerService = {
add$: new Subject<never[]>(),
update$: new Subject<never[]>(),
remove$: new Subject<never[]>(),
};
const themeService = {
currentTheme$: new Subject<void>(),
getColorFromTheme: vi.fn((token: string) => token),
};
const controller = new DocThreadCommentRenderController(
context as any,
@@ -75,7 +104,9 @@ describe('DocThreadCommentRenderController', () => {
docRenderController as any,
univerInstanceService as any,
threadCommentModel as any,
commandService as any
commandService as any,
drawingManagerService as any,
themeService as any
);
expect(threadCommentModel.addComment).not.toHaveBeenCalled();
@@ -104,10 +135,52 @@ describe('DocThreadCommentRenderController', () => {
);
expect(outResolved.show).toBe(false);
threadCommentPanelService.hoveredCommentId = {
unitId: 'doc-1',
subUnitId: DEFAULT_DOC_SUBUNIT_ID,
commentId: 'c3',
};
const outOverlapping = handler(
{ id: 'c2' },
{
unitId: 'doc-1',
index: 3,
customDecorations: [
{ id: 'c2', startIndex: 0, endIndex: 5 },
{ id: 'c3', startIndex: 2, endIndex: 4 },
],
},
next
);
expect(outOverlapping.active).toBe(true);
const outHovered = handler(
{ id: 'c3' },
{
unitId: 'doc-1',
index: 3,
customDecorations: [
{ id: 'c2', startIndex: 0, endIndex: 5 },
{ id: 'c3', startIndex: 2, endIndex: 4 },
],
},
next
);
expect(outHovered.active).toBe(true);
threadCommentPanelService.hoveredCommentId = {
unitId: 'other-doc',
subUnitId: DEFAULT_DOC_SUBUNIT_ID,
commentId: 'c3',
};
const outActiveWithForeignHover = handler({ id: 'c2' }, { unitId: 'doc-1' }, next);
expect(outActiveWithForeignHover.active).toBe(true);
hoveredCommentId$.next(threadCommentPanelService.hoveredCommentId);
expect(reRender).toHaveBeenCalledWith('doc-1');
// resolved branch triggers rerender
threadCommentModel.getComment.mockImplementation((_unitId: string, _subUnitId: string, id: string) => {
if (id === 'c1') return null;
return { id, resolved: id === 'c2' };
return { id, ref: 'text', resolved: id === 'c2' };
});
const outResolvedComment = handler(
{ id: 'c2' },
@@ -145,4 +218,80 @@ describe('DocThreadCommentRenderController', () => {
controller.dispose();
});
it('keeps drawing comment underlines attached to the rendered object', () => {
const ref = serializeThreadCommentAnchor({
kind: ThreadCommentAnchorKind.DOC_DRAWING,
pageId: 'doc-1',
elementId: 'shape-1',
});
const drawingKey = getDrawingShapeKeyByDrawingSearch({
unitId: 'doc-1',
subUnitId: 'doc-1',
drawingId: 'shape-1',
});
let bounds = { left: 20, top: 40, width: 80, height: 60 };
let roots = ['first-comment', 'newest-comment'];
const scene = {
addObject: vi.fn(),
getObjectIncludeInGroup: vi.fn((key: string) => key === drawingKey ? { getRealBound: () => bounds } : null),
getObject: vi.fn(),
};
const context = {
unitId: 'doc-1',
unit: { getUnitId: () => 'doc-1', getBody: () => ({ customDecorations: [] }) },
scene,
engine: { onTransformChange$: { subscribeEvent: vi.fn(() => ({ dispose: vi.fn() })) } },
};
const activeCommentId$ = new BehaviorSubject({
unitId: 'doc-1',
subUnitId: 'doc-1',
commentId: 'first-comment',
});
const hoveredCommentId$ = new BehaviorSubject<undefined>(undefined);
const panelService = {
activeCommentId: activeCommentId$.value,
hoveredCommentId: hoveredCommentId$.value,
activeCommentId$,
hoveredCommentId$,
};
const commentUpdate$ = new Subject<{ type: string; unitId: string }>();
const commentModel = {
commentUpdate$,
query: vi.fn(() => roots.map((id) => ({ root: { id, ref }, subUnitId: 'doc-1' }))),
getComment: vi.fn((_unitId: string, _subUnitId: string, id: string) => ({ id, ref, resolved: false })),
syncThreadComments: vi.fn(),
};
const drawingManager = { add$: new Subject(), update$: new Subject(), remove$: new Subject() };
const controller = new DocThreadCommentRenderController(
context as never,
{ intercept: vi.fn(() => ({ dispose: vi.fn() })) } as never,
panelService as never,
{ reRender: vi.fn() } as never,
{ getCurrentUnitOfType: vi.fn(() => context.unit) } as never,
commentModel as never,
{ executeCommand: vi.fn(() => Promise.resolve(true)), onCommandExecuted: vi.fn(() => ({ dispose: vi.fn() })) } as never,
drawingManager as never,
{ currentTheme$: new Subject(), getColorFromTheme: vi.fn((token: string) => token) } as never
);
const overlay = scene.addObject.mock.calls[0][0];
expect(overlay.isHit(new Vector2(60, 102))).toBe(true);
expect(overlay.hitCommentId).toBe('newest-comment');
roots = ['first-comment'];
commentUpdate$.next({ type: 'delete', unitId: 'doc-1' });
expect(overlay.isHit(new Vector2(60, 102))).toBe(true);
expect(overlay.hitCommentId).toBe('first-comment');
bounds = { left: 120, top: 140, width: 100, height: 70 };
drawingManager.update$.next([{ unitId: 'doc-1' }]);
expect(overlay.isHit(new Vector2(60, 102))).toBe(false);
expect(overlay.isHit(new Vector2(170, 212))).toBe(true);
roots = [];
commentUpdate$.next({ type: 'resolve', unitId: 'doc-1' });
expect(overlay.isHit(new Vector2(170, 212))).toBe(false);
controller.dispose();
});
});
@@ -14,24 +14,36 @@
* limitations under the License.
*/
import type { DocumentDataModel } from '@univerjs/core';
import type { DocumentDataModel, EventState } from '@univerjs/core';
import type { IRichTextEditingMutationParams } from '@univerjs/docs';
import type { IRenderContext, IRenderModule } from '@univerjs/engine-render';
import type { IMouseEvent, IPointerEvent, IRenderContext, IRenderModule } from '@univerjs/engine-render';
import type { IThreadCommentCanvasOutline, IThreadCommentCanvasUnderline } from '@univerjs/thread-comment-ui';
import {
CustomDecorationType,
Disposable,
ICommandService,
Inject,
IUniverInstanceService,
ThemeService,
toDisposable,
UniverInstanceType,
} from '@univerjs/core';
import { DOC_INTERCEPTOR_POINT, DocInterceptorService, RichTextEditingMutation } from '@univerjs/docs';
import { DEFAULT_DOC_SUBUNIT_ID } from '@univerjs/docs-thread-comment';
import { DocRenderController } from '@univerjs/docs-ui';
import { ThreadCommentModel } from '@univerjs/thread-comment';
import { ThreadCommentPanelService } from '@univerjs/thread-comment-ui';
import { DEFAULT_DOC_SUBUNIT_ID } from '../../common/const';
import { getDrawingShapeKeyByDrawingSearch, IDrawingManagerService } from '@univerjs/drawing';
import { deserializeThreadCommentAnchor, ThreadCommentAnchorKind, ThreadCommentModel } from '@univerjs/thread-comment';
import { ThreadCommentCanvasOverlay, ThreadCommentPanelService } from '@univerjs/thread-comment-ui';
import { pairwise, startWith } from 'rxjs';
import { ShowCommentPanelOperation } from '../../commands/operations/show-comment-panel.operation';
const DOC_COMMENT_DRAWING_OVERLAY_KEY = 'doc-thread-comment-drawing-overlay';
const DOC_COMMENT_DRAWING_OVERLAY_LAYER_INDEX = 10_100;
export class DocThreadCommentRenderController extends Disposable implements IRenderModule {
private readonly _drawingOverlay: ThreadCommentCanvasOverlay;
private readonly _drawingCommentSubUnits = new Map<string, string>();
constructor(
private readonly _context: IRenderContext<DocumentDataModel>,
@Inject(DocInterceptorService) private readonly _docInterceptorService: DocInterceptorService,
@@ -39,45 +51,168 @@ export class DocThreadCommentRenderController extends Disposable implements IRen
@Inject(DocRenderController) private readonly _docRenderController: DocRenderController,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@Inject(ThreadCommentModel) private readonly _threadCommentModel: ThreadCommentModel,
@ICommandService private readonly _commandService: ICommandService
@ICommandService private readonly _commandService: ICommandService,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@Inject(ThemeService) private readonly _themeService: ThemeService
) {
super();
this._drawingOverlay = new ThreadCommentCanvasOverlay(DOC_COMMENT_DRAWING_OVERLAY_KEY, {
...this._getDrawingOverlayColors(),
zoomRatio: 1,
markers: [],
underlines: [],
});
this._context.scene.addObject(this._drawingOverlay, DOC_COMMENT_DRAWING_OVERLAY_LAYER_INDEX);
this._interceptorViewModel();
this._initReRender();
this._initSyncComments();
this._initDrawingOverlay();
}
private _initReRender() {
this.disposeWithMe(this._threadCommentPanelService.activeCommentId$.subscribe((activeComment) => {
if (activeComment) {
this._docRenderController.reRender(activeComment.unitId);
return;
}
const unitId = this._univerInstanceService.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC)?.getUnitId();
if (unitId) {
this._docRenderController.reRender(unitId);
}
}));
[
this._threadCommentPanelService.activeCommentId$,
this._threadCommentPanelService.hoveredCommentId$,
].forEach((observable) => this.disposeWithMe(observable.pipe(
startWith(undefined),
pairwise()
).subscribe(([previous, current]) => {
const currentUnitId = this._univerInstanceService
.getCurrentUnitOfType(UniverInstanceType.UNIVER_DOC)
?.getUnitId();
new Set([previous?.unitId, current?.unitId, currentUnitId]).forEach((unitId) => {
if (unitId) {
this._docRenderController.reRender(unitId);
}
});
})));
this.disposeWithMe(this._threadCommentModel.commentUpdate$.subscribe((update) => {
if (update.type === 'resolve') {
this._docRenderController.reRender(update.unitId);
}
if (update.unitId === this._context.unitId) {
this._syncDrawingOverlay();
}
}));
}
private _initDrawingOverlay(): void {
this.disposeWithMe(toDisposable(this._drawingOverlay.onPointerDown$.subscribeEvent(
(_event: IPointerEvent | IMouseEvent, state: EventState) => {
const commentId = this._drawingOverlay.hitCommentId;
const subUnitId = commentId && this._drawingCommentSubUnits.get(commentId);
if (!commentId || !subUnitId) {
return;
}
state.stopPropagation();
this._commandService.executeCommand(ShowCommentPanelOperation.id, {
activeComment: { unitId: this._context.unitId, subUnitId, commentId, trigger: 'doc-canvas' },
});
}
)));
this.disposeWithMe(toDisposable(
this._context.engine.onTransformChange$.subscribeEvent(() => this._syncDrawingOverlay())
));
[this._threadCommentPanelService.activeCommentId$, this._threadCommentPanelService.hoveredCommentId$]
.forEach((observable) => this.disposeWithMe(observable.subscribe(() => this._syncDrawingOverlay())));
[this._drawingManagerService.add$, this._drawingManagerService.update$, this._drawingManagerService.remove$]
.forEach((observable) => this.disposeWithMe(observable.subscribe((drawings) => {
if (drawings.some((drawing) => drawing.unitId === this._context.unitId)) {
this._syncDrawingOverlay();
}
})));
this.disposeWithMe(this._themeService.currentTheme$.subscribe(() => this._syncDrawingOverlay()));
this._syncDrawingOverlay();
}
private _syncDrawingOverlay(): void {
const underlines = new Map<string, IThreadCommentCanvasUnderline>();
this._drawingCommentSubUnits.clear();
this._threadCommentModel.query({
unitIds: [this._context.unitId],
anchorKinds: [ThreadCommentAnchorKind.DOC_DRAWING],
resolved: false,
}).forEach(({ root, subUnitId }) => {
const anchor = deserializeThreadCommentAnchor(root.ref);
if (anchor?.kind !== ThreadCommentAnchorKind.DOC_DRAWING) {
return;
}
const outline = this._getDrawingOutline(anchor.pageId ?? subUnitId, anchor.elementId);
if (!outline) {
return;
}
this._drawingCommentSubUnits.set(root.id, subUnitId);
underlines.set(`${anchor.pageId ?? subUnitId}\0${anchor.elementId}`, {
commentId: root.id,
left: outline.left,
top: outline.top + outline.height + 2,
width: outline.width,
});
});
const focusedCommentIds: string[] = [];
const focusOutlines = new Map<string, IThreadCommentCanvasOutline>();
[this._threadCommentPanelService.activeCommentId, this._threadCommentPanelService.hoveredCommentId]
.forEach((target) => {
if (!target || target.unitId !== this._context.unitId) {
return;
}
const comment = this._threadCommentModel.getComment(target.unitId, target.subUnitId, target.commentId);
const anchor = comment && deserializeThreadCommentAnchor(comment.ref);
if (anchor?.kind !== ThreadCommentAnchorKind.DOC_DRAWING) {
return;
}
focusedCommentIds.push(target.commentId);
const outline = this._getDrawingOutline(anchor.pageId ?? target.subUnitId, anchor.elementId);
if (outline) {
focusOutlines.set(`${anchor.pageId ?? target.subUnitId}\0${anchor.elementId}`, outline);
}
});
this._drawingOverlay.updateState({
...this._getDrawingOverlayColors(),
markers: [],
underlines: Array.from(underlines.values()),
focusedCommentIds,
focusOutlines: Array.from(focusOutlines.values()),
});
}
private _getDrawingOutline(subUnitId: string, drawingId: string): IThreadCommentCanvasOutline | null {
const objectKey = getDrawingShapeKeyByDrawingSearch({
unitId: this._context.unitId,
subUnitId,
drawingId,
});
const object = this._context.scene.getObjectIncludeInGroup?.(objectKey)
?? this._context.scene.getObject(objectKey);
if (!object) {
return null;
}
const bounds = object.getRealBound();
return { left: bounds.left, top: bounds.top, width: bounds.width, height: bounds.height };
}
private _getDrawingOverlayColors(): { accentColor: string; foregroundColor: string; outlineColor: string } {
return {
accentColor: this._themeService.getColorFromTheme('yellow.400'),
foregroundColor: this._themeService.getColorFromTheme('gray.900'),
outlineColor: this._themeService.getColorFromTheme('white'),
};
}
private _interceptorViewModel() {
this._docInterceptorService.intercept(DOC_INTERCEPTOR_POINT.CUSTOM_DECORATION, {
handler: (data, pos, next) => {
if (!data) {
return next(data);
}
const { unitId, index, customDecorations } = pos;
const activeComment = this._threadCommentPanelService.activeCommentId;
const { commentId, unitId: commentUnitID } = activeComment || {};
const activeCustomDecoration = customDecorations.find((i) => i.id === commentId);
const { unitId } = pos;
const focusedComments = [
this._threadCommentPanelService.activeCommentId,
this._threadCommentPanelService.hoveredCommentId,
];
const comment = this._threadCommentModel.getComment(unitId, DEFAULT_DOC_SUBUNIT_ID, data.id);
if (!comment) {
return next({
@@ -86,11 +221,14 @@ export class DocThreadCommentRenderController extends Disposable implements IRen
});
}
const isActiveIndex = activeCustomDecoration && index >= activeCustomDecoration.startIndex && index <= activeCustomDecoration.endIndex;
const isActive = commentUnitID === unitId && data.id === commentId;
const isActive = focusedComments.some((focusedComment) => (
focusedComment?.unitId === unitId
&& focusedComment.subUnitId === DEFAULT_DOC_SUBUNIT_ID
&& focusedComment.commentId === data.id
));
return next({
...data,
active: isActive || isActiveIndex,
active: isActive,
show: !comment.resolved,
});
},
@@ -20,6 +20,7 @@ import { IMenuManagerService } from '@univerjs/ui';
import { AddDocCommentComment } from '../commands/commands/add-doc-comment.command';
import { DeleteDocCommentComment } from '../commands/commands/delete-doc-comment.command';
import {
AddDocDrawingCommentOperation,
ShowCommentPanelOperation,
StartAddCommentOperation,
ToggleCommentPanelOperation,
@@ -38,6 +39,7 @@ export class DocThreadCommentUIController extends Disposable {
private _initCommands() {
[
AddDocDrawingCommentOperation,
AddDocCommentComment,
DeleteDocCommentComment,
ShowCommentPanelOperation,
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
panel: {
title: 'إدارة التعليقات',
addComment: 'إضافة تعليق',
openComments: 'فتح التعليقات',
},
},
};
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
panel: {
title: 'Gestió de comentaris',
addComment: 'Afegeix un comentari',
openComments: 'Obre els comentaris',
},
},
};
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
panel: {
title: 'Kommentarverwaltung',
addComment: 'Kommentar hinzufügen',
openComments: 'Kommentare öffnen',
},
},
};
@@ -19,6 +19,7 @@ const locale = {
panel: {
title: 'Comment Management',
addComment: 'Add Comment',
openComments: 'Open Comments',
},
},
};
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
panel: {
title: 'Gestión de comentarios',
addComment: 'Añadir comentario',
openComments: 'Abrir comentarios',
},
},
};
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
panel: {
title: 'مدیریت نظرات',
addComment: 'افزودن نظر',
openComments: 'باز کردن نظرات',
},
},
};
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
panel: {
title: 'Gestion des commentaires',
addComment: 'Ajouter un commentaire',
openComments: 'Ouvrir les commentaires',
},
},
};
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
panel: {
title: 'Manajemen Komentar',
addComment: 'Tambah Komentar',
openComments: 'Buka Komentar',
},
},
};
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
panel: {
title: 'Gestione Commenti',
addComment: 'Aggiungi Commento',
openComments: 'Apri commenti',
},
},
};
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
panel: {
title: 'コメント管理',
addComment: 'コメントを追加',
openComments: 'コメントを開く',
},
},
};
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
panel: {
title: '댓글 관리',
addComment: '댓글 추가',
openComments: '댓글 열기',
},
},
};
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
panel: {
title: 'Zarządzanie komentarzami',
addComment: 'Dodaj komentarz',
openComments: 'Otwórz komentarze',
},
},
};
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
panel: {
title: 'Gerenciamento de Comentários',
addComment: 'Adicionar Comentário',
openComments: 'Abrir comentários',
},
},
};
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
panel: {
title: 'Управление комментариями',
addComment: 'Добавить комментарий',
openComments: 'Открыть комментарии',
},
},
};
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
panel: {
title: 'Správa komentárov',
addComment: 'Pridať komentár',
openComments: 'Otvoriť komentáre',
},
},
};
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
panel: {
title: 'Quản lý Bình luận',
addComment: 'Thêm bình luận',
openComments: 'Mở bình luận',
},
},
};
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
panel: {
title: '评论管理',
addComment: '添加评论',
openComments: '打开评论',
},
},
};
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
panel: {
title: '評論管理',
addComment: '新增評論',
openComments: '開啟評論',
},
},
};
@@ -21,6 +21,7 @@ const locale: typeof enUS = {
panel: {
title: '評論管理',
addComment: '新增評論',
openComments: '開啟評論',
},
},
};
@@ -23,10 +23,22 @@ import { DocumentEditArea, IRenderManagerService, withCurrentTypeOfRenderer } fr
import { getMenuHiddenObservable, MenuItemType } from '@univerjs/ui';
import { debounceTime, Observable } from 'rxjs';
import {
AddDocDrawingCommentOperation,
StartAddCommentOperation,
ToggleCommentPanelOperation,
} from '../commands/operations/show-comment-panel.operation';
export function AddDocDrawingCommentMenuItemFactory(accessor: IAccessor): IMenuButtonItem<LocaleKey> {
return {
id: AddDocDrawingCommentOperation.id,
type: MenuItemType.BUTTON,
icon: 'InsertCommentDoubleIcon',
title: 'docs-thread-comment-ui.panel.addComment',
tooltip: 'docs-thread-comment-ui.panel.addComment',
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
};
}
export const shouldDisableAddComment = (accessor: IAccessor) => {
const renderManagerService = accessor.get(IRenderManagerService);
const docSelectionManagerService = accessor.get(DocSelectionManagerService);
@@ -55,7 +67,7 @@ export function AddDocCommentMenuItemFactory(accessor: IAccessor): IMenuButtonIt
return {
id: StartAddCommentOperation.id,
type: MenuItemType.BUTTON,
icon: 'CommentIcon',
icon: 'InsertCommentDoubleIcon',
title: 'docs-thread-comment-ui.panel.addComment',
tooltip: 'docs-thread-comment-ui.panel.addComment',
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC, undefined, SHEET_EDITOR_UNITS),
@@ -77,8 +89,8 @@ export function ToolbarDocCommentMenuItemFactory(accessor: IAccessor): IMenuButt
id: ToggleCommentPanelOperation.id,
type: MenuItemType.BUTTON,
icon: 'CommentIcon',
title: 'docs-thread-comment-ui.panel.addComment',
tooltip: 'docs-thread-comment-ui.panel.addComment',
title: 'docs-thread-comment-ui.panel.openComments',
tooltip: 'docs-thread-comment-ui.panel.openComments',
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_DOC),
};
}
@@ -16,19 +16,26 @@
import type { MenuSchemaType } from '@univerjs/ui';
import { FLOAT_TOOLBAR_MENU_POSITION } from '@univerjs/docs-ui';
import { ContextMenuGroup, ContextMenuPosition, RibbonInsertGroup } from '@univerjs/ui';
import { ContextMenuGroup, ContextMenuPosition, FloatingObjectToolbarPosition, RibbonInsertGroup } from '@univerjs/ui';
import {
AddDocDrawingCommentOperation,
StartAddCommentOperation,
ToggleCommentPanelOperation,
} from '../commands/operations/show-comment-panel.operation';
import { AddDocCommentMenuItemFactory, ToolbarDocCommentMenuItemFactory } from './menu';
import { AddDocCommentMenuItemFactory, AddDocDrawingCommentMenuItemFactory, ToolbarDocCommentMenuItemFactory } from './menu';
export const menuSchema: MenuSchemaType = {
[RibbonInsertGroup.MEDIA]: {
[ToggleCommentPanelOperation.id]: {
order: 3,
gridLayout: { row: 1, column: 4, showLabel: true },
menuItemFactory: ToolbarDocCommentMenuItemFactory,
},
[StartAddCommentOperation.id]: {
order: 3.1,
gridLayout: { row: 2, column: 4, showLabel: true },
menuItemFactory: AddDocCommentMenuItemFactory,
},
},
[FLOAT_TOOLBAR_MENU_POSITION]: {
[StartAddCommentOperation.id]: {
@@ -36,6 +43,12 @@ export const menuSchema: MenuSchemaType = {
menuItemFactory: AddDocCommentMenuItemFactory,
},
},
[FloatingObjectToolbarPosition.DOC]: {
[AddDocDrawingCommentOperation.id]: {
order: 10,
menuItemFactory: AddDocDrawingCommentMenuItemFactory,
},
},
[ContextMenuPosition.MAIN_AREA]: {
[ContextMenuGroup.DATA]: {
[StartAddCommentOperation.id]: {
@@ -44,4 +57,12 @@ export const menuSchema: MenuSchemaType = {
},
},
},
[ContextMenuPosition.DRAWING]: {
[ContextMenuGroup.DATA]: {
[AddDocDrawingCommentOperation.id]: {
order: 1,
menuItemFactory: AddDocDrawingCommentMenuItemFactory,
},
},
},
};
+12 -9
View File
@@ -14,11 +14,12 @@
* limitations under the License.
*/
import type { Dependency } from '@univerjs/core';
import type { IUniverDocsThreadCommentUIConfig } from './config/config';
import { DependentOn, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core';
import { UniverDocsPlugin } from '@univerjs/docs';
import { UniverDocsThreadCommentPlugin } from '@univerjs/docs-thread-comment';
import { UniverDocsUIPlugin } from '@univerjs/docs-ui';
import { UniverDrawingPlugin } from '@univerjs/drawing';
import { IRenderManagerService, UniverRenderEnginePlugin } from '@univerjs/engine-render';
import { UniverThreadCommentPlugin } from '@univerjs/thread-comment';
import { UniverThreadCommentUIPlugin } from '@univerjs/thread-comment-ui';
@@ -31,9 +32,17 @@ import { DocThreadCommentRenderController } from './controllers/render-controlle
import { DocThreadCommentUIController } from './controllers/ui.controller';
import { DocThreadCommentService } from './services/doc-thread-comment.service';
const STARTING_DEPENDENCIES: Array<Parameters<Injector['add']>[0]> = [
[DocThreadCommentUIController],
[DocThreadCommentSelectionController],
[DocThreadCommentService],
];
@DependentOn(
UniverDocsPlugin,
UniverDocsThreadCommentPlugin,
UniverThreadCommentPlugin,
UniverDrawingPlugin,
UniverRenderEnginePlugin,
UniverDocsUIPlugin,
UniverThreadCommentUIPlugin
@@ -67,11 +76,7 @@ export class UniverDocsThreadCommentUIPlugin extends Plugin {
override onStarting(): void {
this._injector.add([ComponentsController]);
this._injector.get(ComponentsController);
([
[DocThreadCommentUIController],
[DocThreadCommentSelectionController],
[DocThreadCommentService],
] as Dependency[]).forEach((dep) => {
STARTING_DEPENDENCIES.forEach((dep) => {
this._injector.add(dep);
});
}
@@ -84,8 +89,6 @@ export class UniverDocsThreadCommentUIPlugin extends Plugin {
}
private _initRenderModule() {
[DocThreadCommentRenderController].forEach((dep) => {
this._renderManagerSrv.registerRenderModule(UniverInstanceType.UNIVER_DOC, dep as unknown as Dependency);
});
this._renderManagerSrv.registerRenderModule(UniverInstanceType.UNIVER_DOC, [DocThreadCommentRenderController]);
}
}
@@ -17,25 +17,35 @@
import type { DocumentDataModel } from '@univerjs/core';
import type { IAddDocCommentComment } from '../commands/commands/add-doc-comment.command';
import type { IDeleteDocCommentComment } from '../commands/commands/delete-doc-comment.command';
import { ICommandService, Injector, isInternalEditorID, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { ICommandService, Injector, isInternalEditorID, IUniverInstanceService, UniverInstanceType, UserManagerService } from '@univerjs/core';
import { DocSelectionManagerService, RichTextEditingMutation } from '@univerjs/docs';
import { ThreadCommentPanel } from '@univerjs/thread-comment-ui';
import { DEFAULT_DOC_SUBUNIT_ID } from '@univerjs/docs-thread-comment';
import { deserializeThreadCommentAnchor, serializeThreadCommentAnchor, ThreadCommentAnchorKind, ThreadCommentModel } from '@univerjs/thread-comment';
import { ThreadCommentDraftService, ThreadCommentPanel } from '@univerjs/thread-comment-ui';
import { useDependency, useObservable } from '@univerjs/ui';
import { useEffect, useMemo, useState } from 'react';
import { debounceTime, filter, map, Observable } from 'rxjs';
import { AddDocCommentComment } from '../commands/commands/add-doc-comment.command';
import { DeleteDocCommentComment } from '../commands/commands/delete-doc-comment.command';
import { StartAddCommentOperation } from '../commands/operations/show-comment-panel.operation';
import { DEFAULT_DOC_SUBUNIT_ID } from '../common/const';
import { shouldDisableAddComment } from '../menu/menu';
import { DocThreadCommentService } from '../services/doc-thread-comment.service';
export function getDocCommentPanelSubUnitId(draft: { unitId: string; subUnitId: string } | null, unitId: string | undefined): string {
return draft && draft.unitId === unitId ? draft.subUnitId : DEFAULT_DOC_SUBUNIT_ID;
}
export const DocThreadCommentPanel = () => {
const univerInstanceService = useDependency(IUniverInstanceService);
const injector = useDependency(Injector);
const doc$ = useMemo(() => univerInstanceService.getCurrentTypeOfUnit$<DocumentDataModel>(UniverInstanceType.UNIVER_DOC).pipe(filter((doc) => !!doc && !isInternalEditorID(doc.getUnitId()))), [univerInstanceService]);
const doc = useObservable(doc$);
const subUnitId$ = useMemo(() => new Observable<string>((sub) => sub.next(DEFAULT_DOC_SUBUNIT_ID)), []);
const draftService = useDependency(ThreadCommentDraftService);
const drawingDraft = useObservable(draftService.draft$, draftService.draft);
const subUnitId$ = useMemo(
() => new Observable<string>((sub) => sub.next(getDocCommentPanelSubUnitId(drawingDraft, doc?.getUnitId()))),
[doc, drawingDraft]
);
const docSelectionManagerService = useDependency(DocSelectionManagerService);
const selectionChange$ = useMemo(
() => docSelectionManagerService.textSelection$.pipe(debounceTime(16)),
@@ -48,8 +58,35 @@ export const DocThreadCommentPanel = () => {
[injector, selectionChange$]
);
const commandService = useDependency(ICommandService);
const threadCommentModel = useDependency(ThreadCommentModel);
useObservable(threadCommentModel.commentUpdate$);
const docCommentService = useDependency(DocThreadCommentService);
const tempComment = useObservable(docCommentService.addingComment$);
const textTempComment = useObservable(docCommentService.addingComment$);
const userManagerService = useDependency(UserManagerService);
const drawingTempComment = drawingDraft?.anchor.kind === ThreadCommentAnchorKind.DOC_DRAWING && drawingDraft.unitId === doc?.getUnitId()
? {
id: '',
threadId: '',
unitId: drawingDraft.unitId,
subUnitId: drawingDraft.subUnitId,
ref: serializeThreadCommentAnchor(drawingDraft.anchor),
dT: '',
personId: userManagerService.getCurrentUser().userID,
text: { dataStream: '\r\n' },
}
: null;
const tempComment = drawingTempComment ?? textTempComment;
const drawingIds = new Set(Object.keys(doc?.getSnapshot().drawings ?? {}));
const isDrawingComment = (comment: { ref: string }) => {
const anchor = deserializeThreadCommentAnchor(comment.ref);
return anchor?.kind === ThreadCommentAnchorKind.DOC_DRAWING
|| (comment.ref.startsWith('#') && drawingIds.has(comment.ref.slice(1)));
};
const drawingCommentIds = doc
? threadCommentModel.getUnit(doc.getUnitId())
.filter((thread) => isDrawingComment(thread.root))
.map((thread) => thread.root.id)
: [];
const [commentIds, setCommentIds] = useState<string[]>([]);
useEffect(() => {
@@ -94,33 +131,48 @@ export const DocThreadCommentPanel = () => {
getSubUnitName={() => ''}
disableAdd={disableAdd}
tempComment={tempComment}
onAddComment={(comment) => {
onAddComment={async (comment) => {
if (drawingTempComment && !comment.parentId) {
return true;
}
// attach an comment to an custom-range
if (!comment.parentId) {
const params: IAddDocCommentComment = {
unitId,
range: tempComment!,
range: textTempComment!,
comment,
};
commandService.executeCommand(AddDocCommentComment.id, params);
const success = await commandService.executeCommand(AddDocCommentComment.id, params);
if (!success) {
throw new Error('Failed to add document comment.');
}
docCommentService.endAdd();
return false;
}
return true;
}}
onDeleteComment={(comment) => {
onAfterDeleteComment={async (comment) => {
if (!comment.parentId) {
if (isDrawingComment(comment)) {
return;
}
const params: IDeleteDocCommentComment = {
unitId,
commentId: comment.id,
};
commandService.executeCommand(DeleteDocCommentComment.id, params);
return false;
await commandService.executeCommand(DeleteDocCommentComment.id, params);
}
return true;
}}
showComments={commentIds}
showComments={[
...commentIds,
...drawingCommentIds,
]}
onTempCommentClose={() => draftService.cancel()}
formatRef={(comment) => {
const anchor = deserializeThreadCommentAnchor(comment.ref);
return anchor?.kind === ThreadCommentAnchorKind.DOC_DRAWING ? `#${anchor.elementId}` : comment.ref;
}}
/>
);
};
@@ -31,18 +31,18 @@ import {
UserManagerService,
} from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { DEFAULT_DOC_SUBUNIT_ID } from '@univerjs/docs-thread-comment';
import { IRenderManagerService } from '@univerjs/engine-render';
import { IThreadCommentDataSourceService, ThreadCommentDataSourceService, ThreadCommentModel } from '@univerjs/thread-comment';
import { SetActiveCommentOperation, ThreadCommentPanelService } from '@univerjs/thread-comment-ui';
import { SetActiveCommentOperation, ThreadCommentDraftService, ThreadCommentPanelService } from '@univerjs/thread-comment-ui';
import threadCommentEnUS from '@univerjs/thread-comment-ui/locale/en-US';
import { ISidebarService, RediContext } from '@univerjs/ui';
import { act } from 'react';
import { createRoot } from 'react-dom/client';
import { Subject } from 'rxjs';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DEFAULT_DOC_SUBUNIT_ID } from '../../common/const';
import { DocThreadCommentService } from '../../services/doc-thread-comment.service';
import { DocThreadCommentPanel } from '../DocThreadCommentPanel';
import { DocThreadCommentPanel, getDocCommentPanelSubUnitId } from '../DocThreadCommentPanel';
const DOC_ID = 'doc-thread-comment-panel-test';
@@ -179,6 +179,7 @@ function createPanelTestBed(decorationIds: string[]) {
injector.add([IThreadCommentDataSourceService, { useClass: ThreadCommentDataSourceService }]);
injector.add([ThreadCommentModel]);
injector.add([ISidebarService, { useClass: TestSidebarService as never }]);
injector.add([ThreadCommentDraftService]);
injector.add([ThreadCommentPanelService]);
injector.add([DocThreadCommentService]);
@@ -256,4 +257,11 @@ describe('DocThreadCommentPanel', () => {
expect(container.textContent).not.toContain('Detached feedback');
expect(container.textContent?.match(/Visible document feedback/g)).toHaveLength(1);
});
it('uses the drawing subunit while a drawing comment draft is active', () => {
expect(getDocCommentPanelSubUnitId({ unitId: DOC_ID, subUnitId: DOC_ID }, DOC_ID)).toBe(DOC_ID);
expect(getDocCommentPanelSubUnitId({ unitId: 'other-doc', subUnitId: 'other-doc' }, DOC_ID)).toBe(DEFAULT_DOC_SUBUNIT_ID);
expect(getDocCommentPanelSubUnitId(null, DOC_ID)).toBe(DEFAULT_DOC_SUBUNIT_ID);
expect(getDocCommentPanelSubUnitId(null, undefined)).toBe(DEFAULT_DOC_SUBUNIT_ID);
});
});
+39
View File
@@ -0,0 +1,39 @@
# @univerjs/docs-thread-comment
`@univerjs/docs-thread-comment` provides model commands and Facade APIs for comments anchored to fixed document text ranges. It has no UI or rendering dependency and can be used in Node environments.
## Package Overview
| Package | UMD global | CSS | Locales | Facade entry |
| --- | --- | :---: | :---: | :---: |
| `@univerjs/docs-thread-comment` | `UniverDocsThreadComment` | No | No | Yes |
## Installation
```sh
pnpm add @univerjs/docs-thread-comment
# or
npm install @univerjs/docs-thread-comment
```
Keep all `@univerjs/*` packages on the same version.
## Usage
```ts
import { UniverDocsThreadCommentPlugin } from '@univerjs/docs-thread-comment';
import '@univerjs/docs-thread-comment/facade';
univer.registerPlugin(UniverDocsThreadCommentPlugin);
const range = univerAPI.getActiveDocument()?.getTextRange(0, 12);
await range?.createCommentAsync('Verify this introduction.', { id: 'review-intro' });
```
Use `@univerjs/docs-thread-comment-ui` separately when menus, canvas decorations, or the shared side panel are required.
## Resources
- [Documentation](https://docs.univer.ai)
- [NPM package](https://npmjs.com/package/@univerjs/docs-thread-comment)
- [GitHub repository](https://github.com/dream-num/univer)
+87
View File
@@ -0,0 +1,87 @@
{
"name": "@univerjs/docs-thread-comment",
"version": "1.0.0-beta.2",
"private": false,
"description": "Thread comment model integration and Facade APIs for Univer Docs.",
"author": "DreamNum Co., Ltd. <developer@univer.ai>",
"license": "Apache-2.0",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/univer"
},
"homepage": "https://univer.ai",
"repository": {
"type": "git",
"url": "https://github.com/dream-num/univer"
},
"bugs": {
"url": "https://github.com/dream-num/univer/issues"
},
"keywords": [
"univer",
"docs",
"comment",
"thread-comment"
],
"exports": {
".": "./src/index.ts",
"./facade": "./src/facade/index.ts",
"./*": "./src/*"
},
"main": "./src/index.ts",
"types": "./lib/types/index.d.ts",
"publishConfig": {
"access": "public",
"main": "./lib/es/index.js",
"module": "./lib/es/index.js",
"exports": {
".": {
"import": "./lib/es/index.js",
"require": "./lib/cjs/index.js",
"types": "./lib/types/index.d.ts"
},
"./*": {
"import": "./lib/es/*",
"require": "./lib/cjs/*",
"types": "./lib/types/index.d.ts"
},
"./facade": {
"import": "./lib/es/facade.js",
"require": "./lib/cjs/facade.js",
"types": "./lib/types/facade/index.d.ts"
},
"./lib/facade": {
"import": "./lib/es/facade.js",
"require": "./lib/cjs/facade.js",
"types": "./lib/types/facade/index.d.ts"
},
"./lib/*": "./lib/*"
}
},
"directories": {
"lib": "lib"
},
"files": [
"lib"
],
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"coverage": "vitest run --coverage",
"typecheck": "tsc --noEmit",
"build:bundle": "univer-cli build",
"build:types": "tsc -p tsconfig.node.json",
"build": "pnpm run build:bundle && pnpm run build:types"
},
"dependencies": {
"@univerjs/core": "workspace:*",
"@univerjs/docs": "workspace:*",
"@univerjs/thread-comment": "workspace:*"
},
"devDependencies": {
"@univerjs-infra/shared": "workspace:*",
"rxjs": "^7.8.2",
"typescript": "^6.0.3",
"vitest": "^4.1.10"
}
}
@@ -0,0 +1,113 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IDocumentData } from '@univerjs/core';
import {
ICommandService,
IUniverInstanceService,
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { FDocument } from '@univerjs/docs/facade';
import {
AddCommentMutation,
IThreadCommentDataSourceService,
ThreadCommentDataSourceService,
ThreadCommentFacadeService,
ThreadCommentModel,
} from '@univerjs/thread-comment';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { DEFAULT_DOC_SUBUNIT_ID } from '../../../common/const';
import {
AddDocCommentDecorationMutation,
CreateDocTextRangeCommentCommand,
} from '../create-doc-text-range-comment.command';
import '@univerjs/docs-thread-comment/facade';
const DOC_ID = 'headless-doc-comment-test';
function createDocData(): IDocumentData {
return {
id: DOC_ID,
body: {
dataStream: 'Hello world\r\n',
textRuns: [],
paragraphs: [],
sectionBreaks: [],
customBlocks: [],
customDecorations: [],
},
documentStyle: {
pageSize: { width: 594.3, height: 840.51 },
marginTop: 72,
marginBottom: 72,
marginRight: 90,
marginLeft: 90,
},
};
}
describe('CreateDocTextRangeCommentCommand', () => {
let univer: Univer;
beforeEach(() => {
univer = new Univer();
const injector = univer.__getInjector();
injector.add([IThreadCommentDataSourceService, { useClass: ThreadCommentDataSourceService }]);
injector.add([ThreadCommentModel]);
injector.add([ThreadCommentFacadeService]);
univer.createUnit(UniverInstanceType.UNIVER_DOC, createDocData());
const commandService = injector.get(ICommandService);
commandService.registerCommand(CreateDocTextRangeCommentCommand);
commandService.registerCommand(AddCommentMutation);
commandService.registerCommand(AddDocCommentDecorationMutation);
});
afterEach(() => univer.dispose());
it('creates and queries a fixed text comment without UI services', async () => {
const injector = univer.__getInjector();
const commandService = injector.get(ICommandService);
await expect(commandService.executeCommand(CreateDocTextRangeCommentCommand.id, {
unitId: DOC_ID,
range: { startOffset: 0, endOffset: 5 },
content: 'Check the greeting.',
id: 'agent-comment-1',
personId: 'agent-user-1',
})).resolves.toBe(true);
const documentModel = injector.get(IUniverInstanceService)
.getUnit<DocumentDataModel>(DOC_ID, UniverInstanceType.UNIVER_DOC);
if (!documentModel) {
throw new Error('Test document was not created.');
}
const range = injector.createInstance(FDocument, documentModel).getTextRange(0, 5);
expect(range.getComments()).toHaveLength(1);
expect(injector.get(ThreadCommentModel).getThread(
DOC_ID,
DEFAULT_DOC_SUBUNIT_ID,
'agent-comment-1'
)?.root).toMatchObject({
ref: 'Hello',
startOffset: 0,
endOffset: 5,
personId: 'agent-user-1',
});
});
});
@@ -0,0 +1,206 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { DocumentDataModel, IAccessor, ICommand, IMutation, IMutationInfo, ITextRangeParam, JSONXActions } from '@univerjs/core';
import type { IThreadComment, ThreadCommentContent } from '@univerjs/thread-comment';
import {
BuildTextUtils,
CommandType,
CustomDecorationType,
generateRandomId,
ICommandService,
IUniverInstanceService,
JSONX,
sequenceExecute,
UniverInstanceType,
UserManagerService,
} from '@univerjs/core';
import {
AddCommentMutation,
getDT,
IThreadCommentDataSourceService,
normalizeThreadCommentContent,
} from '@univerjs/thread-comment';
import { DEFAULT_DOC_SUBUNIT_ID } from '../../common/const';
export interface IAddDocTextRangeCommentParams {
unitId: string;
comment: IThreadComment;
range: ITextRangeParam;
}
/** Parameters accepted by the document text-range Facade. */
export interface ICreateDocTextRangeCommentParams {
unitId: string;
range: ITextRangeParam;
content: ThreadCommentContent;
attachments?: string[];
id?: string;
threadId?: string;
personId?: string;
dateTime?: Date;
}
interface IValidTextRange extends ITextRangeParam {
startOffset: number;
endOffset: number;
}
export interface IPreparedDocTextRangeComment {
comment: IThreadComment;
commentMutation: IMutationInfo;
decorationMutationParams: IDocCommentDecorationMutationParams;
}
export interface IDocCommentDecorationMutationParams {
unitId: string;
actions: JSONXActions;
segmentId?: string;
}
/** Applies document comment decoration actions without render or selection services. */
export const AddDocCommentDecorationMutation: IMutation<
IDocCommentDecorationMutationParams,
IDocCommentDecorationMutationParams | false
> = {
id: 'docs-thread-comment.mutation.add-decoration',
type: CommandType.MUTATION,
handler(accessor, params) {
if (!params || JSONX.isNoop(params.actions)) {
return false;
}
const document = accessor.get(IUniverInstanceService)
.getUnit<DocumentDataModel>(params.unitId, UniverInstanceType.UNIVER_DOC);
if (!document) {
return false;
}
const undoActions = JSONX.invertWithDoc(params.actions, document.getSnapshot());
document.apply(params.actions);
return { ...params, actions: undoActions };
},
};
function getValidRange(accessor: IAccessor, unitId: string, range: ITextRangeParam): IValidTextRange | null {
const { startOffset, endOffset } = range;
if (!Number.isInteger(startOffset) || !Number.isInteger(endOffset) || startOffset < 0 || endOffset <= startOffset) {
return null;
}
const document = accessor.get(IUniverInstanceService).getUnit<DocumentDataModel>(unitId, UniverInstanceType.UNIVER_DOC);
const body = document?.getSelfOrHeaderFooterModel(range.segmentId)?.getBody();
if (!body || endOffset > body.dataStream.length) {
return null;
}
return { ...range, startOffset, endOffset, collapsed: false };
}
/** Builds the model mutations shared by headless and UI document comment commands. */
export async function prepareDocTextRangeComment(
accessor: IAccessor,
params: IAddDocTextRangeCommentParams
): Promise<IPreparedDocTextRangeComment | null> {
const range = getValidRange(accessor, params.unitId, params.range);
if (!range) {
return null;
}
const savedComment = await accessor.get(IThreadCommentDataSourceService).addComment(params.comment);
const comment: IThreadComment = {
...params.comment,
...savedComment,
unitId: params.unitId,
subUnitId: DEFAULT_DOC_SUBUNIT_ID,
startOffset: range.startOffset,
endOffset: range.endOffset,
segmentId: range.segmentId,
collapsed: false,
};
const textX = BuildTextUtils.customDecoration.add({
ranges: [range],
id: comment.id,
type: CustomDecorationType.COMMENT,
});
const decorationMutationParams: IDocCommentDecorationMutationParams = {
unitId: params.unitId,
actions: JSONX.getInstance().editOp(textX.serialize()),
segmentId: range.segmentId,
};
return {
comment,
commentMutation: {
id: AddCommentMutation.id,
params: { unitId: params.unitId, subUnitId: DEFAULT_DOC_SUBUNIT_ID, comment },
},
decorationMutationParams,
};
}
/**
* Creates a root comment and document decoration for an explicit text range.
* This model command is safe to execute without loading document UI packages.
*/
export const CreateDocTextRangeCommentCommand: ICommand<ICreateDocTextRangeCommentParams> = {
id: 'docs.command.create-text-range-comment',
type: CommandType.COMMAND,
async handler(accessor, params) {
if (!params) {
return false;
}
const range = getValidRange(accessor, params.unitId, params.range);
if (!range) {
return false;
}
const document = accessor.get(IUniverInstanceService).getUnit<DocumentDataModel>(params.unitId, UniverInstanceType.UNIVER_DOC);
const body = document?.getSelfOrHeaderFooterModel(range.segmentId)?.getBody();
if (!body) {
return false;
}
const id = params.id ?? generateRandomId();
const comment: IThreadComment = {
id,
threadId: params.threadId ?? id,
unitId: params.unitId,
subUnitId: DEFAULT_DOC_SUBUNIT_ID,
ref: BuildTextUtils.transform.getPlainText(body.dataStream.slice(range.startOffset, range.endOffset)),
text: normalizeThreadCommentContent(params.content),
attachments: params.attachments ?? [],
dT: getDT(params.dateTime),
personId: params.personId ?? accessor.get(UserManagerService).getCurrentUser().userID,
startOffset: range.startOffset,
endOffset: range.endOffset,
segmentId: range.segmentId,
collapsed: false,
};
const prepared = await prepareDocTextRangeComment(accessor, { unitId: params.unitId, range, comment });
if (!prepared) {
return false;
}
return (await sequenceExecute([
prepared.commentMutation,
{ id: AddDocCommentDecorationMutation.id, params: prepared.decorationMutationParams },
], accessor.get(ICommandService))).result;
},
};
@@ -0,0 +1,20 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/** Stable subunit used by document text comments. */
export const DEFAULT_DOC_SUBUNIT_ID = 'default_doc';
export const DOCS_THREAD_COMMENT_PLUGIN_NAME = 'DOC_THREAD_COMMENT_PLUGIN';
@@ -0,0 +1,24 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export const DOCS_THREAD_COMMENT_PLUGIN_CONFIG_KEY = 'docs-thread-comment.config';
export const configSymbol = Symbol(DOCS_THREAD_COMMENT_PLUGIN_CONFIG_KEY);
export interface IUniverDocsThreadCommentConfig {
}
export const defaultPluginConfig: IUniverDocsThreadCommentConfig = {};
@@ -0,0 +1,130 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { Injector } from '@univerjs/core';
import { CustomDecorationType, ICommandService } from '@univerjs/core';
import * as DocsThreadComment from '@univerjs/docs-thread-comment';
import * as DocsFacade from '@univerjs/docs/facade';
import * as ThreadComment from '@univerjs/thread-comment';
export type IDocumentTextRangeCommentCreateOptions = Omit<
DocsThreadComment.ICreateDocTextRangeCommentParams,
'unitId' | 'range' | 'content'
>;
/** Comment methods added to a fixed document text range. */
export interface IFDocumentTextRangeThreadCommentMixin {
/**
* Creates a comment on this text range.
* @param content Plain text or a Univer document body for rich comment content.
* @param options Optional stable IDs, author, attachments, and creation time.
* @returns `true` when the create command succeeds; otherwise, `false`.
* @throws {TypeError} If the content is empty.
* @example
* ```ts
* const range = univerAPI.getActiveDocument()?.getTextRange(0, 12);
* await range?.createCommentAsync('Verify this introduction.', { id: 'review-intro' });
* ```
*/
createCommentAsync(
content: ThreadComment.ThreadCommentContent,
options?: IDocumentTextRangeCommentCreateOptions
): Promise<boolean>;
/**
* Returns locally loaded comments whose comment decorations overlap this text range.
* @returns Matching comment threads. The returned anchors use `DOC_TEXT_RANGE`.
* @example
* ```ts
* const range = univerAPI.getActiveDocument()?.getTextRange(0, 12);
* const comments = range?.getComments() ?? [];
* comments.forEach(({ root, children }) => console.log(root.id, children.length));
* ```
*/
getComments(): ThreadComment.IFacadeThreadCommentInfo[];
/**
* Synchronizes known document threads and returns comments whose decorations overlap this text range.
* @returns A promise resolving to the synchronized matching comment threads.
* @example
* ```ts
* const range = univerAPI.getActiveDocument()?.getTextRange(0, 12);
* const comments = range ? await range.listCommentsAsync() : [];
* console.log(comments.length);
* ```
*/
listCommentsAsync(): Promise<ThreadComment.IFacadeThreadCommentInfo[]>;
}
export class FDocumentTextRangeThreadCommentMixin extends DocsFacade.FDocumentTextRange implements IFDocumentTextRangeThreadCommentMixin {
declare private _threadCommentCommandService: ICommandService;
declare private _threadCommentFacadeService: ThreadComment.ThreadCommentFacadeService;
override _initialize(injector: Injector): void {
this._threadCommentCommandService = injector.get(ICommandService);
let commentService: ThreadComment.ThreadCommentFacadeService | undefined;
Object.defineProperty(this, '_threadCommentFacadeService', {
get: () => commentService ??= injector.get(ThreadComment.ThreadCommentFacadeService),
});
}
/** @inheritdoc */
override createCommentAsync(
content: ThreadComment.ThreadCommentContent,
options: IDocumentTextRangeCommentCreateOptions = {}
): Promise<boolean> {
return this._threadCommentCommandService.executeCommand(DocsThreadComment.CreateDocTextRangeCommentCommand.id, {
...options,
unitId: this._document.getId(),
range: { ...this.getRange(), collapsed: false },
content,
});
}
/** @inheritdoc */
override getComments(): ThreadComment.IFacadeThreadCommentInfo[] {
const range = this.getRange();
const commentIds = this._getOverlappingCommentIds(range);
return this._threadCommentFacadeService.getComments({
unitIds: [this._document.getId()],
subUnitIds: [DocsThreadComment.DEFAULT_DOC_SUBUNIT_ID],
anchorKinds: [ThreadComment.ThreadCommentAnchorKind.DOC_TEXT_RANGE],
}).filter((comment) => commentIds.has(comment.root.id));
}
/** @inheritdoc */
override async listCommentsAsync(): Promise<ThreadComment.IFacadeThreadCommentInfo[]> {
const range = this.getRange();
const commentIds = this._getOverlappingCommentIds(range);
const comments = await this._threadCommentFacadeService.listCommentsAsync({
unitIds: [this._document.getId()],
subUnitIds: [DocsThreadComment.DEFAULT_DOC_SUBUNIT_ID],
anchorKinds: [ThreadComment.ThreadCommentAnchorKind.DOC_TEXT_RANGE],
});
return comments.filter((comment) => commentIds.has(comment.root.id));
}
private _getOverlappingCommentIds(range: DocsFacade.IFDocumentTextRange): Set<string> {
const decorations = this._document.getBody(range.segmentId).customDecorations ?? [];
return new Set(decorations.filter((decoration) => decoration.type === CustomDecorationType.COMMENT
&& decoration.startIndex < range.endOffset
&& decoration.endIndex >= range.startOffset).map((decoration) => decoration.id));
}
}
DocsFacade.FDocumentTextRange.extend(FDocumentTextRangeThreadCommentMixin);
declare module '@univerjs/docs/facade' {
interface FDocumentTextRange extends IFDocumentTextRangeThreadCommentMixin {}
}
@@ -0,0 +1,22 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import './f-document-text-range';
export type {
IDocumentTextRangeCommentCreateOptions,
IFDocumentTextRangeThreadCommentMixin,
} from './f-document-text-range';
+30
View File
@@ -0,0 +1,30 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
export {
AddDocCommentDecorationMutation,
CreateDocTextRangeCommentCommand,
prepareDocTextRangeComment,
} from './commands/commands/create-doc-text-range-comment.command';
export type {
IAddDocTextRangeCommentParams,
ICreateDocTextRangeCommentParams,
IDocCommentDecorationMutationParams,
IPreparedDocTextRangeComment,
} from './commands/commands/create-doc-text-range-comment.command';
export { DEFAULT_DOC_SUBUNIT_ID } from './common/const';
export type { IUniverDocsThreadCommentConfig } from './config/config';
export { UniverDocsThreadCommentPlugin } from './plugin';
@@ -0,0 +1,65 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { IUniverDocsThreadCommentConfig } from './config/config';
import {
DependentOn,
ICommandService,
IConfigService,
Inject,
Injector,
merge,
Plugin,
UniverInstanceType,
} from '@univerjs/core';
import { UniverDocsPlugin } from '@univerjs/docs';
import { UniverThreadCommentPlugin } from '@univerjs/thread-comment';
import pkg from '../package.json';
import {
AddDocCommentDecorationMutation,
CreateDocTextRangeCommentCommand,
} from './commands/commands/create-doc-text-range-comment.command';
import { DOCS_THREAD_COMMENT_PLUGIN_NAME } from './common/const';
import { defaultPluginConfig, DOCS_THREAD_COMMENT_PLUGIN_CONFIG_KEY } from './config/config';
@DependentOn(UniverDocsPlugin, UniverThreadCommentPlugin)
export class UniverDocsThreadCommentPlugin extends Plugin {
static override pluginName = DOCS_THREAD_COMMENT_PLUGIN_NAME;
static override packageName = pkg.name;
static override version = pkg.version;
static override type = UniverInstanceType.UNIVER_DOC;
constructor(
private readonly _config: Partial<IUniverDocsThreadCommentConfig> = defaultPluginConfig,
@Inject(Injector) protected override _injector: Injector,
@IConfigService private readonly _configService: IConfigService,
@ICommandService private readonly _commandService: ICommandService
) {
super();
const { ...rest } = merge(
{},
defaultPluginConfig,
this._config
);
this._configService.setConfig(DOCS_THREAD_COMMENT_PLUGIN_CONFIG_KEY, rest);
}
override onStarting(): void {
this.disposeWithMe(this._commandService.registerCommand(CreateDocTextRangeCommentCommand));
this.disposeWithMe(this._commandService.registerCommand(AddDocCommentDecorationMutation));
}
}
@@ -0,0 +1,8 @@
{
"extends": "@univerjs-infra/shared/tsconfigs/base",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"]
}
@@ -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"
]
}
@@ -0,0 +1,3 @@
import createConfig from '@univerjs-infra/shared/vitest';
export default createConfig();
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { DocumentBlockRangeType, UniverInstanceType } from '@univerjs/core';
import { DocumentBlockRangeType, FOCUSING_COMMON_DRAWINGS, UniverInstanceType } from '@univerjs/core';
import { RichTextEditingMutation } from '@univerjs/docs';
import { ContextMenuPosition } from '@univerjs/ui';
import { describe, expect, it, vi } from 'vitest';
@@ -38,6 +38,7 @@ function createController(options?: {
textRanges?: Array<{ startOffset: number; endOffset: number }>;
blockRanges?: Array<{ startIndex: number; endIndex: number; blockType: DocumentBlockRangeType }>;
menuVisible?: boolean;
focusingDrawing?: boolean;
}) {
const onPointerDown$ = createEventSubject();
let commandHandler: ((command: { id: string }) => void) | undefined;
@@ -60,6 +61,9 @@ function createController(options?: {
} as never,
{ isPointerOnNonChecklistBullet: vi.fn(() => options?.pointerOnBullet ?? false) } as never,
{ getTextRanges: vi.fn(() => options?.textRanges ?? []) } as never,
{
getContextValue: vi.fn((key) => key === FOCUSING_COMMON_DRAWINGS && (options?.focusingDrawing ?? false)),
} as never,
{
getCurrentUnitOfType: vi.fn((type) => type === UniverInstanceType.UNIVER_DOC
? { getBody: () => ({ blockRanges: options?.blockRanges ?? [] }) }
@@ -81,6 +85,16 @@ describe('DocContextMenuRenderController', () => {
controller.dispose();
});
it('opens the drawing context menu when a drawing is focused', () => {
const { controller, onPointerDown$, contextMenuService } = createController({ focusingDrawing: true });
const event = { button: 2, offsetX: 10, offsetY: 20 };
onPointerDown$.emit(event);
expect(contextMenuService.triggerContextMenu).toHaveBeenCalledWith(event, ContextMenuPosition.DRAWING);
controller.dispose();
});
it('does not open context menu for checklist bullets or selections inside code blocks', () => {
const bullet = createController({ pointerOnBullet: true });
bullet.onPointerDown$.emit({ button: 2, offsetX: 10, offsetY: 20 });
@@ -0,0 +1,51 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EventSubject } from '@univerjs/core';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { DocResizeRenderController } from '../doc-resize.render-controller';
describe('DocResizeRenderController', () => {
it('refreshes page layout and text selection after sidebar layout changes', async () => {
const calculatePagePosition = vi.fn();
const refreshRanges = vi.fn();
const refreshSelection = vi.fn();
const sidebarOptions$ = new Subject();
const controller = new DocResizeRenderController(
{
unitId: 'doc-1',
engine: {
onTransformChange$: new EventSubject(),
},
} as never,
{ calculatePagePosition } as never,
{ refreshSelection } as never,
{ refreshRanges } as never,
{ sidebarOptions$ } as never
);
sidebarOptions$.next({ visible: true });
expect(calculatePagePosition).not.toHaveBeenCalled();
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
expect(calculatePagePosition).toHaveBeenCalledOnce();
expect(refreshRanges).toHaveBeenCalledOnce();
expect(refreshSelection).toHaveBeenCalledOnce();
controller.dispose();
});
});
@@ -22,7 +22,9 @@ import {
DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY,
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
DocumentBlockRangeType,
FOCUSING_COMMON_DRAWINGS,
ICommandService,
IContextService,
Inject,
IUniverInstanceService,
UniverInstanceType,
@@ -48,6 +50,7 @@ export class DocContextMenuRenderController extends Disposable implements IRende
@ICommandService private readonly _commandService: ICommandService,
@Inject(DocEventManagerService) private readonly _docEventManagerService: DocEventManagerService,
@Inject(DocSelectionManagerService) private readonly _docSelectionManagerService: DocSelectionManagerService,
@IContextService private readonly _contextService: IContextService,
@Inject(IUniverInstanceService) private readonly _univerInstanceService: IUniverInstanceService
) {
super();
@@ -70,7 +73,10 @@ export class DocContextMenuRenderController extends Disposable implements IRende
return;
}
this._contextMenuService.triggerContextMenu(event, ContextMenuPosition.MAIN_AREA);
const position = this._contextService.getContextValue(FOCUSING_COMMON_DRAWINGS)
? ContextMenuPosition.DRAWING
: ContextMenuPosition.MAIN_AREA;
this._contextMenuService.triggerContextMenu(event, position);
}
});
this.disposeWithMe(documentsSubscription);
@@ -17,16 +17,19 @@
import type { IRenderContext, IRenderModule } from '@univerjs/engine-render';
import { Disposable, fromEventSubject, Inject, isInternalEditorID } from '@univerjs/core';
import { DocSelectionManagerService } from '@univerjs/docs';
import { TRANSFORM_CHANGE_OBSERVABLE_TYPE } from '@univerjs/engine-render';
import { animationFrameScheduler, filter, throttleTime } from 'rxjs';
import { ISidebarService } from '@univerjs/ui';
import { animationFrameScheduler, observeOn, throttleTime } from 'rxjs';
import { DocPageLayoutService } from '../../services/doc-page-layout.service';
import { DocSelectionRenderService } from '../../services/selection/doc-selection-render.service';
// REFACTOR: @JOCS, move to new-docs package.
export class DocResizeRenderController extends Disposable implements IRenderModule {
constructor(
private _context: IRenderContext,
@Inject(DocPageLayoutService) private readonly _docPageLayoutService: DocPageLayoutService,
@Inject(DocSelectionManagerService) private readonly _textSelectionManagerService: DocSelectionManagerService
@Inject(DocSelectionManagerService) private readonly _textSelectionManagerService: DocSelectionManagerService,
@Inject(DocSelectionRenderService) private readonly _docSelectionRenderService: DocSelectionRenderService,
@ISidebarService private readonly _sidebarService: ISidebarService
) {
super();
@@ -39,16 +42,24 @@ export class DocResizeRenderController extends Disposable implements IRenderModu
private _initResize() {
this.disposeWithMe(
fromEventSubject(this._context.engine.onTransformChange$).pipe(
filter((evt) => evt.type === TRANSFORM_CHANGE_OBSERVABLE_TYPE.resize),
throttleTime(0, animationFrameScheduler)
).subscribe(() => {
if (this._disposed) {
return;
}
).subscribe(() => this._refreshLayoutAndSelection())
);
this._docPageLayoutService.calculatePagePosition();
this._textSelectionManagerService.refreshSelection();
})
this.disposeWithMe(
this._sidebarService.sidebarOptions$.pipe(
observeOn(animationFrameScheduler)
).subscribe(() => this._refreshLayoutAndSelection())
);
}
private _refreshLayoutAndSelection() {
if (this._disposed) {
return;
}
this._docPageLayoutService.calculatePagePosition();
this._docSelectionRenderService.refreshRanges();
this._textSelectionManagerService.refreshSelection();
}
}
@@ -232,6 +232,11 @@ export class DocSelectionRenderService extends RxDisposable implements IRenderMo
this._reserveRanges = status;
}
refreshRanges() {
this._rangeList.forEach((range) => range.refresh());
this._rectRangeList.forEach((range) => range.refresh());
}
private _setRangeStyle(style: ITextSelectionStyle = NORMAL_TEXT_SELECTION_PLUGIN_STYLE) {
this._selectionStyle = style;
}
@@ -76,6 +76,7 @@ export interface IRenderManagerService extends IDisposable {
* @param dep
*/
registerRenderModule<T extends UnitModel>(type: UniverInstanceType, dep: Dependency<T>): IDisposable;
registerRenderModule(type: UniverInstanceType, dep: Dependency): IDisposable;
}
const DEFAULT_SCENE_SIZE = { width: 1500, height: 1000 };
@@ -148,6 +149,8 @@ export class RenderManagerService extends Disposable implements IRenderManagerSe
* @param type
* @param depCtor
*/
registerRenderModule<T extends UnitModel>(type: UniverInstanceType, depCtor: Dependency<T>): IDisposable;
registerRenderModule(type: UniverInstanceType, depCtor: Dependency): IDisposable;
registerRenderModule(type: UniverInstanceType, depCtor: Dependency): IDisposable {
if (!this._renderDependencies.has(type)) {
this._renderDependencies.set(type, []);
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { Dependency, Workbook } from '@univerjs/core';
import type { Dependency } from '@univerjs/core';
import type { IUniverSheetsDataValidationUIConfig } from './config/config';
import { DependentOn, ICommandService, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core';
import { UniverDataValidationPlugin } from '@univerjs/data-validation';
@@ -117,7 +117,7 @@ export class UniverSheetsDataValidationMobileUIPlugin extends Plugin {
this._injector.get(DataValidationPermissionController);
const renderManager = this._injector.get(IRenderManagerService);
renderManager.registerRenderModule<Workbook>(
renderManager.registerRenderModule(
UniverInstanceType.UNIVER_SHEET,
[SheetsDataValidationReRenderController] as Dependency
);
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { Dependency, Workbook } from '@univerjs/core';
import type { Dependency } from '@univerjs/core';
import type { IUniverSheetsDataValidationUIConfig } from './config/config';
import { DependentOn, ICommandService, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core';
import { UniverDataValidationPlugin } from '@univerjs/data-validation';
@@ -119,7 +119,7 @@ export class UniverSheetsDataValidationUIPlugin extends Plugin {
this._injector.get(DataValidationAlertController);
const renderManager = this._injector.get(IRenderManagerService);
renderManager.registerRenderModule<Workbook>(
renderManager.registerRenderModule(
UniverInstanceType.UNIVER_SHEET,
[SheetsDataValidationReRenderController] as Dependency
);
@@ -27,7 +27,7 @@ import {
import { IDrawingManagerService } from '@univerjs/drawing';
import { IRenderManagerService } from '@univerjs/engine-render';
import { SheetCanvasPopManagerService } from '@univerjs/sheets-ui';
import { IMessageService } from '@univerjs/ui';
import { IMenuManagerService, IMessageService, MenuItemType } from '@univerjs/ui';
import { Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { DrawingPopupMenuController } from '../drawing-popup-menu.controller';
@@ -46,10 +46,14 @@ describe('DrawingPopupMenuController', () => {
const currentWorkbook$ = new Subject<never>();
const disposedWorkbook$ = new Subject<never>();
const imageObject = { oKey: 'image-1' };
const attachPopupToObject = vi.fn(() => ({
dispose: vi.fn(),
canDispose: () => true,
}));
let attachedPopup: { extraProps?: Record<string, unknown> } | undefined;
const attachPopupToObject = vi.fn((_targetObject: unknown, popup: { extraProps?: Record<string, unknown> }) => {
attachedPopup = popup;
return {
dispose: vi.fn(),
canDispose: () => true,
};
});
injector.add([LocaleService]);
injector.add([IDrawingManagerService, {
@@ -92,6 +96,19 @@ describe('DrawingPopupMenuController', () => {
} as never,
}]);
injector.add([IMessageService, { useValue: { show: () => toDisposable(() => {}) } as never }]);
injector.add([IMenuManagerService, {
useValue: {
getFlatMenuByPositionKey: () => [{
key: 'add-comment',
order: 0,
item: {
id: 'sheet.operation.add-drawing-comment',
type: MenuItemType.BUTTON,
title: 'sheets-thread-comment-ui.menu.addComment',
},
}],
} as never,
}]);
injector.add([IContextService, {
useValue: {
contextChanged$,
@@ -113,6 +130,12 @@ describe('DrawingPopupMenuController', () => {
direction: 'left',
})
);
expect(attachedPopup?.extraProps?.menuItems).toEqual(expect.arrayContaining([
expect.objectContaining({
commandId: 'sheet.operation.add-drawing-comment',
label: 'sheets-thread-comment-ui.menu.addComment',
}),
]));
controller.dispose();
injector.dispose();
@@ -43,7 +43,12 @@ import {
import { IRenderManagerService } from '@univerjs/engine-render';
import { RemoveSheetDrawingCommand } from '@univerjs/sheets-drawing';
import { SheetCanvasPopManagerService } from '@univerjs/sheets-ui';
import { IMessageService } from '@univerjs/ui';
import {
FloatingObjectToolbarPosition,
IMenuManagerService,
IMessageService,
MenuItemType,
} from '@univerjs/ui';
import { FlipSheetDrawingCommand } from '../commands/commands/flip-drawings.command';
import { EditSheetDrawingOperation } from '../commands/operations/edit-sheet-drawing.operation';
@@ -58,6 +63,7 @@ export class DrawingPopupMenuController extends RxDisposable {
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IUniverInstanceService private readonly _univerInstanceService: IUniverInstanceService,
@IMessageService private readonly _messageService: IMessageService,
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService,
@IContextService private readonly _contextService: IContextService,
@IImageIoService private readonly _ioService: ImageIoService,
@ICommandService private readonly _commandService: ICommandService
@@ -176,7 +182,10 @@ export class DrawingPopupMenuController extends RxDisposable {
direction: this._localeService.getDirection() === 'rtl' ? 'left' : 'horizontal',
offset: [2, 0],
extraProps: {
menuItems: menus || this._getImageMenuItems(unitId, subUnitId, drawingId, drawingType),
menuItems: [
...(menus || this._getImageMenuItems(unitId, subUnitId, drawingId, drawingType)),
...this._getFloatingObjectMenuItems(),
],
},
}));
})
@@ -248,4 +257,22 @@ export class DrawingPopupMenuController extends RxDisposable {
},
];
}
private _getFloatingObjectMenuItems() {
return this._menuManagerService
.getFlatMenuByPositionKey(FloatingObjectToolbarPosition.SHEET)
.flatMap(({ item }, index) => {
if (!item || item.type !== MenuItemType.BUTTON || !item.title) {
return [];
}
return [{
label: item.title,
index: 100 + index,
commandId: item.commandId ?? item.id,
commandParams: typeof item.params === 'function' ? item.params() : item.params,
disable: false,
}];
});
}
}
@@ -832,6 +832,16 @@ export class FOverGridImage extends FBase {
return this._image.drawingId;
}
/** Returns the workbook unit id that owns this image. */
getUnitId(): string {
return this._image.unitId;
}
/** Returns the worksheet id that owns this image. */
getSubUnitId(): string {
return this._image.subUnitId;
}
/**
* Get the drawing type of the image
* @returns {DrawingTypeEnum} The drawing type of the image
+1 -1
View File
@@ -22,6 +22,6 @@ import './f-univer';
export type * from './f-enum';
export type * from './f-event';
export type * from './f-over-grid-image';
export { FOverGridImage, type FOverGridImageBuilder, type IFOverGridImage } from './f-over-grid-image';
export type * from './f-univer';
export type * from './f-worksheet';
+2 -2
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import type { Dependency, Workbook } from '@univerjs/core';
import type { Dependency } from '@univerjs/core';
import type { IUniverSheetsHyperLinkUIConfig } from './config/config';
import { DependentOn, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core';
import { UniverDocsPlugin } from '@univerjs/docs';
@@ -94,7 +94,7 @@ export class UniverSheetsHyperLinkUIPlugin extends Plugin {
override onReady(): void {
const renderManager = this._injector.get(IRenderManagerService);
renderManager.registerRenderModule<Workbook>(UniverInstanceType.UNIVER_SHEET, [SheetsHyperLinkRenderController] as Dependency);
renderManager.registerRenderModule(UniverInstanceType.UNIVER_SHEET, [SheetsHyperLinkRenderController] as Dependency);
this._injector.get(SheetsHyperLinkAutoFillController);
this._injector.get(SheetsHyperLinkCopyPasteController);
@@ -75,6 +75,7 @@
},
"dependencies": {
"@univerjs/core": "workspace:*",
"@univerjs/drawing": "workspace:*",
"@univerjs/engine-formula": "workspace:*",
"@univerjs/engine-render": "workspace:*",
"@univerjs/icons": "1.38.0",
@@ -18,6 +18,7 @@ import type { IWorkbookData } from '@univerjs/core';
import type { ISelectionWithStyle } from '@univerjs/sheets';
import type { IThreadComment } from '@univerjs/thread-comment';
import {
DrawingTypeEnum,
ICommandService,
IUniverInstanceService,
LifecycleService,
@@ -27,6 +28,7 @@ import {
Univer,
UniverInstanceType,
} from '@univerjs/core';
import { DrawingManagerService, IDrawingManagerService } from '@univerjs/drawing';
import { SheetsSelectionsService } from '@univerjs/sheets';
import { SheetsThreadCommentModel } from '@univerjs/sheets-thread-comment';
import { CellPopupManagerService, SheetCanvasPopManagerService } from '@univerjs/sheets-ui';
@@ -41,7 +43,7 @@ import { DesktopSidebarService, ISidebarService } from '@univerjs/ui';
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
import { SheetsThreadCommentPopupService } from '../../../services/sheets-thread-comment-popup.service';
import { SHEETS_THREAD_COMMENT_PANEL } from '../../../types/const';
import { ShowAddSheetCommentModalOperation, ToggleSheetCommentPanelOperation } from '../comment.operation';
import { AddSheetDrawingCommentOperation, ShowAddSheetCommentModalOperation, ToggleSheetCommentPanelOperation } from '../comment.operation';
const unitId = 'comment-workbook';
const subUnitId = 'sheet-1';
@@ -193,4 +195,25 @@ describe('sheet thread comment operations', () => {
expect(panelService.panelVisible).toBe(false);
expect(sidebarService.visible).toBe(false);
});
it('rejects a focused drawing from another workbook or worksheet', () => {
const injector = univer.__getInjector();
const drawing = {
unitId: 'other-book',
subUnitId,
drawingId: 'drawing-1',
drawingType: DrawingTypeEnum.DRAWING_SHAPE,
};
const drawingManager = new DrawingManagerService();
drawingManager.registerDrawingData(drawing.unitId, {
[drawing.subUnitId]: { data: { [drawing.drawingId]: drawing }, order: [drawing.drawingId] },
});
drawingManager.focusDrawing([drawing]);
injector.add([IDrawingManagerService, {
useValue: drawingManager,
}]);
expect(AddSheetDrawingCommentOperation.handler(injector, {})).toBe(false);
drawingManager.dispose();
});
});
@@ -14,12 +14,14 @@
* limitations under the License.
*/
import type { IOperation } from '@univerjs/core';
import type { IOperation, Workbook } from '@univerjs/core';
import type { ISheetLocation } from '@univerjs/sheets';
import { CommandType, IUniverInstanceService } from '@univerjs/core';
import { CommandType, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { IDrawingManagerService } from '@univerjs/drawing';
import { getSheetCommandTarget, SheetsSelectionsService } from '@univerjs/sheets';
import { SheetsThreadCommentModel } from '@univerjs/sheets-thread-comment';
import { ThreadCommentPanelService } from '@univerjs/thread-comment-ui';
import { ThreadCommentAnchorKind } from '@univerjs/thread-comment';
import { ThreadCommentDraftService, ThreadCommentPanelService } from '@univerjs/thread-comment-ui';
import { ISidebarService } from '@univerjs/ui';
import { SheetsThreadCommentPopupService } from '../../services/sheets-thread-comment-popup.service';
import { SHEETS_THREAD_COMMENT_PANEL } from '../../types/const';
@@ -80,14 +82,72 @@ export const ToggleSheetCommentPanelOperation: IOperation = {
sidebarService.close();
panelService.setPanelVisible(false);
} else {
sidebarService.open({
header: { title: 'sheets-thread-comment-ui.panel.title' },
children: { label: SHEETS_THREAD_COMMENT_PANEL },
width: 360,
});
panelService.setPanelVisible(true);
openSheetCommentPanel(sidebarService, panelService);
}
return true;
},
};
export const OpenSheetCommentPanelOperation: IOperation = {
id: 'sheet.operation.open-comment-panel',
type: CommandType.OPERATION,
handler(accessor) {
openSheetCommentPanel(
accessor.get(ISidebarService),
accessor.get(ThreadCommentPanelService)
);
return true;
},
};
function openSheetCommentPanel(
sidebarService: ISidebarService,
panelService: ThreadCommentPanelService
): void {
if (!panelService.panelVisible || sidebarService.options.children?.label !== SHEETS_THREAD_COMMENT_PANEL) {
sidebarService.open({
header: { title: 'sheets-thread-comment-ui.panel.title' },
children: { label: SHEETS_THREAD_COMMENT_PANEL },
width: 360,
onClose: () => panelService.setPanelVisible(false),
});
}
panelService.setPanelVisible(true);
}
export const AddSheetDrawingCommentOperation: IOperation = {
id: 'sheet.operation.add-drawing-comment',
type: CommandType.OPERATION,
handler(accessor) {
const drawing = accessor.get(IDrawingManagerService).getFocusDrawings()[0];
const workbook = accessor.get(IUniverInstanceService)
.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
if (
!drawing
|| !workbook
|| drawing.unitId !== workbook.getUnitId()
|| drawing.subUnitId !== workbook.getActiveSheet()?.getSheetId()
) {
return false;
}
accessor.get(ThreadCommentDraftService).place({
unitId: drawing.unitId,
subUnitId: drawing.subUnitId,
anchor: {
kind: ThreadCommentAnchorKind.SHEET_DRAWING,
pageId: drawing.subUnitId,
elementId: drawing.drawingId,
},
});
const panelService = accessor.get(ThreadCommentPanelService);
accessor.get(ISidebarService).open({
header: { title: 'sheets-thread-comment-ui.panel.title' },
children: { label: SHEETS_THREAD_COMMENT_PANEL },
width: 360,
onClose: () => panelService.setPanelVisible(false),
});
panelService.setPanelVisible(true);
return true;
},
};
@@ -129,4 +129,29 @@ describe('SheetsThreadCommentCopyPasteController', () => {
controller.dispose();
});
it('does not implicitly clone a comment during a normal cell copy', async () => {
await testBed.commandService.executeCommand(AddCommentMutation.id, {
unitId: 'test',
subUnitId: 'sheet1',
comment: createRootComment(),
});
const controller = testBed.injector.createInstance(SheetsThreadCommentCopyPasteController);
const hook = testBed.getClipboardHook()!;
hook.onBeforeCopy('test', 'sheet1', {
startRow: 0,
endRow: 0,
startColumn: 0,
endColumn: 0,
});
expect(hook.onPasteCells(
null,
{ unitId: 'test', subUnitId: 'sheet2', range: { rows: [2], cols: [3] } },
null,
{ copyType: COPY_TYPE.COPY }
)).toEqual({ redos: [], undos: [] });
controller.dispose();
});
});
@@ -14,32 +14,57 @@
* limitations under the License.
*/
import { AddCommentCommand, DeleteCommentCommand, UpdateCommentCommand } from '@univerjs/thread-comment';
import { ICommandService, Injector, LocaleService } from '@univerjs/core';
import { SheetPermissionCheckController } from '@univerjs/sheets';
import { SheetsThreadCommentModel } from '@univerjs/sheets-thread-comment';
import {
AddCommentCommand,
DeleteCommentCommand,
serializeThreadCommentAnchor,
ThreadCommentAnchorKind,
UpdateCommentCommand,
} from '@univerjs/thread-comment';
import { describe, expect, it, vi } from 'vitest';
import { ShowAddSheetCommentModalOperation } from '../../commands/operations/comment.operation';
import { SheetsThreadCommentPermissionController } from '../sheets-thread-comment-permission.controller';
type BeforeCommandHandler = (command: { id: string; params?: unknown }) => void;
function createTestBed(permissionCheck: object, commentModel: object = { getComment: vi.fn() }) {
let beforeCommandHandler: BeforeCommandHandler | undefined;
const injector = new Injector([
[ICommandService, { useValue: {
beforeCommandExecuted: vi.fn((handler: BeforeCommandHandler) => {
beforeCommandHandler = handler;
return { dispose: vi.fn() };
}),
} }],
[LocaleService, { useValue: { t: (key: string) => key } }],
[SheetPermissionCheckController, { useValue: permissionCheck }],
[SheetsThreadCommentModel, { useValue: commentModel }],
[SheetsThreadCommentPermissionController],
]);
return {
controller: injector.get(SheetsThreadCommentPermissionController),
getBeforeCommandHandler: () => beforeCommandHandler,
};
}
describe('SheetsThreadCommentPermissionController', () => {
it('blocks comment panel, add, update and delete actions when comment permissions are denied', () => {
let beforeCommandHandler: ((command: { id: string; params?: unknown }) => void) | undefined;
const permissionCheck = {
permissionCheckWithoutRange: vi.fn(() => false),
permissionCheckWithRanges: vi.fn(() => false),
blockExecuteWithoutPermission: vi.fn(),
};
const controller = new SheetsThreadCommentPermissionController(
{ t: (key: string) => key } as never,
{
beforeCommandExecuted: vi.fn((handler) => {
beforeCommandHandler = handler;
return { dispose: vi.fn() };
}),
} as never,
permissionCheck as never,
const { controller, getBeforeCommandHandler } = createTestBed(
permissionCheck,
{
getComment: vi.fn(() => ({ id: 'comment-1', ref: 'C4' })),
} as never
}
);
const beforeCommandHandler = getBeforeCommandHandler();
beforeCommandHandler?.({ id: ShowAddSheetCommentModalOperation.id });
beforeCommandHandler?.({
@@ -85,4 +110,42 @@ describe('SheetsThreadCommentPermissionController', () => {
controller.dispose();
});
it('rechecks drawing comment permissions without creating an invalid cell range', () => {
const permissionCheck = {
permissionCheckWithoutRange: vi.fn()
.mockReturnValueOnce(true)
.mockReturnValueOnce(false),
permissionCheckWithRanges: vi.fn(),
blockExecuteWithoutPermission: vi.fn(),
};
const { controller, getBeforeCommandHandler } = createTestBed(permissionCheck);
const beforeCommandHandler = getBeforeCommandHandler();
const command = {
id: AddCommentCommand.id,
params: {
unitId: 'unit-1',
subUnitId: 'sheet-1',
comment: {
ref: serializeThreadCommentAnchor({
kind: ThreadCommentAnchorKind.SHEET_DRAWING,
elementId: 'drawing-1',
}),
},
},
};
beforeCommandHandler?.(command);
beforeCommandHandler?.(command);
expect(permissionCheck.permissionCheckWithoutRange).toHaveBeenCalledTimes(2);
expect(permissionCheck.permissionCheckWithoutRange).toHaveBeenLastCalledWith(
expect.any(Object),
'unit-1',
'sheet-1'
);
expect(permissionCheck.permissionCheckWithRanges).not.toHaveBeenCalled();
expect(permissionCheck.blockExecuteWithoutPermission).toHaveBeenCalledTimes(1);
controller.dispose();
});
});
@@ -0,0 +1,54 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { describe, expect, it } from 'vitest';
import { resolveFocusedSheetComment } from '../sheets-thread-comment-popup.controller';
describe('resolveFocusedSheetComment', () => {
const activeComment = {
unitId: 'book-1',
subUnitId: 'sheet-1',
commentId: 'active-comment',
};
it('prefers a hovered comment from the current sheet', () => {
const hoveredComment = {
unitId: 'book-1',
subUnitId: 'sheet-1',
commentId: 'hovered-comment',
};
expect(resolveFocusedSheetComment(activeComment, hoveredComment, 'book-1', 'sheet-1'))
.toEqual(hoveredComment);
});
it('keeps the active highlight when hover belongs to another sheet or workbook', () => {
expect(resolveFocusedSheetComment(activeComment, {
unitId: 'book-1',
subUnitId: 'sheet-2',
commentId: 'foreign-hover',
}, 'book-1', 'sheet-1')).toEqual(activeComment);
expect(resolveFocusedSheetComment(activeComment, {
unitId: 'book-2',
subUnitId: 'sheet-1',
commentId: 'foreign-hover',
}, 'book-1', 'sheet-1')).toEqual(activeComment);
});
it('does not draw a stale highlight outside the current sheet context', () => {
expect(resolveFocusedSheetComment(activeComment, undefined, 'book-1', 'sheet-2')).toBeUndefined();
});
});
@@ -15,7 +15,7 @@
*/
import { Disposable, Inject } from '@univerjs/core';
import { CommentIcon } from '@univerjs/icons';
import { CommentIcon, InsertCommentDoubleIcon } from '@univerjs/icons';
import { ComponentManager, IconManager } from '@univerjs/ui';
import { SHEETS_THREAD_COMMENT_MODAL, SHEETS_THREAD_COMMENT_PANEL } from '../types/const';
import { SheetsThreadCommentCell } from '../views/SheetsThreadCommentCell';
@@ -46,6 +46,7 @@ export class ComponentsController extends Disposable {
private _registerIcons(): void {
this.disposeWithMe(this._iconManager.register({
CommentIcon,
InsertCommentDoubleIcon,
}));
}
}
@@ -0,0 +1,104 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { getDrawingShapeKeyByDrawingSearch } from '@univerjs/drawing';
import { Vector2 } from '@univerjs/engine-render';
import { serializeThreadCommentAnchor, ThreadCommentAnchorKind } from '@univerjs/thread-comment';
import { BehaviorSubject, Subject } from 'rxjs';
import { describe, expect, it, vi } from 'vitest';
import { SheetsThreadCommentDrawingRenderController } from '../drawing.render-controller';
describe('SheetsThreadCommentDrawingRenderController', () => {
it('aggregates drawing roots, follows transforms, and retains the remaining root after deletion', () => {
const worksheet = { getSheetId: () => 'sheet-1', getZoomRatio: () => 2 };
const activeSheet$ = new BehaviorSubject(worksheet);
const workbook = { activeSheet$, getActiveSheet: () => worksheet };
const drawingKey = getDrawingShapeKeyByDrawingSearch({
unitId: 'book-1',
subUnitId: 'sheet-1',
drawingId: 'shape-1',
});
let bounds = { left: 20, top: 40, width: 80, height: 60 };
let roots = ['first-comment', 'newest-comment'];
const scene = {
addObject: vi.fn(),
getObjectIncludeInGroup: vi.fn((key: string) => key === drawingKey ? { getRealBound: () => bounds } : null),
getObject: vi.fn(),
};
const context = {
unitId: 'book-1',
unit: workbook,
scene,
engine: { onTransformChange$: { subscribeEvent: vi.fn(() => ({ dispose: vi.fn() })) } },
};
const commentUpdate$ = new Subject<{ unitId: string }>();
const ref = serializeThreadCommentAnchor({
kind: ThreadCommentAnchorKind.SHEET_DRAWING,
pageId: 'sheet-1',
elementId: 'shape-1',
});
const commentModel = {
commentUpdate$,
query: vi.fn(() => roots.map((id) => ({ root: { id, ref } }))),
getComment: vi.fn((_unitId: string, _subUnitId: string, commentId: string) => ({ id: commentId, ref })),
};
const activeCommentId$ = new BehaviorSubject({
unitId: 'book-1',
subUnitId: 'sheet-1',
commentId: 'first-comment',
});
const hoveredCommentId$ = new BehaviorSubject<undefined>(undefined);
const panelService = {
activeCommentId: activeCommentId$.value,
hoveredCommentId: hoveredCommentId$.value,
activeCommentId$,
hoveredCommentId$,
setActiveComment: vi.fn(),
};
const drawingManager = { add$: new Subject(), update$: new Subject(), remove$: new Subject() };
const themeService = {
currentTheme$: new Subject(),
getColorFromTheme: vi.fn((token: string) => token),
};
const controller = new SheetsThreadCommentDrawingRenderController(
context as never,
{ executeCommand: vi.fn(() => Promise.resolve(true)) } as never,
drawingManager as never,
commentModel as never,
panelService as never,
themeService as never
);
const overlay = scene.addObject.mock.calls[0][0];
expect(overlay.isHit(new Vector2(60, 101))).toBe(true);
expect(overlay.hitCommentId).toBe('newest-comment');
roots = ['first-comment'];
commentUpdate$.next({ unitId: 'book-1' });
expect(overlay.isHit(new Vector2(60, 101))).toBe(true);
expect(overlay.hitCommentId).toBe('first-comment');
bounds = { left: 120, top: 140, width: 100, height: 70 };
drawingManager.update$.next([{ unitId: 'book-1' }]);
expect(overlay.isHit(new Vector2(60, 101))).toBe(false);
expect(overlay.isHit(new Vector2(170, 211))).toBe(true);
roots = [];
commentUpdate$.next({ unitId: 'book-1' });
expect(overlay.isHit(new Vector2(170, 211))).toBe(false);
controller.dispose();
});
});
@@ -0,0 +1,161 @@
/**
* Copyright 2023-present DreamNum Co., Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import type { EventState, Workbook } from '@univerjs/core';
import type { IMouseEvent, IPointerEvent, IRenderContext, IRenderModule } from '@univerjs/engine-render';
import type { IThreadCommentCanvasOutline, IThreadCommentCanvasUnderline } from '@univerjs/thread-comment-ui';
import { ICommandService, Inject, RxDisposable, ThemeService, toDisposable } from '@univerjs/core';
import { getDrawingShapeKeyByDrawingSearch, IDrawingManagerService } from '@univerjs/drawing';
import { deserializeThreadCommentAnchor, ThreadCommentAnchorKind, ThreadCommentModel } from '@univerjs/thread-comment';
import { ThreadCommentCanvasOverlay, ThreadCommentPanelService } from '@univerjs/thread-comment-ui';
import { OpenSheetCommentPanelOperation } from '../../commands/operations/comment.operation';
const SHEET_COMMENT_DRAWING_OVERLAY_KEY = 'sheet-thread-comment-drawing-overlay';
const SHEET_COMMENT_DRAWING_OVERLAY_LAYER_INDEX = 10_100;
export class SheetsThreadCommentDrawingRenderController extends RxDisposable implements IRenderModule {
private readonly _overlay: ThreadCommentCanvasOverlay;
constructor(
private readonly _context: IRenderContext<Workbook>,
@ICommandService private readonly _commandService: ICommandService,
@IDrawingManagerService private readonly _drawingManagerService: IDrawingManagerService,
@Inject(ThreadCommentModel) private readonly _commentModel: ThreadCommentModel,
@Inject(ThreadCommentPanelService) private readonly _panelService: ThreadCommentPanelService,
@Inject(ThemeService) private readonly _themeService: ThemeService
) {
super();
this._overlay = new ThreadCommentCanvasOverlay(SHEET_COMMENT_DRAWING_OVERLAY_KEY, {
...this._getColors(),
zoomRatio: 1,
markers: [],
underlines: [],
});
this._context.scene.addObject(this._overlay, SHEET_COMMENT_DRAWING_OVERLAY_LAYER_INDEX);
this.disposeWithMe(toDisposable(this._overlay.onPointerDown$.subscribeEvent(
(_event: IPointerEvent | IMouseEvent, state: EventState) => this._onOverlayPointerDown(state)
)));
this.disposeWithMe(toDisposable(
this._context.engine.onTransformChange$.subscribeEvent(() => this._syncOverlay())
));
this.disposeWithMe(this._context.unit.activeSheet$.subscribe(() => this._syncOverlay()));
this.disposeWithMe(this._commentModel.commentUpdate$.subscribe((update) => {
if (update.unitId === this._context.unitId) {
this._syncOverlay();
}
}));
[this._panelService.activeCommentId$, this._panelService.hoveredCommentId$]
.forEach((observable) => this.disposeWithMe(observable.subscribe(() => this._syncOverlay())));
[this._drawingManagerService.add$, this._drawingManagerService.update$, this._drawingManagerService.remove$]
.forEach((observable) => this.disposeWithMe(observable.subscribe((drawings) => {
if (drawings.some((drawing) => drawing.unitId === this._context.unitId)) {
this._syncOverlay();
}
})));
this.disposeWithMe(this._themeService.currentTheme$.subscribe(() => this._syncOverlay()));
this._syncOverlay();
}
private _onOverlayPointerDown(state: EventState): void {
const commentId = this._overlay.hitCommentId;
const subUnitId = this._context.unit.getActiveSheet()?.getSheetId();
if (!commentId || !subUnitId) {
return;
}
state.stopPropagation();
this._panelService.setActiveComment({
unitId: this._context.unitId,
subUnitId,
commentId,
trigger: 'sheet-canvas',
});
this._commandService.executeCommand(OpenSheetCommentPanelOperation.id).catch(() => undefined);
}
private _syncOverlay(): void {
const worksheet = this._context.unit.getActiveSheet();
const subUnitId = worksheet?.getSheetId();
if (!worksheet || !subUnitId) {
this._overlay.updateState({ markers: [], underlines: [], focusedCommentIds: [], focusOutlines: [] });
return;
}
const underlines = new Map<string, IThreadCommentCanvasUnderline>();
this._commentModel.query({
unitIds: [this._context.unitId],
subUnitIds: [subUnitId],
anchorKinds: [ThreadCommentAnchorKind.SHEET_DRAWING],
resolved: false,
}).forEach(({ root }) => {
const anchor = deserializeThreadCommentAnchor(root.ref);
if (anchor?.kind !== ThreadCommentAnchorKind.SHEET_DRAWING) {
return;
}
const outline = this._getDrawingOutline(subUnitId, anchor.elementId);
if (outline) {
underlines.set(anchor.elementId, {
commentId: root.id,
left: outline.left,
top: outline.top + outline.height + 2 / worksheet.getZoomRatio(),
width: outline.width,
});
}
});
const focusedCommentIds: string[] = [];
const focusOutlines = new Map<string, IThreadCommentCanvasOutline>();
[this._panelService.activeCommentId, this._panelService.hoveredCommentId].forEach((target) => {
if (!target || target.unitId !== this._context.unitId || target.subUnitId !== subUnitId) {
return;
}
const comment = this._commentModel.getComment(target.unitId, target.subUnitId, target.commentId);
const anchor = comment && deserializeThreadCommentAnchor(comment.ref);
if (anchor?.kind !== ThreadCommentAnchorKind.SHEET_DRAWING) {
return;
}
focusedCommentIds.push(target.commentId);
const outline = this._getDrawingOutline(subUnitId, anchor.elementId);
if (outline) {
focusOutlines.set(anchor.elementId, outline);
}
});
this._overlay.updateState({
...this._getColors(),
zoomRatio: worksheet.getZoomRatio(),
markers: [],
underlines: Array.from(underlines.values()),
focusedCommentIds,
focusOutlines: Array.from(focusOutlines.values()),
});
}
private _getDrawingOutline(subUnitId: string, drawingId: string): IThreadCommentCanvasOutline | null {
const objectKey = getDrawingShapeKeyByDrawingSearch({ unitId: this._context.unitId, subUnitId, drawingId });
const object = this._context.scene.getObjectIncludeInGroup?.(objectKey)
?? this._context.scene.getObject(objectKey);
if (!object) {
return null;
}
const bounds = object.getRealBound();
return { left: bounds.left, top: bounds.top, width: bounds.width, height: bounds.height };
}
private _getColors(): { accentColor: string; foregroundColor: string; outlineColor: string } {
return {
accentColor: this._themeService.getColorFromTheme('yellow.400'),
foregroundColor: this._themeService.getColorFromTheme('gray.900'),
outlineColor: this._themeService.getColorFromTheme('white'),
};
}
}
@@ -36,7 +36,9 @@ import {
AddCommentCommand,
DeleteCommentCommand,
DeleteCommentTreeCommand,
deserializeThreadCommentAnchor,
ResolveCommentCommand,
ThreadCommentAnchorKind,
UpdateCommentCommand,
} from '@univerjs/thread-comment';
import { ShowAddSheetCommentModalOperation, ToggleSheetCommentPanelOperation } from '../commands/operations/comment.operation';
@@ -69,15 +71,7 @@ export class SheetsThreadCommentPermissionController extends Disposable {
} else if (id === AddCommentCommand.id) {
const params = command.params as IAddCommentCommandParams;
const { unitId, subUnitId, comment } = params;
const location = singleReferenceToGrid(comment.ref);
const { row, column } = location;
const permission = this._sheetPermissionCheckController.permissionCheckWithRanges({
workbookTypes: [WorkbookCommentPermission],
worksheetTypes: [WorksheetViewPermission],
rangeTypes: [RangeProtectionPermissionViewPoint],
}, [{ startRow: row, startColumn: column, endRow: row, endColumn: column }], unitId, subUnitId);
if (!permission) {
if (!this._hasCommentPermission(unitId, subUnitId, comment.ref)) {
this._sheetPermissionCheckController.blockExecuteWithoutPermission(this._localeService.t<LocaleKey>('sheets-thread-comment-ui.permission.commentErr'));
}
} else if (id === UpdateCommentCommand.id) {
@@ -86,39 +80,43 @@ export class SheetsThreadCommentPermissionController extends Disposable {
const { commentId } = payload;
const comment = this._sheetsThreadCommentModel.getComment(unitId, subUnitId, commentId);
if (comment) {
const location = singleReferenceToGrid(comment.ref);
const { row, column } = location;
const permission = this._sheetPermissionCheckController.permissionCheckWithRanges({
workbookTypes: [WorkbookCommentPermission],
worksheetTypes: [WorksheetViewPermission],
rangeTypes: [RangeProtectionPermissionViewPoint],
}, [{ startRow: row, startColumn: column, endRow: row, endColumn: column }], unitId, subUnitId);
if (!permission) {
this._sheetPermissionCheckController.blockExecuteWithoutPermission(this._localeService.t<LocaleKey>('sheets-thread-comment-ui.permission.commentErr'));
}
if (comment && !this._hasCommentPermission(unitId, subUnitId, comment.ref)) {
this._sheetPermissionCheckController.blockExecuteWithoutPermission(this._localeService.t<LocaleKey>('sheets-thread-comment-ui.permission.commentErr'));
}
} else if (id === ResolveCommentCommand.id || id === DeleteCommentCommand.id || id === DeleteCommentTreeCommand.id) {
const params = command.params as IResolveCommentCommandParams | IDeleteCommentCommandParams | IDeleteCommentTreeCommandParams;
const { unitId, subUnitId, commentId } = params;
const comment = this._sheetsThreadCommentModel.getComment(unitId, subUnitId, commentId);
if (comment) {
const location = singleReferenceToGrid(comment.ref);
const { row, column } = location;
const permission = this._sheetPermissionCheckController.permissionCheckWithRanges({
workbookTypes: [WorkbookCommentPermission],
worksheetTypes: [WorksheetViewPermission],
rangeTypes: [RangeProtectionPermissionViewPoint],
}, [{ startRow: row, startColumn: column, endRow: row, endColumn: column }], unitId, subUnitId);
if (!permission) {
this._sheetPermissionCheckController.blockExecuteWithoutPermission(this._localeService.t<LocaleKey>('sheets-thread-comment-ui.permission.commentErr'));
}
if (comment && !this._hasCommentPermission(unitId, subUnitId, comment.ref)) {
this._sheetPermissionCheckController.blockExecuteWithoutPermission(this._localeService.t<LocaleKey>('sheets-thread-comment-ui.permission.commentErr'));
}
}
})
);
}
private _hasCommentPermission(unitId: string, subUnitId: string, ref: string): boolean {
const permissionTypes = {
workbookTypes: [WorkbookCommentPermission],
worksheetTypes: [WorksheetViewPermission],
};
const anchor = deserializeThreadCommentAnchor(ref);
if (anchor?.kind === ThreadCommentAnchorKind.SHEET_DRAWING) {
return this._sheetPermissionCheckController.permissionCheckWithoutRange(
permissionTypes,
unitId,
subUnitId
);
}
const { row, column } = singleReferenceToGrid(ref);
if (!Number.isFinite(row) || !Number.isFinite(column)) {
return false;
}
return this._sheetPermissionCheckController.permissionCheckWithRanges({
...permissionTypes,
rangeTypes: [RangeProtectionPermissionViewPoint],
}, [{ startRow: row, startColumn: column, endRow: row, endColumn: column }], unitId, subUnitId);
}
}
@@ -17,15 +17,16 @@
import type { Nullable, Workbook } from '@univerjs/core';
import type { ISelectionWithStyle } from '@univerjs/sheets';
import type { IDeleteCommentMutationParams } from '@univerjs/thread-comment';
import type { ActiveCommentInfo } from '@univerjs/thread-comment-ui';
import { Disposable, ICommandService, Inject, IUniverInstanceService, RANGE_TYPE, Rectangle, UniverInstanceType } from '@univerjs/core';
import { singleReferenceToGrid } from '@univerjs/engine-formula';
import { IRenderManagerService } from '@univerjs/engine-render';
import { RangeProtectionPermissionViewPoint, SetWorksheetActiveOperation, SheetPermissionCheckController, SheetsSelectionsService, WorkbookCommentPermission, WorksheetViewPermission } from '@univerjs/sheets';
import { SheetsThreadCommentModel } from '@univerjs/sheets-thread-comment';
import { IEditorBridgeService, IMarkSelectionService, ScrollToRangeOperation, SheetSkeletonManagerService } from '@univerjs/sheets-ui';
import { DeleteCommentMutation } from '@univerjs/thread-comment';
import { DeleteCommentMutation, deserializeThreadCommentAnchor, ThreadCommentAnchorKind } from '@univerjs/thread-comment';
import { SetActiveCommentOperation, ThreadCommentPanelService } from '@univerjs/thread-comment-ui';
import { debounceTime } from 'rxjs';
import { combineLatest, debounceTime } from 'rxjs';
import { SheetsThreadCommentPopupService } from '../services/sheets-thread-comment-popup.service';
interface ISelectionShapeInfo {
@@ -35,6 +36,20 @@ interface ISelectionShapeInfo {
commentId: string;
}
export function resolveFocusedSheetComment(
activeComment: ActiveCommentInfo,
hoveredComment: ActiveCommentInfo,
unitId: string | undefined,
subUnitId: string | undefined
): Exclude<ActiveCommentInfo, null | undefined> | undefined {
for (const comment of [hoveredComment, activeComment]) {
if (comment && comment.unitId === unitId && comment.subUnitId === subUnitId) {
return comment;
}
}
return undefined;
}
export class SheetsThreadCommentPopupController extends Disposable {
private _isSwitchToCommenting = false;
private _selectionShapeInfo: Nullable<ISelectionShapeInfo> = null;
@@ -149,6 +164,10 @@ export class SheetsThreadCommentPopupController extends Disposable {
if (!comment || comment.resolved) {
return;
}
const anchor = deserializeThreadCommentAnchor(comment.ref);
if (anchor?.kind === ThreadCommentAnchorKind.SHEET_DRAWING) {
return;
}
const currentUnit = this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
if (!currentUnit) {
@@ -209,8 +228,20 @@ export class SheetsThreadCommentPopupController extends Disposable {
}
private _initMarkSelection() {
this.disposeWithMe(this._threadCommentPanelService.activeCommentId$.pipe(debounceTime(100)).subscribe((activeComment) => {
if (!activeComment) {
this.disposeWithMe(combineLatest([
this._threadCommentPanelService.activeCommentId$,
this._threadCommentPanelService.hoveredCommentId$,
]).pipe(debounceTime(100)).subscribe(([activeComment, hoveredComment]) => {
const currentUnit = this._univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET);
const currentUnitId = currentUnit?.getUnitId();
const currentSheetId = currentUnit?.getActiveSheet()?.getSheetId();
const focusedComment = resolveFocusedSheetComment(
activeComment,
hoveredComment,
currentUnitId,
currentSheetId
);
if (!focusedComment) {
if (this._selectionShapeInfo) {
this._markSelectionService.removeShape(this._selectionShapeInfo.shapeId);
this._selectionShapeInfo = null;
@@ -218,7 +249,7 @@ export class SheetsThreadCommentPopupController extends Disposable {
return;
}
const { unitId, subUnitId, commentId } = activeComment;
const { unitId, subUnitId, commentId } = focusedComment;
if (this._selectionShapeInfo) {
this._markSelectionService.removeShape(this._selectionShapeInfo.shapeId);
this._selectionShapeInfo = null;
@@ -266,7 +297,7 @@ export class SheetsThreadCommentPopupController extends Disposable {
}
this._selectionShapeInfo = {
...activeComment,
...focusedComment,
shapeId,
};
}));
@@ -26,6 +26,7 @@ const locale: typeof enUS = {
},
menu: {
addComment: 'إضافة تعليق',
openComments: 'فتح التعليقات',
commentManagement: 'إدارة التعليقات',
},
},
@@ -26,6 +26,7 @@ const locale: typeof enUS = {
},
menu: {
addComment: 'Afegeix un comentari',
openComments: 'Obre els comentaris',
commentManagement: 'Gestió de comentaris',
},
},
@@ -26,6 +26,7 @@ const locale: typeof enUS = {
},
menu: {
addComment: 'Kommentar hinzufügen',
openComments: 'Kommentare öffnen',
commentManagement: 'Kommentarverwaltung',
},
},
@@ -24,6 +24,7 @@ const locale = {
},
menu: {
addComment: 'Add Comment',
openComments: 'Open Comments',
commentManagement: 'Comment Management',
},
},
@@ -26,6 +26,7 @@ const locale: typeof enUS = {
},
menu: {
addComment: 'Añadir comentario',
openComments: 'Abrir comentarios',
commentManagement: 'Gestión de comentarios',
},
},
@@ -26,6 +26,7 @@ const locale: typeof enUS = {
},
menu: {
addComment: 'افزودن نظر',
openComments: 'باز کردن نظرات',
commentManagement: 'مدیریت نظر',
},
},
@@ -26,6 +26,7 @@ const locale: typeof enUS = {
},
menu: {
addComment: 'Ajouter un commentaire',
openComments: 'Ouvrir les commentaires',
commentManagement: 'Gestion des commentaires',
},
},
@@ -26,6 +26,7 @@ const locale: typeof enUS = {
},
menu: {
addComment: 'Tambah Komentar',
openComments: 'Buka Komentar',
commentManagement: 'Manajemen Komentar',
},
},
@@ -26,6 +26,7 @@ const locale: typeof enUS = {
},
menu: {
addComment: 'Aggiungi commento',
openComments: 'Apri commenti',
commentManagement: 'Gestione commenti',
},
},
@@ -26,6 +26,7 @@ const locale: typeof enUS = {
},
menu: {
addComment: 'コメントを追加',
openComments: 'コメントを開く',
commentManagement: 'コメント管理',
},
},
@@ -26,6 +26,7 @@ const locale: typeof enUS = {
},
menu: {
addComment: '댓글 추가',
openComments: '댓글 열기',
commentManagement: '댓글 관리',
},
},
@@ -26,6 +26,7 @@ const locale: typeof enUS = {
},
menu: {
addComment: 'Dodaj komentarz',
openComments: 'Otwórz komentarze',
commentManagement: 'Zarządzanie komentarzami',
},
},
@@ -26,6 +26,7 @@ const locale: typeof enUS = {
},
menu: {
addComment: 'Adicionar Comentário',
openComments: 'Abrir comentários',
commentManagement: 'Gerenciamento de Comentários',
},
},
@@ -26,6 +26,7 @@ const locale: typeof enUS = {
},
menu: {
addComment: 'Добавить комментарий',
openComments: 'Открыть комментарии',
commentManagement: 'Управление комментариями',
},
},
@@ -26,6 +26,7 @@ const locale: typeof enUS = {
},
menu: {
addComment: 'Pridať komentár',
openComments: 'Otvoriť komentáre',
commentManagement: 'Správa komentárov',
},
},
@@ -26,6 +26,7 @@ const locale: typeof enUS = {
},
menu: {
addComment: 'Thêm bình luận',
openComments: 'Mở bình luận',
commentManagement: 'Quản lý bình luận',
},
},
@@ -26,6 +26,7 @@ const locale: typeof enUS = {
},
menu: {
addComment: '添加评论',
openComments: '打开评论',
commentManagement: '评论管理',
},
},
@@ -26,6 +26,7 @@ const locale: typeof enUS = {
},
menu: {
addComment: '新增評論',
openComments: '開啟評論',
commentManagement: '評論管理',
},
},
@@ -26,6 +26,7 @@ const locale: typeof enUS = {
},
menu: {
addComment: '新增評論',
openComments: '開啟評論',
commentManagement: '評論管理',
},
},
@@ -20,13 +20,22 @@ import { UniverInstanceType } from '@univerjs/core';
import { RangeProtectionPermissionViewPoint, WorkbookCommentPermission, WorksheetViewPermission } from '@univerjs/sheets';
import { getCurrentRangeDisable$, whenSheetEditorFocused } from '@univerjs/sheets-ui';
import { getMenuHiddenObservable, KeyCode, MenuItemType, MetaKeys } from '@univerjs/ui';
import { ShowAddSheetCommentModalOperation, ToggleSheetCommentPanelOperation } from '../commands/operations/comment.operation';
import { AddSheetDrawingCommentOperation, ShowAddSheetCommentModalOperation, ToggleSheetCommentPanelOperation } from '../commands/operations/comment.operation';
export const drawingCommentMenuFactory = (accessor: IAccessor) => ({
id: AddSheetDrawingCommentOperation.id,
type: MenuItemType.BUTTON,
icon: 'InsertCommentDoubleIcon',
title: 'sheets-thread-comment-ui.menu.addComment',
tooltip: 'sheets-thread-comment-ui.menu.addComment',
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET),
});
export const threadCommentMenuFactory = (accessor: IAccessor) => {
return {
id: ShowAddSheetCommentModalOperation.id,
type: MenuItemType.BUTTON,
icon: 'CommentIcon',
icon: 'InsertCommentDoubleIcon',
title: 'sheets-thread-comment-ui.menu.addComment',
hidden$: getMenuHiddenObservable(accessor, UniverInstanceType.UNIVER_SHEET),
disabled$: getCurrentRangeDisable$(accessor, {
@@ -42,7 +51,8 @@ export const threadPanelMenuFactory = (accessor: IAccessor) => {
id: ToggleSheetCommentPanelOperation.id,
type: MenuItemType.BUTTON,
icon: 'CommentIcon',
tooltip: 'sheets-thread-comment-ui.menu.commentManagement',
title: 'sheets-thread-comment-ui.menu.openComments',
tooltip: 'sheets-thread-comment-ui.menu.openComments',
disabled$: getCurrentRangeDisable$(accessor, {
workbookTypes: [WorkbookCommentPermission],
worksheetTypes: [WorksheetViewPermission],
@@ -15,17 +15,22 @@
*/
import type { MenuSchemaType } from '@univerjs/ui';
import { ContextMenuGroup, ContextMenuPosition, RibbonInsertGroup } from '@univerjs/ui';
import { ShowAddSheetCommentModalOperation, ToggleSheetCommentPanelOperation } from '../commands/operations/comment.operation';
import { threadCommentMenuFactory, threadPanelMenuFactory } from './menu';
import { ContextMenuGroup, ContextMenuPosition, FloatingObjectToolbarPosition, RibbonInsertGroup } from '@univerjs/ui';
import { AddSheetDrawingCommentOperation, ShowAddSheetCommentModalOperation, ToggleSheetCommentPanelOperation } from '../commands/operations/comment.operation';
import { drawingCommentMenuFactory, threadCommentMenuFactory, threadPanelMenuFactory } from './menu';
export const menuSchema: MenuSchemaType = {
[RibbonInsertGroup.MEDIA]: {
[ToggleSheetCommentPanelOperation.id]: {
order: 2,
gridLayout: { row: 1, column: 3, rowSpan: 2, showLabel: true },
gridLayout: { row: 1, column: 3, showLabel: true },
menuItemFactory: threadPanelMenuFactory,
},
[ShowAddSheetCommentModalOperation.id]: {
order: 2.1,
gridLayout: { row: 2, column: 3, showLabel: true },
menuItemFactory: threadCommentMenuFactory,
},
},
[ContextMenuPosition.MAIN_AREA]: {
[ContextMenuGroup.OTHERS]: {
@@ -35,4 +40,18 @@ export const menuSchema: MenuSchemaType = {
},
},
},
[FloatingObjectToolbarPosition.SHEET]: {
[AddSheetDrawingCommentOperation.id]: {
order: 10,
menuItemFactory: drawingCommentMenuFactory,
},
},
[ContextMenuPosition.DRAWING]: {
[ContextMenuGroup.OTHERS]: {
[AddSheetDrawingCommentOperation.id]: {
order: 0,
menuItemFactory: drawingCommentMenuFactory,
},
},
},
};
@@ -17,16 +17,18 @@
import type { Dependency } from '@univerjs/core';
import type { IUniverSheetsThreadCommentUIConfig } from './config/config';
import { DependentOn, ICommandService, IConfigService, Inject, Injector, merge, Plugin, UniverInstanceType } from '@univerjs/core';
import { UniverRenderEnginePlugin } from '@univerjs/engine-render';
import { UniverDrawingPlugin } from '@univerjs/drawing';
import { IRenderManagerService, UniverRenderEnginePlugin } from '@univerjs/engine-render';
import { UniverSheetsPlugin } from '@univerjs/sheets';
import { UniverSheetsThreadCommentPlugin } from '@univerjs/sheets-thread-comment';
import { UniverSheetsUIPlugin } from '@univerjs/sheets-ui';
import { UniverThreadCommentPlugin } from '@univerjs/thread-comment';
import { UniverThreadCommentUIPlugin } from '@univerjs/thread-comment-ui';
import pkg from '../package.json';
import { ShowAddSheetCommentModalOperation, ToggleSheetCommentPanelOperation } from './commands/operations/comment.operation';
import { AddSheetDrawingCommentOperation, OpenSheetCommentPanelOperation, ShowAddSheetCommentModalOperation, ToggleSheetCommentPanelOperation } from './commands/operations/comment.operation';
import { defaultPluginConfig, SHEETS_THREAD_COMMENT_UI_PLUGIN_CONFIG_KEY } from './config/config';
import { ComponentsController } from './controllers/components.controller';
import { SheetsThreadCommentDrawingRenderController } from './controllers/render-controllers/drawing.render-controller';
import { SheetsThreadCommentRenderController } from './controllers/render-controllers/render.controller';
import { SheetsThreadCommentCopyPasteController } from './controllers/sheets-thread-comment-copy-paste.controller';
import { SheetsThreadCommentHoverController } from './controllers/sheets-thread-comment-hover.controller';
@@ -39,6 +41,7 @@ import { PLUGIN_NAME } from './types/const';
@DependentOn(
UniverRenderEnginePlugin,
UniverThreadCommentPlugin,
UniverDrawingPlugin,
UniverSheetsPlugin,
UniverThreadCommentUIPlugin,
UniverSheetsThreadCommentPlugin,
@@ -54,6 +57,7 @@ export class UniverSheetsThreadCommentUIPlugin extends Plugin {
private readonly _config: Partial<IUniverSheetsThreadCommentUIConfig> = defaultPluginConfig,
@Inject(Injector) protected override _injector: Injector,
@Inject(ICommandService) protected _commandService: ICommandService,
@IRenderManagerService private readonly _renderManagerService: IRenderManagerService,
@IConfigService private readonly _configService: IConfigService
) {
super();
@@ -86,6 +90,8 @@ export class UniverSheetsThreadCommentUIPlugin extends Plugin {
});
[
AddSheetDrawingCommentOperation,
OpenSheetCommentPanelOperation,
ShowAddSheetCommentModalOperation,
ToggleSheetCommentPanelOperation,
].forEach((command) => {
@@ -100,6 +106,10 @@ export class UniverSheetsThreadCommentUIPlugin extends Plugin {
}
override onRendered(): void {
this._renderManagerService.registerRenderModule(
UniverInstanceType.UNIVER_SHEET,
[SheetsThreadCommentDrawingRenderController]
);
this._injector.get(SheetsThreadCommentCopyPasteController);
this._injector.get(SheetsThreadCommentHoverController);
this._injector.get(SheetsThreadCommentPopupController);
@@ -16,10 +16,11 @@
import type { Workbook } from '@univerjs/core';
import type { IThreadComment } from '@univerjs/thread-comment';
import { ICommandService, IUniverInstanceService, UniverInstanceType } from '@univerjs/core';
import { ICommandService, IUniverInstanceService, UniverInstanceType, UserManagerService } from '@univerjs/core';
import { singleReferenceToGrid } from '@univerjs/engine-formula';
import { IMarkSelectionService } from '@univerjs/sheets-ui';
import { ThreadCommentPanel, ThreadCommentPanelService } from '@univerjs/thread-comment-ui';
import { deserializeThreadCommentAnchor, serializeThreadCommentAnchor, ThreadCommentAnchorKind } from '@univerjs/thread-comment';
import { ThreadCommentDraftService, ThreadCommentPanel, ThreadCommentPanelService } from '@univerjs/thread-comment-ui';
import { useDependency, useObservable } from '@univerjs/ui';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { map } from 'rxjs';
@@ -33,6 +34,21 @@ export const SheetsThreadCommentPanel = () => {
const workbook = univerInstanceService.getCurrentUnitOfType<Workbook>(UniverInstanceType.UNIVER_SHEET)!;
const unitId = workbook.getUnitId();
const commandService = useDependency(ICommandService);
const draftService = useDependency(ThreadCommentDraftService);
const userManagerService = useDependency(UserManagerService);
const draft = useObservable(draftService.draft$, draftService.draft);
const tempDrawingComment = draft?.anchor.kind === ThreadCommentAnchorKind.SHEET_DRAWING && draft.unitId === unitId
? {
id: '',
threadId: '',
unitId: draft.unitId,
subUnitId: draft.subUnitId,
ref: serializeThreadCommentAnchor(draft.anchor),
dT: '',
personId: userManagerService.getCurrentUser().userID,
text: { dataStream: '\r\n' },
}
: null;
const subUnitId$ = useMemo(() => workbook.activeSheet$.pipe(map((i) => i?.getSheetId())), [workbook.activeSheet$]);
const subUnitId = useObservable(subUnitId$, workbook.getActiveSheet()?.getSheetId());
const hoverShapeId = useRef<string | null>(null);
@@ -48,6 +64,10 @@ export const SheetsThreadCommentPanel = () => {
const sort = (comments: IThreadComment[]) => {
return comments.map((comment) => {
const anchor = deserializeThreadCommentAnchor(comment.ref);
if (anchor?.kind === ThreadCommentAnchorKind.SHEET_DRAWING) {
return { ...comment, p: [sheetIndex[comment.subUnitId] ?? 0, Number.MAX_SAFE_INTEGER, 0] };
}
const ref = singleReferenceToGrid(comment.ref);
const p = [sheetIndex[comment.subUnitId] ?? 0, ref.row, ref.column];
return { ...comment, p };
@@ -70,6 +90,9 @@ export const SheetsThreadCommentPanel = () => {
}, [workbook]);
const showShape = useCallback((comment: IThreadComment) => {
if (deserializeThreadCommentAnchor(comment.ref)) {
return null;
}
if (comment.unitId === unitId && comment.subUnitId === subUnitId && !comment.resolved) {
const { row, column } = singleReferenceToGrid(comment.ref);
const worksheet = workbook.getSheetBySheetId(comment.subUnitId);
@@ -157,6 +180,12 @@ export const SheetsThreadCommentPanel = () => {
handleLeave();
return true;
}}
tempComment={tempDrawingComment}
onTempCommentClose={() => draftService.cancel()}
formatRef={(comment) => {
const anchor = deserializeThreadCommentAnchor(comment.ref);
return anchor?.kind === ThreadCommentAnchorKind.SHEET_DRAWING ? `#${anchor.elementId}` : comment.ref;
}}
/>
);
};

Some files were not shown because too many files have changed in this diff Show More