snapshot of https://github.com/dream-num/univer.git (dev, 2026-09-16; blobs > 131072B omitted)
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ICommandService, Univer } from '@univerjs/core';
|
||||
import { DesktopSidebarService, ISidebarService } from '@univerjs/ui';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { ThreadCommentPanelService } from '../../../services/thread-comment-panel.service';
|
||||
import { SetActiveCommentOperation } from '../comment.operations';
|
||||
|
||||
describe('SetActiveCommentOperation', () => {
|
||||
let univer: Univer;
|
||||
let commandService: ICommandService;
|
||||
let panelService: ThreadCommentPanelService;
|
||||
|
||||
beforeEach(() => {
|
||||
univer = new Univer();
|
||||
const injector = univer.__getInjector();
|
||||
injector.add([ISidebarService, { useClass: DesktopSidebarService }]);
|
||||
injector.add([ThreadCommentPanelService]);
|
||||
|
||||
commandService = injector.get(ICommandService);
|
||||
commandService.registerCommand(SetActiveCommentOperation);
|
||||
panelService = injector.get(ThreadCommentPanelService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
univer.dispose();
|
||||
});
|
||||
|
||||
it('activates the thread comment selected by the user', async () => {
|
||||
const activeComment = {
|
||||
unitId: 'book-1',
|
||||
subUnitId: 'sheet-1',
|
||||
commentId: 'comment-1',
|
||||
};
|
||||
|
||||
const result = await commandService.executeCommand(SetActiveCommentOperation.id, activeComment);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(panelService.activeCommentId).toEqual(activeComment);
|
||||
});
|
||||
|
||||
it('clears active thread comment when the selection is reset', async () => {
|
||||
await commandService.executeCommand(SetActiveCommentOperation.id, {
|
||||
unitId: 'book-1',
|
||||
subUnitId: 'sheet-1',
|
||||
commentId: 'comment-1',
|
||||
});
|
||||
|
||||
const result = await commandService.executeCommand(SetActiveCommentOperation.id);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(panelService.activeCommentId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ICommand } from '@univerjs/core';
|
||||
import { CommandType } from '@univerjs/core';
|
||||
import { ThreadCommentPanelService } from '../../services/thread-comment-panel.service';
|
||||
|
||||
export interface ISetActiveCommentOperationParams {
|
||||
unitId: string;
|
||||
subUnitId: string;
|
||||
commentId: string;
|
||||
}
|
||||
|
||||
export const SetActiveCommentOperation: ICommand<ISetActiveCommentOperationParams> = {
|
||||
id: 'thread-comment-ui.operation.set-active-comment',
|
||||
type: CommandType.OPERATION,
|
||||
handler(accessor, params) {
|
||||
const panelService = accessor.get(ThreadCommentPanelService);
|
||||
panelService.setActiveComment(params);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
29
packages/thread-comment-ui/src/config/config.ts
Normal file
29
packages/thread-comment-ui/src/config/config.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { DependencyOverride } from '@univerjs/core';
|
||||
import type { MenuConfig } from '@univerjs/ui';
|
||||
|
||||
export const THREAD_COMMENT_UI_PLUGIN_CONFIG_KEY = 'thread-comment-ui.config';
|
||||
|
||||
export const configSymbol = Symbol(THREAD_COMMENT_UI_PLUGIN_CONFIG_KEY);
|
||||
|
||||
export interface IUniverThreadCommentUIConfig {
|
||||
overrides?: DependencyOverride;
|
||||
menu?: MenuConfig;
|
||||
}
|
||||
|
||||
export const defaultPluginConfig: IUniverThreadCommentUIConfig = {};
|
||||
2
packages/thread-comment-ui/src/global.css
Normal file
2
packages/thread-comment-ui/src/global.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
38
packages/thread-comment-ui/src/index.ts
Normal file
38
packages/thread-comment-ui/src/index.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* 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 './global.css';
|
||||
|
||||
export { SetActiveCommentOperation } from './commands/operations/comment.operations';
|
||||
export type { ISetActiveCommentOperationParams } from './commands/operations/comment.operations';
|
||||
export type { IUniverThreadCommentUIConfig } from './config/config';
|
||||
export { UniverThreadCommentMobileUIPlugin } from './mobile-plugin';
|
||||
export { UniverThreadCommentUIPlugin } from './plugin';
|
||||
export { ThreadCommentCanvasOverlay } from './render-objects/thread-comment-canvas-overlay';
|
||||
export type {
|
||||
IThreadCommentCanvasOutline,
|
||||
IThreadCommentCanvasUnderline,
|
||||
} from './render-objects/thread-comment-canvas-overlay';
|
||||
export { ThreadCommentDraftService } from './services/thread-comment-draft.service';
|
||||
export type { IThreadCommentDraft } from './services/thread-comment-draft.service';
|
||||
export { ThreadCommentPanelService } from './services/thread-comment-panel.service';
|
||||
export type { ActiveCommentInfo } from './services/thread-comment-panel.service';
|
||||
export { MobileThreadCommentPanel } from './views/mobile/MobileThreadCommentPanel';
|
||||
export { MobileThreadCommentTree } from './views/mobile/MobileThreadCommentTree';
|
||||
export { ThreadCommentPanel } from './views/ThreadCommentPanel';
|
||||
export type { IThreadCommentPanelProps } from './views/ThreadCommentPanel';
|
||||
export { ThreadCommentTree, ThreadCommentTreeLocation } from './views/ThreadCommentTree';
|
||||
export type { IThreadCommentTreeProps } from './views/ThreadCommentTree';
|
||||
55
packages/thread-comment-ui/src/locale/ar-SA.ts
Normal file
55
packages/thread-comment-ui/src/locale/ar-SA.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'thread-comment-ui': {
|
||||
mobile: { back: 'رجوع' },
|
||||
panel: {
|
||||
title: 'إدارة التعليقات',
|
||||
empty: 'لا توجد تعليقات بعد',
|
||||
reset: 'إعادة ضبط التصفية',
|
||||
addComment: 'إضافة تعليق',
|
||||
solved: 'تم الحل',
|
||||
},
|
||||
editor: {
|
||||
placeholder: 'رد أو إشارة إلى آخرين باستخدام @',
|
||||
reply: 'تعليق',
|
||||
cancel: 'إلغاء',
|
||||
save: 'حفظ',
|
||||
},
|
||||
item: {
|
||||
edit: 'تحرير',
|
||||
delete: 'حذف هذا التعليق',
|
||||
more: 'المزيد',
|
||||
},
|
||||
filter: {
|
||||
sheet: {
|
||||
all: 'كل الأوراق',
|
||||
current: 'الورقة الحالية',
|
||||
},
|
||||
status: {
|
||||
all: 'كل التعليقات',
|
||||
resolved: 'تم الحل',
|
||||
unsolved: 'لم يتم الحل',
|
||||
concernMe: 'يتعلق بي',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
55
packages/thread-comment-ui/src/locale/ca-ES.ts
Normal file
55
packages/thread-comment-ui/src/locale/ca-ES.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'thread-comment-ui': {
|
||||
mobile: { back: 'Enrere' },
|
||||
panel: {
|
||||
title: 'Gestió de comentaris',
|
||||
empty: 'Encara no hi ha comentaris',
|
||||
reset: 'Restableix el filtre',
|
||||
addComment: 'Afegeix un comentari',
|
||||
solved: 'Resolut',
|
||||
},
|
||||
editor: {
|
||||
placeholder: 'Respon o afegeix altres amb @',
|
||||
reply: 'Comenta',
|
||||
cancel: 'Cancel·la',
|
||||
save: 'Desa',
|
||||
},
|
||||
item: {
|
||||
edit: 'Edita',
|
||||
delete: 'Elimina aquest comentari',
|
||||
more: 'Més',
|
||||
},
|
||||
filter: {
|
||||
sheet: {
|
||||
all: 'Tots els fulls',
|
||||
current: 'Full actual',
|
||||
},
|
||||
status: {
|
||||
all: 'Tots els comentaris',
|
||||
resolved: 'Resolts',
|
||||
unsolved: 'No resolts',
|
||||
concernMe: 'Em concerneix',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
55
packages/thread-comment-ui/src/locale/de-DE.ts
Normal file
55
packages/thread-comment-ui/src/locale/de-DE.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'thread-comment-ui': {
|
||||
mobile: { back: 'Zurück' },
|
||||
panel: {
|
||||
title: 'Kommentarverwaltung',
|
||||
empty: 'Noch keine Kommentare',
|
||||
reset: 'Filter zurücksetzen',
|
||||
addComment: 'Kommentar hinzufügen',
|
||||
solved: 'Gelöst',
|
||||
},
|
||||
editor: {
|
||||
placeholder: 'Antworten oder andere mit @ erwähnen',
|
||||
reply: 'Kommentar',
|
||||
cancel: 'Abbrechen',
|
||||
save: 'Speichern',
|
||||
},
|
||||
item: {
|
||||
edit: 'Bearbeiten',
|
||||
delete: 'Diesen Kommentar löschen',
|
||||
more: 'Mehr',
|
||||
},
|
||||
filter: {
|
||||
sheet: {
|
||||
all: 'Alle Blätter',
|
||||
current: 'Aktuelles Blatt',
|
||||
},
|
||||
status: {
|
||||
all: 'Alle Kommentare',
|
||||
resolved: 'Gelöst',
|
||||
unsolved: 'Ungelöst',
|
||||
concernMe: 'Betrifft mich',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
53
packages/thread-comment-ui/src/locale/en-US.ts
Normal file
53
packages/thread-comment-ui/src/locale/en-US.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const locale = {
|
||||
'thread-comment-ui': {
|
||||
mobile: { back: 'Back' },
|
||||
panel: {
|
||||
title: 'Comment Management',
|
||||
empty: 'No comments yet',
|
||||
reset: 'Reset Filter',
|
||||
addComment: 'Add Comment',
|
||||
solved: 'Solved',
|
||||
},
|
||||
editor: {
|
||||
placeholder: 'Reply or add others with @',
|
||||
reply: 'Comment',
|
||||
cancel: 'Cancel',
|
||||
save: 'Save',
|
||||
},
|
||||
item: {
|
||||
edit: 'Edit',
|
||||
delete: 'Delete This Comment',
|
||||
more: 'More',
|
||||
},
|
||||
filter: {
|
||||
sheet: {
|
||||
all: 'All sheet',
|
||||
current: 'Current sheet',
|
||||
},
|
||||
status: {
|
||||
all: 'All comments',
|
||||
resolved: 'Resolved',
|
||||
unsolved: 'Not resolved',
|
||||
concernMe: 'Concern me',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
55
packages/thread-comment-ui/src/locale/es-ES.ts
Normal file
55
packages/thread-comment-ui/src/locale/es-ES.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'thread-comment-ui': {
|
||||
mobile: { back: 'Atrás' },
|
||||
panel: {
|
||||
title: 'Gestión de comentarios',
|
||||
empty: 'Aún no hay comentarios',
|
||||
reset: 'Restablecer filtro',
|
||||
addComment: 'Añadir comentario',
|
||||
solved: 'Resuelto',
|
||||
},
|
||||
editor: {
|
||||
placeholder: 'Responde o añade a otros con @',
|
||||
reply: 'Comentar',
|
||||
cancel: 'Cancelar',
|
||||
save: 'Guardar',
|
||||
},
|
||||
item: {
|
||||
edit: 'Editar',
|
||||
delete: 'Eliminar este comentario',
|
||||
more: 'Más',
|
||||
},
|
||||
filter: {
|
||||
sheet: {
|
||||
all: 'Todas las hojas',
|
||||
current: 'Hoja actual',
|
||||
},
|
||||
status: {
|
||||
all: 'Todos los comentarios',
|
||||
resolved: 'Resueltos',
|
||||
unsolved: 'No resueltos',
|
||||
concernMe: 'Me concierne',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
55
packages/thread-comment-ui/src/locale/fa-IR.ts
Normal file
55
packages/thread-comment-ui/src/locale/fa-IR.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'thread-comment-ui': {
|
||||
mobile: { back: 'بازگشت' },
|
||||
panel: {
|
||||
title: 'مدیریت نظرات',
|
||||
empty: 'هنوز نظری وجود ندارد',
|
||||
reset: 'بازنشانی فیلتر',
|
||||
addComment: 'افزودن نظر',
|
||||
solved: 'حل شده',
|
||||
},
|
||||
editor: {
|
||||
placeholder: 'پاسخ یا اضافه کردن دیگران با @',
|
||||
reply: 'نظر',
|
||||
cancel: 'انصراف',
|
||||
save: 'ذخیره',
|
||||
},
|
||||
item: {
|
||||
edit: 'ویرایش',
|
||||
delete: 'حذف این نظر',
|
||||
more: 'بیشتر',
|
||||
},
|
||||
filter: {
|
||||
sheet: {
|
||||
all: 'همه برگهها',
|
||||
current: 'برگه فعلی',
|
||||
},
|
||||
status: {
|
||||
all: 'همه نظرات',
|
||||
resolved: 'حل شده',
|
||||
unsolved: 'حل نشده',
|
||||
concernMe: 'موضوع مربوط به من',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
55
packages/thread-comment-ui/src/locale/fr-FR.ts
Normal file
55
packages/thread-comment-ui/src/locale/fr-FR.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'thread-comment-ui': {
|
||||
mobile: { back: 'Retour' },
|
||||
panel: {
|
||||
title: 'Gestion des commentaires',
|
||||
empty: 'Pas encore de commentaires',
|
||||
reset: 'Réinitialiser le filtre',
|
||||
addComment: 'Ajouter un commentaire',
|
||||
solved: 'Résolu',
|
||||
},
|
||||
editor: {
|
||||
placeholder: 'Répondre ou ajouter d\'autres avec @',
|
||||
reply: 'Commenter',
|
||||
cancel: 'Annuler',
|
||||
save: 'Enregistrer',
|
||||
},
|
||||
item: {
|
||||
edit: 'Modifier',
|
||||
delete: 'Supprimer ce commentaire',
|
||||
more: 'Plus',
|
||||
},
|
||||
filter: {
|
||||
sheet: {
|
||||
all: 'Toutes les feuilles',
|
||||
current: 'Feuille actuelle',
|
||||
},
|
||||
status: {
|
||||
all: 'Tous les commentaires',
|
||||
resolved: 'Résolu',
|
||||
unsolved: 'Non résolu',
|
||||
concernMe: 'Me concerne',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
55
packages/thread-comment-ui/src/locale/id-ID.ts
Normal file
55
packages/thread-comment-ui/src/locale/id-ID.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'thread-comment-ui': {
|
||||
mobile: { back: 'Kembali' },
|
||||
panel: {
|
||||
title: 'Manajemen Komentar',
|
||||
empty: 'Belum ada komentar',
|
||||
reset: 'Atur Ulang Filter',
|
||||
addComment: 'Tambah Komentar',
|
||||
solved: 'Terselesaikan',
|
||||
},
|
||||
editor: {
|
||||
placeholder: 'Balas atau tambahkan orang lain dengan @',
|
||||
reply: 'Komentar',
|
||||
cancel: 'Batal',
|
||||
save: 'Simpan',
|
||||
},
|
||||
item: {
|
||||
edit: 'Edit',
|
||||
delete: 'Hapus Komentar Ini',
|
||||
more: 'Lebih Banyak',
|
||||
},
|
||||
filter: {
|
||||
sheet: {
|
||||
all: 'Semua lembar',
|
||||
current: 'Lembar saat ini',
|
||||
},
|
||||
status: {
|
||||
all: 'Semua komentar',
|
||||
resolved: 'Terselesaikan',
|
||||
unsolved: 'Belum terselesaikan',
|
||||
concernMe: 'Yang menyangkut saya',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
55
packages/thread-comment-ui/src/locale/it-IT.ts
Normal file
55
packages/thread-comment-ui/src/locale/it-IT.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'thread-comment-ui': {
|
||||
mobile: { back: 'Indietro' },
|
||||
panel: {
|
||||
title: 'Gestione Commenti',
|
||||
empty: 'Nessun commento',
|
||||
reset: 'Reimposta filtro',
|
||||
addComment: 'Aggiungi commento',
|
||||
solved: 'Risolto',
|
||||
},
|
||||
editor: {
|
||||
placeholder: 'Rispondi o aggiungi altri con @',
|
||||
reply: 'Commenta',
|
||||
cancel: 'Annulla',
|
||||
save: 'Salva',
|
||||
},
|
||||
item: {
|
||||
edit: 'Modifica',
|
||||
delete: 'Elimina questo commento',
|
||||
more: 'Altro',
|
||||
},
|
||||
filter: {
|
||||
sheet: {
|
||||
all: 'Tutti i fogli',
|
||||
current: 'Foglio corrente',
|
||||
},
|
||||
status: {
|
||||
all: 'Tutti i commenti',
|
||||
resolved: 'Risolti',
|
||||
unsolved: 'Non risolti',
|
||||
concernMe: 'Mi riguardano',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
55
packages/thread-comment-ui/src/locale/ja-JP.ts
Normal file
55
packages/thread-comment-ui/src/locale/ja-JP.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'thread-comment-ui': {
|
||||
mobile: { back: '戻る' },
|
||||
panel: {
|
||||
title: 'コメント管理',
|
||||
empty: 'まだコメントはありません',
|
||||
reset: 'フィルタをリセット',
|
||||
addComment: 'コメントを追加',
|
||||
solved: '解決済み',
|
||||
},
|
||||
editor: {
|
||||
placeholder: '返信または@で他のユーザーを追加',
|
||||
reply: '返信',
|
||||
cancel: 'キャンセル',
|
||||
save: '保存',
|
||||
},
|
||||
item: {
|
||||
edit: '編集',
|
||||
delete: 'コメントを削除',
|
||||
more: 'もっと見る',
|
||||
},
|
||||
filter: {
|
||||
sheet: {
|
||||
all: 'すべてのシート',
|
||||
current: '現在のシート',
|
||||
},
|
||||
status: {
|
||||
all: 'すべてのコメント',
|
||||
resolved: '解決済み',
|
||||
unsolved: '未解決',
|
||||
concernMe: '自分に関連',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
55
packages/thread-comment-ui/src/locale/ko-KR.ts
Normal file
55
packages/thread-comment-ui/src/locale/ko-KR.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'thread-comment-ui': {
|
||||
mobile: { back: '뒤로' },
|
||||
panel: {
|
||||
title: '댓글 관리',
|
||||
empty: '아직 댓글이 없습니다',
|
||||
reset: '필터 초기화',
|
||||
addComment: '댓글 추가',
|
||||
solved: '해결됨',
|
||||
},
|
||||
editor: {
|
||||
placeholder: '답변 또는 @로 다른 사람 추가',
|
||||
reply: '댓글',
|
||||
cancel: '취소',
|
||||
save: '저장',
|
||||
},
|
||||
item: {
|
||||
edit: '편집',
|
||||
delete: '댓글 삭제',
|
||||
more: '더 보기',
|
||||
},
|
||||
filter: {
|
||||
sheet: {
|
||||
all: '모든 시트',
|
||||
current: '현재 시트',
|
||||
},
|
||||
status: {
|
||||
all: '모든 댓글',
|
||||
resolved: '해결됨',
|
||||
unsolved: '해결되지 않음',
|
||||
concernMe: '나에게 관련됨',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
55
packages/thread-comment-ui/src/locale/pl-PL.ts
Normal file
55
packages/thread-comment-ui/src/locale/pl-PL.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'thread-comment-ui': {
|
||||
mobile: { back: 'Wstecz' },
|
||||
panel: {
|
||||
title: 'Zarządzanie komentarzami',
|
||||
empty: 'Brak komentarzy',
|
||||
reset: 'Resetuj filtr',
|
||||
addComment: 'Dodaj komentarz',
|
||||
solved: 'Rozwiązane',
|
||||
},
|
||||
editor: {
|
||||
placeholder: 'Odpowiedz lub dodaj innych za pomocą @',
|
||||
reply: 'Komentarz',
|
||||
cancel: 'Anuluj',
|
||||
save: 'Zapisz',
|
||||
},
|
||||
item: {
|
||||
edit: 'Edytuj',
|
||||
delete: 'Usuń ten komentarz',
|
||||
more: 'Więcej',
|
||||
},
|
||||
filter: {
|
||||
sheet: {
|
||||
all: 'Wszystkie arkusze',
|
||||
current: 'Bieżący arkusz',
|
||||
},
|
||||
status: {
|
||||
all: 'Wszystkie komentarze',
|
||||
resolved: 'Rozwiązane',
|
||||
unsolved: 'Nierozwiązane',
|
||||
concernMe: 'Dotyczące mnie',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
55
packages/thread-comment-ui/src/locale/pt-BR.ts
Normal file
55
packages/thread-comment-ui/src/locale/pt-BR.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'thread-comment-ui': {
|
||||
mobile: { back: 'Voltar' },
|
||||
panel: {
|
||||
title: 'Gerenciamento de Comentários',
|
||||
empty: 'Nenhum comentário ainda',
|
||||
reset: 'Redefinir Filtro',
|
||||
addComment: 'Adicionar Comentário',
|
||||
solved: 'Resolvido',
|
||||
},
|
||||
editor: {
|
||||
placeholder: 'Responder ou mencionar outros com @',
|
||||
reply: 'Comentar',
|
||||
cancel: 'Cancelar',
|
||||
save: 'Salvar',
|
||||
},
|
||||
item: {
|
||||
edit: 'Editar',
|
||||
delete: 'Excluir Este Comentário',
|
||||
more: 'Mais',
|
||||
},
|
||||
filter: {
|
||||
sheet: {
|
||||
all: 'Todas as planilhas',
|
||||
current: 'Planilha atual',
|
||||
},
|
||||
status: {
|
||||
all: 'Todos os comentários',
|
||||
resolved: 'Resolvidos',
|
||||
unsolved: 'Não resolvidos',
|
||||
concernMe: 'Me envolvem',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
55
packages/thread-comment-ui/src/locale/ru-RU.ts
Normal file
55
packages/thread-comment-ui/src/locale/ru-RU.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'thread-comment-ui': {
|
||||
mobile: { back: 'Назад' },
|
||||
panel: {
|
||||
title: 'Управление комментариями',
|
||||
empty: 'Комментариев пока нет',
|
||||
reset: 'Сбросить фильтр',
|
||||
addComment: 'Добавить комментарий',
|
||||
solved: 'Решено',
|
||||
},
|
||||
editor: {
|
||||
placeholder: 'Отметьте пользователей через @',
|
||||
reply: 'Добавить комментарий',
|
||||
cancel: 'Отмена',
|
||||
save: 'Сохранить',
|
||||
},
|
||||
item: {
|
||||
edit: 'Редактировать',
|
||||
delete: 'Удалить',
|
||||
more: 'Больше',
|
||||
},
|
||||
filter: {
|
||||
sheet: {
|
||||
all: 'Все листы',
|
||||
current: 'Текущий лист',
|
||||
},
|
||||
status: {
|
||||
all: 'Все комментарии',
|
||||
resolved: 'Решено',
|
||||
unsolved: 'Не решено',
|
||||
concernMe: 'Относящиеся ко мне',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
55
packages/thread-comment-ui/src/locale/sk-SK.ts
Normal file
55
packages/thread-comment-ui/src/locale/sk-SK.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'thread-comment-ui': {
|
||||
mobile: { back: 'Späť' },
|
||||
panel: {
|
||||
title: 'Správa komentárov',
|
||||
empty: 'Zatiaľ žiadne komentáre',
|
||||
reset: 'Obnoviť filter',
|
||||
addComment: 'Pridať komentár',
|
||||
solved: 'Vyriešené',
|
||||
},
|
||||
editor: {
|
||||
placeholder: 'Odpovedzte alebo pridajte ostatných pomocou @',
|
||||
reply: 'Komentovať',
|
||||
cancel: 'Zrušiť',
|
||||
save: 'Uložiť',
|
||||
},
|
||||
item: {
|
||||
edit: 'Upraviť',
|
||||
delete: 'Odstrániť tento komentár',
|
||||
more: 'Viac',
|
||||
},
|
||||
filter: {
|
||||
sheet: {
|
||||
all: 'Všetky hárky',
|
||||
current: 'Aktuálny hárok',
|
||||
},
|
||||
status: {
|
||||
all: 'Všetky komentáre',
|
||||
resolved: 'Vyriešené',
|
||||
unsolved: 'Nevyriešené',
|
||||
concernMe: 'Týka sa ma',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
20
packages/thread-comment-ui/src/locale/types.ts
Normal file
20
packages/thread-comment-ui/src/locale/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { LocaleLeafKeys } from '@univerjs/core';
|
||||
import type enUS from './en-US';
|
||||
|
||||
export type LocaleKey = LocaleLeafKeys<typeof enUS>;
|
||||
56
packages/thread-comment-ui/src/locale/vi-VN.ts
Normal file
56
packages/thread-comment-ui/src/locale/vi-VN.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 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 enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'thread-comment-ui': {
|
||||
mobile: { back: 'Quay lại' },
|
||||
panel: {
|
||||
title: 'Quản lý Bình luận',
|
||||
empty: 'Chưa có bình luận',
|
||||
reset: 'Đặt lại',
|
||||
addComment: 'Thêm bình luận',
|
||||
solved: 'Đã giải quyết',
|
||||
},
|
||||
editor: {
|
||||
placeholder: 'Phản hồi',
|
||||
reply: 'Phản hồi',
|
||||
cancel: 'Hủy',
|
||||
save: 'Lưu',
|
||||
},
|
||||
item: {
|
||||
edit: 'Chỉnh sửa',
|
||||
delete: 'Xóa',
|
||||
more: 'Thêm',
|
||||
},
|
||||
filter: {
|
||||
sheet: {
|
||||
all: 'Tất cả bảng',
|
||||
current: 'Bảng hiện tại',
|
||||
},
|
||||
status: {
|
||||
all: 'Tất cả bình luận',
|
||||
resolved: 'Đã giải quyết',
|
||||
unsolved: 'Chưa giải quyết',
|
||||
concernMe: 'Liên quan đến tôi',
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
export default locale;
|
||||
55
packages/thread-comment-ui/src/locale/zh-CN.ts
Normal file
55
packages/thread-comment-ui/src/locale/zh-CN.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'thread-comment-ui': {
|
||||
mobile: { back: '返回' },
|
||||
panel: {
|
||||
title: '评论管理',
|
||||
empty: '暂无评论',
|
||||
reset: '重置',
|
||||
addComment: '添加评论',
|
||||
solved: '已解决',
|
||||
},
|
||||
editor: {
|
||||
placeholder: '回复',
|
||||
reply: '回复',
|
||||
cancel: '取消',
|
||||
save: '保存',
|
||||
},
|
||||
item: {
|
||||
edit: '编辑',
|
||||
delete: '删除',
|
||||
more: '更多',
|
||||
},
|
||||
filter: {
|
||||
sheet: {
|
||||
all: '所有表格',
|
||||
current: '当前表格',
|
||||
},
|
||||
status: {
|
||||
all: '所有评论',
|
||||
resolved: '已解决',
|
||||
unsolved: '未解决',
|
||||
concernMe: '与我有关',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
55
packages/thread-comment-ui/src/locale/zh-HK.ts
Normal file
55
packages/thread-comment-ui/src/locale/zh-HK.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'thread-comment-ui': {
|
||||
mobile: { back: '返回' },
|
||||
panel: {
|
||||
title: '評論管理',
|
||||
empty: '暫無評論',
|
||||
reset: '重置',
|
||||
addComment: '新增評論',
|
||||
solved: '已解決',
|
||||
},
|
||||
editor: {
|
||||
placeholder: '回覆',
|
||||
reply: '回覆',
|
||||
cancel: '取消',
|
||||
save: '儲存',
|
||||
},
|
||||
item: {
|
||||
edit: '編輯',
|
||||
delete: '刪除',
|
||||
more: '更多',
|
||||
},
|
||||
filter: {
|
||||
sheet: {
|
||||
all: '所有表格',
|
||||
current: '當前表格',
|
||||
},
|
||||
status: {
|
||||
all: '所有評論',
|
||||
resolved: '已解決',
|
||||
unsolved: '未解決',
|
||||
concernMe: '與我有關',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
55
packages/thread-comment-ui/src/locale/zh-TW.ts
Normal file
55
packages/thread-comment-ui/src/locale/zh-TW.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'thread-comment-ui': {
|
||||
mobile: { back: '返回' },
|
||||
panel: {
|
||||
title: '評論管理',
|
||||
empty: '暫無評論',
|
||||
reset: '重置',
|
||||
addComment: '新增評論',
|
||||
solved: '已解決',
|
||||
},
|
||||
editor: {
|
||||
placeholder: '回覆',
|
||||
reply: '回覆',
|
||||
cancel: '取消',
|
||||
save: '儲存',
|
||||
},
|
||||
item: {
|
||||
edit: '編輯',
|
||||
delete: '刪除',
|
||||
more: '更多',
|
||||
},
|
||||
filter: {
|
||||
sheet: {
|
||||
all: '所有表格',
|
||||
current: '當前表格',
|
||||
},
|
||||
status: {
|
||||
all: '所有評論',
|
||||
resolved: '已解決',
|
||||
unsolved: '未解決',
|
||||
concernMe: '與我有關',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
72
packages/thread-comment-ui/src/mobile-plugin.ts
Normal file
72
packages/thread-comment-ui/src/mobile-plugin.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Dependency } from '@univerjs/core';
|
||||
import type { IUniverThreadCommentUIConfig } from './config/config';
|
||||
import {
|
||||
DependentOn,
|
||||
ICommandService,
|
||||
IConfigService,
|
||||
Inject,
|
||||
Injector,
|
||||
merge,
|
||||
mergeOverrideWithDependencies,
|
||||
Plugin,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { UniverDocsUIPlugin } from '@univerjs/docs-ui';
|
||||
import { UniverThreadCommentPlugin } from '@univerjs/thread-comment';
|
||||
import { UniverMobileUIPlugin } from '@univerjs/ui';
|
||||
import pkg from '../package.json';
|
||||
import { SetActiveCommentOperation } from './commands/operations/comment.operations';
|
||||
import { defaultPluginConfig, THREAD_COMMENT_UI_PLUGIN_CONFIG_KEY } from './config/config';
|
||||
import { ThreadCommentDraftService } from './services/thread-comment-draft.service';
|
||||
import { ThreadCommentPanelService } from './services/thread-comment-panel.service';
|
||||
import { PLUGIN_NAME } from './types/const';
|
||||
|
||||
@DependentOn(UniverThreadCommentPlugin, UniverDocsUIPlugin, UniverMobileUIPlugin)
|
||||
export class UniverThreadCommentMobileUIPlugin extends Plugin {
|
||||
static override pluginName = PLUGIN_NAME;
|
||||
static override packageName = pkg.name;
|
||||
static override version = pkg.version;
|
||||
static override type = UniverInstanceType.UNIVER_UNKNOWN;
|
||||
|
||||
constructor(
|
||||
private readonly _config: Partial<IUniverThreadCommentUIConfig> = defaultPluginConfig,
|
||||
@Inject(Injector) protected override _injector: Injector,
|
||||
@ICommandService private readonly _commandService: ICommandService,
|
||||
@IConfigService private readonly _configService: IConfigService
|
||||
) {
|
||||
super();
|
||||
|
||||
const { menu, ...rest } = merge({}, defaultPluginConfig, this._config);
|
||||
if (menu) {
|
||||
this._configService.setConfig('menu', menu, { merge: true });
|
||||
}
|
||||
this._configService.setConfig(THREAD_COMMENT_UI_PLUGIN_CONFIG_KEY, rest);
|
||||
}
|
||||
|
||||
override onStarting(): void {
|
||||
(mergeOverrideWithDependencies([
|
||||
[ThreadCommentDraftService],
|
||||
[ThreadCommentPanelService],
|
||||
], this._config?.overrides) as Dependency[]).forEach((dependency) => {
|
||||
this._injector.add(dependency);
|
||||
});
|
||||
|
||||
this._commandService.registerCommand(SetActiveCommentOperation);
|
||||
}
|
||||
}
|
||||
80
packages/thread-comment-ui/src/plugin.ts
Normal file
80
packages/thread-comment-ui/src/plugin.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Dependency } from '@univerjs/core';
|
||||
import type { IUniverThreadCommentUIConfig } from './config/config';
|
||||
import {
|
||||
DependentOn,
|
||||
ICommandService,
|
||||
IConfigService,
|
||||
Inject,
|
||||
Injector,
|
||||
merge,
|
||||
mergeOverrideWithDependencies,
|
||||
Plugin,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { UniverDocsUIPlugin } from '@univerjs/docs-ui';
|
||||
import { UniverThreadCommentPlugin } from '@univerjs/thread-comment';
|
||||
import pkg from '../package.json';
|
||||
import { SetActiveCommentOperation } from './commands/operations/comment.operations';
|
||||
import { defaultPluginConfig, THREAD_COMMENT_UI_PLUGIN_CONFIG_KEY } from './config/config';
|
||||
import { ThreadCommentDraftService } from './services/thread-comment-draft.service';
|
||||
import { ThreadCommentPanelService } from './services/thread-comment-panel.service';
|
||||
import { PLUGIN_NAME } from './types/const';
|
||||
|
||||
@DependentOn(UniverThreadCommentPlugin, UniverDocsUIPlugin)
|
||||
export class UniverThreadCommentUIPlugin extends Plugin {
|
||||
static override pluginName = PLUGIN_NAME;
|
||||
static override packageName = pkg.name;
|
||||
static override version = pkg.version;
|
||||
static override type = UniverInstanceType.UNIVER_UNKNOWN;
|
||||
|
||||
constructor(
|
||||
private readonly _config: Partial<IUniverThreadCommentUIConfig> = defaultPluginConfig,
|
||||
@Inject(Injector) protected override _injector: Injector,
|
||||
@ICommandService protected _commandService: ICommandService,
|
||||
@IConfigService private readonly _configService: IConfigService
|
||||
) {
|
||||
super();
|
||||
|
||||
// Manage the plugin configuration.
|
||||
const { menu, ...rest } = merge(
|
||||
{},
|
||||
defaultPluginConfig,
|
||||
this._config
|
||||
);
|
||||
if (menu) {
|
||||
this._configService.setConfig('menu', menu, { merge: true });
|
||||
}
|
||||
this._configService.setConfig(THREAD_COMMENT_UI_PLUGIN_CONFIG_KEY, rest);
|
||||
}
|
||||
|
||||
override onStarting(): void {
|
||||
(mergeOverrideWithDependencies([
|
||||
[ThreadCommentDraftService],
|
||||
[ThreadCommentPanelService],
|
||||
], this._config?.overrides) as Dependency[]).forEach((dep) => {
|
||||
this._injector.add(dep);
|
||||
});
|
||||
|
||||
[
|
||||
SetActiveCommentOperation,
|
||||
].forEach((command) => {
|
||||
this._commandService.registerCommand(command);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Vector2 } from '@univerjs/engine-render';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { groupThreadCommentCanvasMarkers, ThreadCommentCanvasOverlay } from '../thread-comment-canvas-overlay';
|
||||
|
||||
describe('ThreadCommentCanvasOverlay', () => {
|
||||
it('groups comments at the same canvas anchor and keeps the newest one clickable', () => {
|
||||
expect(groupThreadCommentCanvasMarkers([
|
||||
{ commentId: 'first-comment', x: 100, y: 100 },
|
||||
{ commentId: 'other-position', x: 120, y: 100 },
|
||||
{ commentId: 'newest-comment', x: 100, y: 100 },
|
||||
])).toEqual([
|
||||
{
|
||||
commentId: 'newest-comment',
|
||||
commentIds: ['first-comment', 'newest-comment'],
|
||||
count: 2,
|
||||
x: 100,
|
||||
y: 100,
|
||||
},
|
||||
{
|
||||
commentId: 'other-position',
|
||||
commentIds: ['other-position'],
|
||||
count: 1,
|
||||
x: 120,
|
||||
y: 100,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('decrements and removes an anchor marker as root comments resolve or delete', () => {
|
||||
const comments = [
|
||||
{ commentId: 'first-comment', x: 100, y: 100 },
|
||||
{ commentId: 'second-comment', x: 100, y: 100 },
|
||||
];
|
||||
|
||||
expect(groupThreadCommentCanvasMarkers(comments)[0]).toEqual(expect.objectContaining({ count: 2 }));
|
||||
expect(groupThreadCommentCanvasMarkers(comments.slice(1))[0]).toEqual(expect.objectContaining({ count: 1 }));
|
||||
expect(groupThreadCommentCanvasMarkers([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('repaints pointer previews immediately without debounce', () => {
|
||||
const overlay = new ThreadCommentCanvasOverlay('test-comment-preview', {
|
||||
accentColor: 'yellow',
|
||||
foregroundColor: 'black',
|
||||
outlineColor: 'white',
|
||||
zoomRatio: 1,
|
||||
markers: [],
|
||||
underlines: [],
|
||||
});
|
||||
const immediate = vi.spyOn(overlay, 'makeDirtyNoDebounce');
|
||||
const debounced = vi.spyOn(overlay, 'makeDirty');
|
||||
|
||||
overlay.updateState({ previewMarker: { x: 10, y: 20 }, previewUnderline: null });
|
||||
|
||||
expect(immediate).toHaveBeenCalledWith(true);
|
||||
expect(debounced).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('hits saved position markers and element underlines at the current zoom', () => {
|
||||
const overlay = new ThreadCommentCanvasOverlay('test-comment-overlay', {
|
||||
accentColor: 'yellow',
|
||||
foregroundColor: 'black',
|
||||
outlineColor: 'white',
|
||||
zoomRatio: 2,
|
||||
markers: [{ commentId: 'position-comment', x: 100, y: 100 }],
|
||||
underlines: [{ commentId: 'element-comment', left: 20, top: 40, width: 60 }],
|
||||
previewMarker: { x: 200, y: 200 },
|
||||
});
|
||||
|
||||
expect(overlay.isHit(new Vector2(105, 100))).toBe(true);
|
||||
expect(overlay.hitCommentId).toBe('position-comment');
|
||||
expect(overlay.isHit(new Vector2(50, 41))).toBe(true);
|
||||
expect(overlay.hitCommentId).toBe('element-comment');
|
||||
expect(overlay.isHit(new Vector2(200, 200))).toBe(false);
|
||||
expect(overlay.hitCommentId).toBeNull();
|
||||
});
|
||||
|
||||
it('clears a stale hit when its comment is removed or resolved', () => {
|
||||
const overlay = new ThreadCommentCanvasOverlay('test-comment-overlay', {
|
||||
accentColor: 'yellow',
|
||||
foregroundColor: 'black',
|
||||
outlineColor: 'white',
|
||||
zoomRatio: 1,
|
||||
markers: [{ commentId: 'removed-comment', x: 100, y: 100 }],
|
||||
underlines: [],
|
||||
});
|
||||
|
||||
expect(overlay.isHit(new Vector2(100, 100))).toBe(true);
|
||||
expect(overlay.hitCommentId).toBe('removed-comment');
|
||||
|
||||
overlay.updateState({ markers: [] });
|
||||
|
||||
expect(overlay.hitCommentId).toBeNull();
|
||||
expect(overlay.isHit(new Vector2(100, 100))).toBe(false);
|
||||
});
|
||||
|
||||
it('uses the topmost saved marker for overlapping comments', () => {
|
||||
const overlay = new ThreadCommentCanvasOverlay('test-comment-overlay', {
|
||||
accentColor: 'yellow',
|
||||
foregroundColor: 'black',
|
||||
outlineColor: 'white',
|
||||
zoomRatio: 0.5,
|
||||
markers: [
|
||||
{ commentId: 'first-comment', x: 100, y: 100 },
|
||||
{ commentId: 'top-comment', x: 100, y: 100 },
|
||||
],
|
||||
underlines: [{ commentId: 'element-comment', left: 80, top: 100, width: 40 }],
|
||||
});
|
||||
|
||||
expect(overlay.isHit(new Vector2(100, 100))).toBe(true);
|
||||
expect(overlay.hitCommentId).toBe('top-comment');
|
||||
});
|
||||
|
||||
it('keeps an aggregated marker focused for every comment at the same anchor', () => {
|
||||
const overlay = new ThreadCommentCanvasOverlay('test-comment-group', {
|
||||
accentColor: 'yellow',
|
||||
foregroundColor: 'black',
|
||||
outlineColor: 'white',
|
||||
zoomRatio: 1,
|
||||
markers: [{
|
||||
commentId: 'newest-comment',
|
||||
commentIds: ['older-comment', 'newest-comment'],
|
||||
count: 2,
|
||||
x: 100,
|
||||
y: 100,
|
||||
}],
|
||||
underlines: [],
|
||||
focusedCommentIds: ['older-comment'],
|
||||
});
|
||||
const renderMarker = vi.fn();
|
||||
Reflect.set(overlay, '_renderMarker', renderMarker);
|
||||
|
||||
overlay.render(undefined as never, undefined as never);
|
||||
|
||||
expect(renderMarker).toHaveBeenCalledWith(
|
||||
undefined,
|
||||
expect.objectContaining({ commentId: 'newest-comment', count: 2 }),
|
||||
1,
|
||||
1
|
||||
);
|
||||
expect(overlay.isHit(new Vector2(100, 100))).toBe(true);
|
||||
expect(overlay.hitCommentId).toBe('newest-comment');
|
||||
});
|
||||
|
||||
it('reconciles underline hit geometry after an object transform and restore', () => {
|
||||
const overlay = new ThreadCommentCanvasOverlay('test-comment-overlay', {
|
||||
accentColor: 'yellow',
|
||||
foregroundColor: 'black',
|
||||
outlineColor: 'white',
|
||||
zoomRatio: 1.25,
|
||||
markers: [],
|
||||
underlines: [{ commentId: 'element-comment', left: 20, top: 40, width: 60 }],
|
||||
});
|
||||
|
||||
expect(overlay.isHit(new Vector2(50, 41))).toBe(true);
|
||||
|
||||
overlay.updateState({
|
||||
underlines: [{ commentId: 'element-comment', left: 140, top: 180, width: 120 }],
|
||||
});
|
||||
expect(overlay.isHit(new Vector2(50, 41))).toBe(false);
|
||||
expect(overlay.isHit(new Vector2(200, 181))).toBe(true);
|
||||
|
||||
overlay.updateState({ underlines: [] });
|
||||
expect(overlay.isHit(new Vector2(200, 181))).toBe(false);
|
||||
overlay.updateState({
|
||||
underlines: [{ commentId: 'element-comment', left: 140, top: 180, width: 120 }],
|
||||
});
|
||||
expect(overlay.isHit(new Vector2(200, 181))).toBe(true);
|
||||
});
|
||||
|
||||
it('renders outlines for both selected and hovered object comments', () => {
|
||||
const overlay = new ThreadCommentCanvasOverlay('test-comment-focus', {
|
||||
accentColor: 'yellow',
|
||||
foregroundColor: 'black',
|
||||
outlineColor: 'white',
|
||||
zoomRatio: 1,
|
||||
markers: [],
|
||||
underlines: [],
|
||||
focusedCommentIds: ['selected-comment', 'hovered-comment'],
|
||||
focusOutlines: [
|
||||
{ left: 10, top: 20, width: 100, height: 60 },
|
||||
{ left: 140, top: 80, width: 120, height: 80 },
|
||||
],
|
||||
});
|
||||
const renderOutline = vi.fn();
|
||||
Reflect.set(overlay, '_renderOutline', renderOutline);
|
||||
|
||||
Reflect.apply(overlay.render, overlay, [undefined, undefined]);
|
||||
|
||||
expect(renderOutline).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,297 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IViewportInfo, UniverRenderingContext, Vector2 } from '@univerjs/engine-render';
|
||||
import { BaseObject, DEFAULT_FONTFACE_PLANE } from '@univerjs/engine-render';
|
||||
|
||||
export interface IThreadCommentCanvasMarker {
|
||||
commentId: string;
|
||||
commentIds?: readonly string[];
|
||||
count?: number;
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface IThreadCommentCanvasUnderline {
|
||||
commentId: string;
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
}
|
||||
|
||||
export interface IThreadCommentCanvasOutline {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface IThreadCommentCanvasOverlayState {
|
||||
accentColor: string;
|
||||
foregroundColor: string;
|
||||
outlineColor: string;
|
||||
zoomRatio: number;
|
||||
markers: readonly IThreadCommentCanvasMarker[];
|
||||
underlines: readonly IThreadCommentCanvasUnderline[];
|
||||
focusedCommentIds?: readonly string[];
|
||||
focusOutlines?: readonly IThreadCommentCanvasOutline[];
|
||||
previewMarker?: Omit<IThreadCommentCanvasMarker, 'commentId'> | null;
|
||||
previewUnderline?: Omit<IThreadCommentCanvasUnderline, 'commentId'> | null;
|
||||
}
|
||||
|
||||
const MARKER_SIZE = 24;
|
||||
const MARKER_IDLE_OPACITY = 0.58;
|
||||
const MARKER_PREVIEW_OPACITY = 0.42;
|
||||
const UNDERLINE_WIDTH = 2;
|
||||
const UNDERLINE_HIT_PADDING = 4;
|
||||
const OUTLINE_PADDING = 2;
|
||||
|
||||
export function groupThreadCommentCanvasMarkers(
|
||||
markers: readonly IThreadCommentCanvasMarker[]
|
||||
): IThreadCommentCanvasMarker[] {
|
||||
const groups = new Map<string, IThreadCommentCanvasMarker & { commentIds: string[]; count: number }>();
|
||||
markers.forEach((marker) => {
|
||||
const key = `${marker.x}\0${marker.y}`;
|
||||
const current = groups.get(key);
|
||||
if (current) {
|
||||
current.commentId = marker.commentId;
|
||||
current.commentIds.push(marker.commentId);
|
||||
current.count += 1;
|
||||
return;
|
||||
}
|
||||
groups.set(key, {
|
||||
...marker,
|
||||
commentIds: [marker.commentId],
|
||||
count: 1,
|
||||
});
|
||||
});
|
||||
return Array.from(groups.values());
|
||||
}
|
||||
|
||||
export class ThreadCommentCanvasOverlay extends BaseObject {
|
||||
private _state: IThreadCommentCanvasOverlayState;
|
||||
private _hoveredCommentId: string | null = null;
|
||||
private _hitCommentId: string | null = null;
|
||||
|
||||
constructor(key: string, state: IThreadCommentCanvasOverlayState) {
|
||||
super(key);
|
||||
this.evented = true;
|
||||
this._state = state;
|
||||
this.show();
|
||||
}
|
||||
|
||||
get hitCommentId(): string | null {
|
||||
return this._hitCommentId;
|
||||
}
|
||||
|
||||
updateState(state: Partial<IThreadCommentCanvasOverlayState>): void {
|
||||
this._state = { ...this._state, ...state };
|
||||
if (this._hitCommentId && !this._containsComment(this._hitCommentId)) {
|
||||
this._hitCommentId = null;
|
||||
}
|
||||
if (this._hoveredCommentId && !this._containsComment(this._hoveredCommentId)) {
|
||||
this._hoveredCommentId = null;
|
||||
}
|
||||
if ('previewMarker' in state || 'previewUnderline' in state) {
|
||||
this.makeDirtyNoDebounce(true);
|
||||
} else {
|
||||
this.makeDirty(true);
|
||||
}
|
||||
}
|
||||
|
||||
clearHover(): void {
|
||||
if (this._hoveredCommentId) {
|
||||
this._hoveredCommentId = null;
|
||||
this.makeDirty(true);
|
||||
}
|
||||
this._hitCommentId = null;
|
||||
}
|
||||
|
||||
override isHit(coord: Vector2): boolean {
|
||||
const zoomRatio = this._getZoomRatio();
|
||||
const halfSize = MARKER_SIZE / zoomRatio / 2;
|
||||
let marker: IThreadCommentCanvasMarker | undefined;
|
||||
for (let index = this._state.markers.length - 1; index >= 0; index -= 1) {
|
||||
const item = this._state.markers[index];
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
coord.x >= item.x - halfSize
|
||||
&& coord.x <= item.x + halfSize
|
||||
&& coord.y >= item.y - halfSize
|
||||
&& coord.y <= item.y + halfSize
|
||||
) {
|
||||
marker = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let underline: IThreadCommentCanvasUnderline | undefined;
|
||||
if (!marker) {
|
||||
for (let index = this._state.underlines.length - 1; index >= 0; index -= 1) {
|
||||
const item = this._state.underlines[index];
|
||||
if (!item) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
coord.x >= item.left
|
||||
&& coord.x <= item.left + item.width
|
||||
&& Math.abs(coord.y - item.top) <= UNDERLINE_HIT_PADDING / zoomRatio
|
||||
) {
|
||||
underline = item;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
const commentId = marker?.commentId ?? underline?.commentId ?? null;
|
||||
|
||||
this._hitCommentId = commentId;
|
||||
if (commentId !== this._hoveredCommentId) {
|
||||
this._hoveredCommentId = commentId;
|
||||
this.makeDirty(true);
|
||||
}
|
||||
return commentId !== null;
|
||||
}
|
||||
|
||||
override render(ctx: UniverRenderingContext, _bounds: IViewportInfo): this {
|
||||
const zoomRatio = this._getZoomRatio();
|
||||
|
||||
this._state.underlines.forEach((underline) => this._renderUnderline(ctx, underline, zoomRatio, 1));
|
||||
this._state.focusOutlines?.forEach((outline) => this._renderOutline(ctx, outline, zoomRatio));
|
||||
if (this._state.previewUnderline) {
|
||||
this._renderUnderline(ctx, this._state.previewUnderline, zoomRatio, MARKER_PREVIEW_OPACITY);
|
||||
}
|
||||
this._state.markers.forEach((marker) => this._renderMarker(
|
||||
ctx,
|
||||
marker,
|
||||
zoomRatio,
|
||||
this._markerContainsComment(marker, this._hoveredCommentId)
|
||||
|| this._state.focusedCommentIds?.some((commentId) => this._markerContainsComment(marker, commentId))
|
||||
? 1
|
||||
: MARKER_IDLE_OPACITY
|
||||
));
|
||||
if (this._state.previewMarker) {
|
||||
this._renderMarker(ctx, this._state.previewMarker, zoomRatio, MARKER_PREVIEW_OPACITY);
|
||||
}
|
||||
|
||||
this.makeDirty(false);
|
||||
return this;
|
||||
}
|
||||
|
||||
private _getZoomRatio(): number {
|
||||
return Number.isFinite(this._state.zoomRatio) && this._state.zoomRatio > 0
|
||||
? this._state.zoomRatio
|
||||
: 1;
|
||||
}
|
||||
|
||||
private _containsComment(commentId: string): boolean {
|
||||
return this._state.markers.some((marker) => this._markerContainsComment(marker, commentId))
|
||||
|| this._state.underlines.some((underline) => underline.commentId === commentId);
|
||||
}
|
||||
|
||||
private _markerContainsComment(marker: IThreadCommentCanvasMarker, commentId: string | null): boolean {
|
||||
return commentId != null
|
||||
&& (marker.commentId === commentId || marker.commentIds?.includes(commentId) === true);
|
||||
}
|
||||
|
||||
private _renderUnderline(
|
||||
ctx: UniverRenderingContext,
|
||||
underline: Omit<IThreadCommentCanvasUnderline, 'commentId'>,
|
||||
zoomRatio: number,
|
||||
opacity: number
|
||||
): void {
|
||||
ctx.save();
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.strokeStyle = this._state.accentColor;
|
||||
ctx.lineWidth = UNDERLINE_WIDTH / zoomRatio;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(underline.left, underline.top);
|
||||
ctx.lineTo(underline.left + underline.width, underline.top);
|
||||
ctx.stroke();
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private _renderOutline(
|
||||
ctx: UniverRenderingContext,
|
||||
outline: IThreadCommentCanvasOutline,
|
||||
zoomRatio: number
|
||||
): void {
|
||||
const padding = OUTLINE_PADDING / zoomRatio;
|
||||
ctx.save();
|
||||
ctx.strokeStyle = this._state.accentColor;
|
||||
ctx.lineWidth = UNDERLINE_WIDTH / zoomRatio;
|
||||
ctx.strokeRect(
|
||||
outline.left - padding,
|
||||
outline.top - padding,
|
||||
outline.width + padding * 2,
|
||||
outline.height + padding * 2
|
||||
);
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
private _renderMarker(
|
||||
ctx: UniverRenderingContext,
|
||||
marker: Omit<IThreadCommentCanvasMarker, 'commentId'>,
|
||||
zoomRatio: number,
|
||||
opacity: number
|
||||
): void {
|
||||
const size = MARKER_SIZE / zoomRatio;
|
||||
const left = marker.x - size / 2;
|
||||
const top = marker.y - size / 2;
|
||||
const radius = 4 / zoomRatio;
|
||||
const tail = 4 / zoomRatio;
|
||||
|
||||
ctx.save();
|
||||
ctx.globalAlpha = opacity;
|
||||
ctx.fillStyle = this._state.accentColor;
|
||||
ctx.strokeStyle = this._state.outlineColor;
|
||||
ctx.lineWidth = 1.5 / zoomRatio;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(left + radius, top);
|
||||
ctx.lineTo(left + size - radius, top);
|
||||
ctx.quadraticCurveTo(left + size, top, left + size, top + radius);
|
||||
ctx.lineTo(left + size, top + size - tail - radius);
|
||||
ctx.quadraticCurveTo(left + size, top + size - tail, left + size - radius, top + size - tail);
|
||||
ctx.lineTo(left + size / 2 + tail, top + size - tail);
|
||||
ctx.lineTo(left + size / 2, top + size);
|
||||
ctx.lineTo(left + size / 2 - tail, top + size - tail);
|
||||
ctx.lineTo(left + radius, top + size - tail);
|
||||
ctx.quadraticCurveTo(left, top + size - tail, left, top + size - tail - radius);
|
||||
ctx.lineTo(left, top + radius);
|
||||
ctx.quadraticCurveTo(left, top, left + radius, top);
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
ctx.stroke();
|
||||
|
||||
ctx.fillStyle = this._state.foregroundColor;
|
||||
if ((marker.count ?? 1) > 1) {
|
||||
ctx.font = `600 ${11 / zoomRatio}px ${DEFAULT_FONTFACE_PLANE}`;
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.fillText(String(marker.count), marker.x, top + size * 0.42);
|
||||
} else {
|
||||
const dotRadius = 1.2 / zoomRatio;
|
||||
const dotY = top + size * 0.43;
|
||||
[0.32, 0.5, 0.68].forEach((ratio) => {
|
||||
ctx.beginPath();
|
||||
ctx.arc(left + size * ratio, dotY, dotRadius, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
});
|
||||
}
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Injector, IUniverInstanceService, UniverInstanceType, UserManagerService } from '@univerjs/core';
|
||||
import { ThreadCommentAnchorKind } from '@univerjs/thread-comment';
|
||||
import { BehaviorSubject, Subject } from 'rxjs';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ThreadCommentDraftService } from '../thread-comment-draft.service';
|
||||
|
||||
describe('ThreadCommentDraftService', () => {
|
||||
it('moves from placement mode to an anchored draft and can cancel', () => {
|
||||
const injector = new Injector();
|
||||
const unitDisposed$ = new Subject<{ getUnitId: () => string }>();
|
||||
const focused$ = new BehaviorSubject<string | null>('board-1');
|
||||
const currentUser$ = new BehaviorSubject({ userID: 'user-1', name: 'User 1' });
|
||||
injector.add([IUniverInstanceService, { useValue: { focused$, unitDisposed$ } as never }]);
|
||||
injector.add([UserManagerService, {
|
||||
useValue: {
|
||||
currentUser$,
|
||||
getCurrentUser: () => currentUser$.value,
|
||||
} as never,
|
||||
}]);
|
||||
injector.add([ThreadCommentDraftService]);
|
||||
const service = injector.get(ThreadCommentDraftService);
|
||||
service.startPlacement(UniverInstanceType.UNIVER_BOARD, 'board-1');
|
||||
expect(service.placementType).toBe(UniverInstanceType.UNIVER_BOARD);
|
||||
|
||||
service.startPlacement(UniverInstanceType.UNIVER_BOARD, 'board-1');
|
||||
expect(service.placementType).toBeNull();
|
||||
|
||||
service.startPlacement(UniverInstanceType.UNIVER_BOARD, 'board-1');
|
||||
unitDisposed$.next({ getUnitId: () => 'other-board' });
|
||||
expect(service.placementType).toBe(UniverInstanceType.UNIVER_BOARD);
|
||||
focused$.next('board-2');
|
||||
expect(service.placementType).toBeNull();
|
||||
|
||||
focused$.next('board-1');
|
||||
service.startPlacement(UniverInstanceType.UNIVER_BOARD, 'board-1');
|
||||
unitDisposed$.next({ getUnitId: () => 'board-1' });
|
||||
expect(service.placementType).toBeNull();
|
||||
|
||||
service.startPlacement(UniverInstanceType.UNIVER_BOARD, 'board-1');
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
expect(service.placementType).toBeNull();
|
||||
|
||||
service.place({
|
||||
unitId: 'board-1',
|
||||
subUnitId: 'page-1',
|
||||
anchor: { kind: ThreadCommentAnchorKind.BOARD_POSITION, x: 10, y: 20 },
|
||||
});
|
||||
expect(service.placementType).toBeNull();
|
||||
expect(service.draft?.anchor).toEqual({ kind: ThreadCommentAnchorKind.BOARD_POSITION, x: 10, y: 20 });
|
||||
|
||||
unitDisposed$.next({ getUnitId: () => 'other-board' });
|
||||
expect(service.draft).not.toBeNull();
|
||||
unitDisposed$.next({ getUnitId: () => 'board-1' });
|
||||
expect(service.draft).toBeNull();
|
||||
|
||||
service.place({
|
||||
unitId: 'board-2',
|
||||
subUnitId: 'page-1',
|
||||
anchor: { kind: ThreadCommentAnchorKind.BOARD_POSITION, x: 1, y: 2 },
|
||||
});
|
||||
currentUser$.next({ userID: 'user-2', name: 'User 2' });
|
||||
expect(service.draft).toBeNull();
|
||||
|
||||
service.place({
|
||||
unitId: 'board-2',
|
||||
subUnitId: 'page-1',
|
||||
anchor: { kind: ThreadCommentAnchorKind.BOARD_POSITION, x: 3, y: 4 },
|
||||
});
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }));
|
||||
expect(service.draft).toBeNull();
|
||||
|
||||
service.cancel();
|
||||
expect(service.draft).toBeNull();
|
||||
injector.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* 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 { IWorkbookData } from '@univerjs/core';
|
||||
import { LocaleType, Univer, UniverInstanceType } from '@univerjs/core';
|
||||
import { DesktopSidebarService, ISidebarService } from '@univerjs/ui';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { ThreadCommentPanelService } from '../thread-comment-panel.service';
|
||||
|
||||
const workbookData: IWorkbookData = {
|
||||
id: 'panel-service-workbook',
|
||||
appVersion: '3.0.0-alpha',
|
||||
locale: LocaleType.EN_US,
|
||||
name: 'panel service workbook',
|
||||
sheetOrder: ['sheet-1'],
|
||||
styles: {},
|
||||
sheets: {
|
||||
'sheet-1': {
|
||||
id: 'sheet-1',
|
||||
name: 'Sheet 1',
|
||||
cellData: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
describe('ThreadCommentPanelService', () => {
|
||||
let univer: Univer;
|
||||
let service: ThreadCommentPanelService;
|
||||
let sidebarService: ISidebarService;
|
||||
|
||||
beforeEach(() => {
|
||||
univer = new Univer();
|
||||
const injector = univer.__getInjector();
|
||||
injector.add([ISidebarService, { useClass: DesktopSidebarService }]);
|
||||
injector.add([ThreadCommentPanelService]);
|
||||
|
||||
univer.createUnit(UniverInstanceType.UNIVER_SHEET, workbookData);
|
||||
service = injector.get(ThreadCommentPanelService);
|
||||
sidebarService = injector.get(ISidebarService);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
univer.dispose();
|
||||
});
|
||||
|
||||
it('hides the comment panel when the host sidebar closes', () => {
|
||||
const visibleStates: boolean[] = [];
|
||||
service.panelVisible$.subscribe((visible) => visibleStates.push(visible));
|
||||
|
||||
service.setPanelVisible(true);
|
||||
service.setHoveredComment({ unitId: 'book-1', subUnitId: 'sheet-1', commentId: 'c-1' });
|
||||
sidebarService.open({});
|
||||
sidebarService.close();
|
||||
|
||||
expect(service.panelVisible).toBe(false);
|
||||
expect(service.hoveredCommentId).toBeUndefined();
|
||||
expect(visibleStates).toEqual([false, true, false]);
|
||||
});
|
||||
|
||||
it('publishes the active thread comment selected by the user', () => {
|
||||
const activeComments: unknown[] = [];
|
||||
service.activeCommentId$.subscribe((comment) => activeComments.push(comment));
|
||||
|
||||
service.setActiveComment({ unitId: 'book-1', subUnitId: 'sheet-1', commentId: 'c-1', trigger: 'cell' });
|
||||
|
||||
expect(service.activeCommentId).toEqual({ unitId: 'book-1', subUnitId: 'sheet-1', commentId: 'c-1', trigger: 'cell' });
|
||||
expect(activeComments.at(-1)).toEqual({ unitId: 'book-1', subUnitId: 'sheet-1', commentId: 'c-1', trigger: 'cell' });
|
||||
});
|
||||
|
||||
it('publishes the thread comment hovered in the panel', () => {
|
||||
const hoveredComments: unknown[] = [];
|
||||
service.hoveredCommentId$.subscribe((comment) => hoveredComments.push(comment));
|
||||
|
||||
service.setHoveredComment({
|
||||
unitId: 'book-1',
|
||||
subUnitId: 'sheet-1',
|
||||
commentId: 'c-1',
|
||||
trigger: 'panel-hover',
|
||||
});
|
||||
service.setHoveredComment(undefined);
|
||||
|
||||
expect(service.hoveredCommentId).toBeUndefined();
|
||||
expect(hoveredComments).toEqual([
|
||||
undefined,
|
||||
{
|
||||
unitId: 'book-1',
|
||||
subUnitId: 'sheet-1',
|
||||
commentId: 'c-1',
|
||||
trigger: 'panel-hover',
|
||||
},
|
||||
undefined,
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { UniverInstanceType } from '@univerjs/core';
|
||||
import type { IThreadCommentAnchor } from '@univerjs/thread-comment';
|
||||
import { Disposable, Inject, IUniverInstanceService, toDisposable, UserManagerService } from '@univerjs/core';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
export interface IThreadCommentDraft {
|
||||
unitId: string;
|
||||
subUnitId: string;
|
||||
anchor: IThreadCommentAnchor;
|
||||
}
|
||||
|
||||
export class ThreadCommentDraftService extends Disposable {
|
||||
private readonly _placementType$ = new BehaviorSubject<UniverInstanceType | null>(null);
|
||||
private readonly _draft$ = new BehaviorSubject<IThreadCommentDraft | null>(null);
|
||||
private _placementUnitId: string | undefined;
|
||||
private _ownerUserId: string | undefined;
|
||||
|
||||
readonly placementType$ = this._placementType$.asObservable();
|
||||
readonly draft$ = this._draft$.asObservable();
|
||||
|
||||
constructor(
|
||||
@IUniverInstanceService instanceService: IUniverInstanceService,
|
||||
@Inject(UserManagerService) private readonly _userManagerService: UserManagerService
|
||||
) {
|
||||
super();
|
||||
this.disposeWithMe(instanceService.unitDisposed$.subscribe((unit) => {
|
||||
if (this.draft?.unitId === unit.getUnitId() || this._placementUnitId === unit.getUnitId()) {
|
||||
this.cancel();
|
||||
}
|
||||
}));
|
||||
this.disposeWithMe(instanceService.focused$.subscribe((unitId) => {
|
||||
if (unitId && this._placementUnitId && unitId !== this._placementUnitId) {
|
||||
this.cancel();
|
||||
}
|
||||
}));
|
||||
this.disposeWithMe(this._userManagerService.currentUser$.subscribe((user) => {
|
||||
if (this._ownerUserId && user?.userID !== this._ownerUserId) {
|
||||
this.cancel();
|
||||
}
|
||||
}));
|
||||
if (typeof window !== 'undefined') {
|
||||
const cancelPlacement = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && (this.placementType || this.draft)) {
|
||||
this.cancel();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', cancelPlacement);
|
||||
this.disposeWithMe(toDisposable(() => window.removeEventListener('keydown', cancelPlacement)));
|
||||
}
|
||||
}
|
||||
|
||||
get placementType(): UniverInstanceType | null {
|
||||
return this._placementType$.value;
|
||||
}
|
||||
|
||||
get draft(): IThreadCommentDraft | null {
|
||||
return this._draft$.value;
|
||||
}
|
||||
|
||||
startPlacement(type: UniverInstanceType, unitId: string): void {
|
||||
if (this.placementType === type && this._placementUnitId === unitId) {
|
||||
this.cancel();
|
||||
return;
|
||||
}
|
||||
this._ownerUserId = this._userManagerService.getCurrentUser().userID;
|
||||
this._placementUnitId = unitId;
|
||||
this._draft$.next(null);
|
||||
this._placementType$.next(type);
|
||||
}
|
||||
|
||||
place(draft: IThreadCommentDraft): void {
|
||||
this._ownerUserId = this._userManagerService.getCurrentUser().userID;
|
||||
this._placementUnitId = undefined;
|
||||
this._placementType$.next(null);
|
||||
this._draft$.next(draft);
|
||||
}
|
||||
|
||||
cancel(): void {
|
||||
this._ownerUserId = undefined;
|
||||
this._placementUnitId = undefined;
|
||||
this._placementType$.next(null);
|
||||
this._draft$.next(null);
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
this._placementType$.complete();
|
||||
this._draft$.complete();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Nullable } from '@univerjs/core';
|
||||
import { Disposable, Inject } from '@univerjs/core';
|
||||
import { ISidebarService } from '@univerjs/ui';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
export type ActiveCommentInfo = Nullable<{ unitId: string; subUnitId: string; commentId: string; trigger?: string }>;
|
||||
|
||||
export class ThreadCommentPanelService extends Disposable {
|
||||
private _panelVisible = false;
|
||||
private _panelVisible$ = new BehaviorSubject<boolean>(false);
|
||||
|
||||
private _activeCommentId: ActiveCommentInfo;
|
||||
private _activeCommentId$ = new BehaviorSubject<ActiveCommentInfo>(undefined);
|
||||
|
||||
private _hoveredCommentId: ActiveCommentInfo;
|
||||
private _hoveredCommentId$ = new BehaviorSubject<ActiveCommentInfo>(undefined);
|
||||
|
||||
panelVisible$ = this._panelVisible$.asObservable();
|
||||
activeCommentId$ = this._activeCommentId$.asObservable();
|
||||
hoveredCommentId$ = this._hoveredCommentId$.asObservable();
|
||||
|
||||
constructor(
|
||||
@Inject(ISidebarService) private readonly _sidebarService: ISidebarService
|
||||
) {
|
||||
super();
|
||||
this._init();
|
||||
|
||||
this.disposeWithMe(() => {
|
||||
this._activeCommentId$.complete();
|
||||
this._hoveredCommentId$.complete();
|
||||
this._panelVisible$.complete();
|
||||
});
|
||||
}
|
||||
|
||||
private _init() {
|
||||
this.disposeWithMe(
|
||||
this._sidebarService.sidebarOptions$.subscribe((opt) => {
|
||||
if (!opt.visible) {
|
||||
this.setPanelVisible(false);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
get panelVisible() {
|
||||
return this._panelVisible;
|
||||
}
|
||||
|
||||
get activeCommentId() {
|
||||
return this._activeCommentId;
|
||||
}
|
||||
|
||||
get hoveredCommentId() {
|
||||
return this._hoveredCommentId;
|
||||
}
|
||||
|
||||
setPanelVisible(visible: boolean) {
|
||||
this._panelVisible = visible;
|
||||
this._panelVisible$.next(visible);
|
||||
if (!visible) {
|
||||
this.setHoveredComment(undefined);
|
||||
}
|
||||
}
|
||||
|
||||
setActiveComment(commentInfo: ActiveCommentInfo) {
|
||||
this._activeCommentId = commentInfo;
|
||||
this._activeCommentId$.next(commentInfo);
|
||||
}
|
||||
|
||||
setHoveredComment(commentInfo: ActiveCommentInfo) {
|
||||
this._hoveredCommentId = commentInfo;
|
||||
this._hoveredCommentId$.next(commentInfo);
|
||||
}
|
||||
}
|
||||
17
packages/thread-comment-ui/src/types/const.ts
Normal file
17
packages/thread-comment-ui/src/types/const.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export const PLUGIN_NAME = 'UNIVER_THREAD_COMMENT_UI_PLUGIN';
|
||||
262
packages/thread-comment-ui/src/views/ThreadCommentEditor.tsx
Normal file
262
packages/thread-comment-ui/src/views/ThreadCommentEditor.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDocumentBody, IDocumentData, IUser } from '@univerjs/core';
|
||||
import type { Editor, IKeyboardEventConfig } from '@univerjs/docs-ui';
|
||||
import type { IThreadComment } from '@univerjs/thread-comment';
|
||||
import type { LocaleKey } from '../locale/types';
|
||||
import {
|
||||
BuildTextUtils,
|
||||
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
|
||||
ICommandService,
|
||||
LocaleService,
|
||||
Tools,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { ActionRow, Button, clsx } from '@univerjs/design';
|
||||
import { BreakLineCommand, DeleteLeftCommand, DeleteRightCommand, IEditorService, RichTextEditor } from '@univerjs/docs-ui';
|
||||
import { KeyCode, useDependency } from '@univerjs/ui';
|
||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';
|
||||
import { SetActiveCommentOperation } from '../commands/operations/comment.operations';
|
||||
import { focusThreadCommentEditor } from './thread-comment-editor/util';
|
||||
|
||||
export interface IThreadCommentEditorProps {
|
||||
id?: string;
|
||||
comment?: Pick<IThreadComment, 'attachments' | 'text' | 'mentions'>;
|
||||
onSave?: (comment: Pick<IThreadComment, 'attachments' | 'text'>) => boolean | void | Promise<boolean | void>;
|
||||
onCancel?: () => void;
|
||||
autoFocus?: boolean;
|
||||
unitId: string;
|
||||
subUnitId: string;
|
||||
type: UniverInstanceType;
|
||||
editorId: string;
|
||||
ActionRowComponent?: typeof ActionRow;
|
||||
}
|
||||
|
||||
export interface IThreadCommentEditorInstance {
|
||||
reply: (text: IDocumentBody) => void;
|
||||
}
|
||||
|
||||
function getSnapshot(body: IDocumentBody): IDocumentData {
|
||||
return {
|
||||
id: 'd',
|
||||
body,
|
||||
documentStyle: {},
|
||||
};
|
||||
}
|
||||
|
||||
export const ThreadCommentEditor = forwardRef<IThreadCommentEditorInstance, IThreadCommentEditorProps>((props, ref) => {
|
||||
const { comment, onSave, id, onCancel, autoFocus, unitId, type, editorId, ActionRowComponent = ActionRow } = props;
|
||||
const commandService = useDependency(ICommandService);
|
||||
const localeService = useDependency(LocaleService);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const submittingRef = useRef(false);
|
||||
const mountedRef = useRef(true);
|
||||
const editorService = useDependency(IEditorService);
|
||||
const editorRef = useRef<Editor>(null);
|
||||
const rootEditorId = type === UniverInstanceType.UNIVER_DOC ? DOCS_NORMAL_EDITOR_UNIT_ID_KEY : unitId;
|
||||
const [canSubmit, setCanSubmit] = useState(() => (
|
||||
BuildTextUtils.transform.getPlainText(comment?.text?.dataStream ?? '')
|
||||
));
|
||||
|
||||
const keyboardEventConfig: IKeyboardEventConfig = useMemo(() => (
|
||||
{
|
||||
keyCodes: [
|
||||
{ keyCode: KeyCode.ENTER },
|
||||
{ keyCode: KeyCode.BACKSPACE },
|
||||
{ keyCode: KeyCode.DELETE },
|
||||
],
|
||||
handler: (keyCode) => {
|
||||
let commandId: string;
|
||||
switch (keyCode) {
|
||||
case KeyCode.ENTER:
|
||||
commandId = BreakLineCommand.id;
|
||||
break;
|
||||
case KeyCode.BACKSPACE:
|
||||
commandId = DeleteLeftCommand.id;
|
||||
break;
|
||||
case KeyCode.DELETE:
|
||||
commandId = DeleteRightCommand.id;
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
focusThreadCommentEditor(editorService, editorId, editorRef.current);
|
||||
commandService.executeCommand(commandId).then(() => {
|
||||
if (keyCode !== KeyCode.ENTER && mountedRef.current) {
|
||||
const dataStream = editorRef.current?.getDocumentData().body?.dataStream ?? '';
|
||||
setCanSubmit(BuildTextUtils.transform.getPlainText(dataStream));
|
||||
}
|
||||
});
|
||||
},
|
||||
}
|
||||
), [commandService, editorId, editorService]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
reply(text) {
|
||||
if (!editorRef.current) {
|
||||
return;
|
||||
}
|
||||
focusThreadCommentEditor(editorService, editorId, editorRef.current);
|
||||
const documentData = getSnapshot(text);
|
||||
editorRef.current?.setDocumentData(documentData, [{
|
||||
startOffset: documentData.body!.dataStream.length - 2,
|
||||
endOffset: documentData.body!.dataStream.length - 2,
|
||||
collapsed: true,
|
||||
}]);
|
||||
setCanSubmit(BuildTextUtils.transform.getPlainText(documentData.body!.dataStream));
|
||||
setEditing(true);
|
||||
},
|
||||
}));
|
||||
|
||||
const handleSave = async () => {
|
||||
const currentEditor = editorRef.current;
|
||||
if (!currentEditor || submittingRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newText = Tools.deepClone(currentEditor.getDocumentData().body);
|
||||
submittingRef.current = true;
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const success = await onSave?.({
|
||||
...comment,
|
||||
text: newText!,
|
||||
});
|
||||
if (success === false) {
|
||||
return;
|
||||
}
|
||||
if (!mountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentEditor.blur();
|
||||
currentEditor.replaceText('', false);
|
||||
currentEditor.setSelectionRanges([], false);
|
||||
setCanSubmit('');
|
||||
setEditing(false);
|
||||
} catch {
|
||||
return;
|
||||
} finally {
|
||||
submittingRef.current = false;
|
||||
if (mountedRef.current) {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditorMouseDown = () => {
|
||||
focusThreadCommentEditor(editorService, editorId, editorRef.current);
|
||||
setEditing(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFocus) {
|
||||
return;
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
focusThreadCommentEditor(editorService, editorId, editorRef.current);
|
||||
});
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}, [autoFocus, editorId, editorService]);
|
||||
|
||||
return (
|
||||
<div onClick={(e) => e.preventDefault()}>
|
||||
<div onMouseDown={handleEditorMouseDown}>
|
||||
{/* Comments are hosted by Sheets/Docs, so editing should not replace the host's global focus. */}
|
||||
<RichTextEditor
|
||||
className="univer-w-full"
|
||||
editorRef={editorRef}
|
||||
editorId={editorId}
|
||||
preserveHostFocus
|
||||
autoFocus={autoFocus}
|
||||
keyboardEventConfig={keyboardEventConfig}
|
||||
placeholder={localeService.t<LocaleKey>('thread-comment-ui.editor.placeholder')}
|
||||
initialValue={comment?.text && getSnapshot(comment.text)}
|
||||
onChange={(data) => setCanSubmit(BuildTextUtils.transform.getPlainText(data.body?.dataStream ?? ''))}
|
||||
onFocusChange={(isFocus) => isFocus && setEditing(isFocus)}
|
||||
isSingle={false}
|
||||
maxHeight={64}
|
||||
onClickOutside={() => {
|
||||
setTimeout(() => {
|
||||
editorService.focus(rootEditorId);
|
||||
}, 30);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{editing
|
||||
? (
|
||||
<ActionRowComponent
|
||||
className="univer-mt-3 univer-flex univer-flex-row univer-justify-end univer-gap-2"
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const currentEditor = editorRef.current;
|
||||
currentEditor?.blur();
|
||||
currentEditor?.replaceText('', false);
|
||||
currentEditor?.setSelectionRanges([], false);
|
||||
setCanSubmit('');
|
||||
onCancel?.();
|
||||
setEditing(false);
|
||||
commandService.executeCommand(SetActiveCommentOperation.id);
|
||||
}}
|
||||
>
|
||||
{localeService.t<LocaleKey>('thread-comment-ui.editor.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
disabled={!canSubmit || submitting}
|
||||
onClick={handleSave}
|
||||
>
|
||||
{localeService.t<LocaleKey>(id ? 'thread-comment-ui.editor.save' : 'thread-comment-ui.editor.reply')}
|
||||
</Button>
|
||||
</ActionRowComponent>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export const ThreadCommentSuggestion = ({ active, user }: { active: boolean; user: IUser }) => (
|
||||
<div
|
||||
className={clsx(`
|
||||
univer-flex univer-items-center univer-text-sm univer-text-gray-900
|
||||
dark:!univer-text-gray-0
|
||||
`, {
|
||||
'univer-bg-gray-50 dark:!univer-bg-gray-900': active,
|
||||
})}
|
||||
>
|
||||
<img
|
||||
className="univer-mr-1.5 univer-size-6 univer-rounded-full"
|
||||
src={user.avatar}
|
||||
draggable={false}
|
||||
/>
|
||||
<span>{user.name}</span>
|
||||
</div>
|
||||
);
|
||||
362
packages/thread-comment-ui/src/views/ThreadCommentPanel.tsx
Normal file
362
packages/thread-comment-ui/src/views/ThreadCommentPanel.tsx
Normal file
@@ -0,0 +1,362 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Nullable } from '@univerjs/core';
|
||||
import type { IThreadComment } from '@univerjs/thread-comment';
|
||||
import type { Observable } from 'rxjs';
|
||||
import type { LocaleKey } from '../locale/types';
|
||||
import type { ThreadCommentPanelSection } from './thread-comment-panel/util';
|
||||
import type { IThreadCommentTreeProps } from './ThreadCommentTree';
|
||||
import { ICommandService, LocaleService, UniverInstanceType, UserManagerService } from '@univerjs/core';
|
||||
import { ActionRow, Button, Select } from '@univerjs/design';
|
||||
import { IncreaseIcon } from '@univerjs/icons';
|
||||
import { ThreadCommentModel } from '@univerjs/thread-comment';
|
||||
import { useDependency, useObservable } from '@univerjs/ui';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { SetActiveCommentOperation } from '../commands/operations/comment.operations';
|
||||
import { ThreadCommentPanelService } from '../services/thread-comment-panel.service';
|
||||
import { getThreadCommentPanelItemKey, isSameThreadCommentTarget, shouldClearThreadCommentTarget } from './thread-comment-panel/util';
|
||||
import { ThreadCommentTree, ThreadCommentTreeLocation } from './ThreadCommentTree';
|
||||
|
||||
export interface IThreadCommentPanelProps {
|
||||
unitId: string;
|
||||
subUnitId$: Observable<string | undefined>;
|
||||
type: UniverInstanceType;
|
||||
onAdd: () => void;
|
||||
getSubUnitName: (subUnitId: string) => string;
|
||||
onResolve?: (id: string, resolved: boolean) => void;
|
||||
sortComments?: (comments: IThreadComment[]) => IThreadComment[];
|
||||
onItemLeave?: (comment: IThreadComment) => void;
|
||||
onItemEnter?: (comment: IThreadComment) => void;
|
||||
disableAdd?: boolean;
|
||||
tempComment?: Nullable<IThreadComment>;
|
||||
onAddComment?: IThreadCommentTreeProps['onAddComment'];
|
||||
onDeleteComment?: IThreadCommentTreeProps['onDeleteComment'];
|
||||
onAfterDeleteComment?: IThreadCommentTreeProps['onAfterDeleteComment'];
|
||||
showComments?: string[];
|
||||
formatRef?: (comment: IThreadComment) => string;
|
||||
onTempCommentClose?: () => void;
|
||||
autoFocusActiveComment?: boolean;
|
||||
}
|
||||
|
||||
interface IThreadCommentWithUsers extends IThreadComment {
|
||||
users: Set<string>;
|
||||
}
|
||||
|
||||
export const ThreadCommentPanel = (props: IThreadCommentPanelProps) => {
|
||||
const {
|
||||
unitId,
|
||||
subUnitId$,
|
||||
type,
|
||||
onAdd,
|
||||
getSubUnitName,
|
||||
onResolve,
|
||||
sortComments,
|
||||
onItemLeave,
|
||||
onItemEnter,
|
||||
disableAdd,
|
||||
tempComment,
|
||||
onAddComment,
|
||||
onDeleteComment,
|
||||
onAfterDeleteComment,
|
||||
showComments,
|
||||
formatRef,
|
||||
onTempCommentClose,
|
||||
autoFocusActiveComment = false,
|
||||
} = props;
|
||||
const [unit, setUnit] = useState('all');
|
||||
const [status, setStatus] = useState('all');
|
||||
const localeService = useDependency(LocaleService);
|
||||
const userService = useDependency(UserManagerService);
|
||||
const threadCommentModel = useDependency(ThreadCommentModel);
|
||||
const panelService = useDependency(ThreadCommentPanelService);
|
||||
const activeCommentId = useObservable(panelService.activeCommentId$);
|
||||
useObservable(threadCommentModel.commentUpdate$);
|
||||
const unitComments = threadCommentModel.getUnit(unitId);
|
||||
const scopedTempComment = tempComment?.unitId === unitId ? tempComment : null;
|
||||
const activeExists = activeCommentId
|
||||
? Boolean(
|
||||
threadCommentModel.getComment(activeCommentId.unitId, activeCommentId.subUnitId, activeCommentId.commentId) ||
|
||||
(scopedTempComment && isSameThreadCommentTarget(activeCommentId, scopedTempComment))
|
||||
)
|
||||
: false;
|
||||
const shouldClearActiveTarget = shouldClearThreadCommentTarget(activeCommentId, unitId, activeExists);
|
||||
const commandService = useDependency(ICommandService);
|
||||
const subUnitId = useObservable(subUnitId$);
|
||||
const shouldScrollRef = useRef(true);
|
||||
const location = ThreadCommentTreeLocation.PANEL;
|
||||
const currentUser = useObservable(userService.currentUser$);
|
||||
const comments = useMemo(() => {
|
||||
const allComments = unit === 'all' && type !== UniverInstanceType.UNIVER_SLIDE
|
||||
? unitComments
|
||||
: unitComments.filter((i) => i.subUnitId === subUnitId);
|
||||
|
||||
const sort = sortComments ?? ((a) => a);
|
||||
const res: IThreadCommentWithUsers[] = allComments.map((i) => ({ ...i.root, children: i.children ?? [], users: i.relativeUsers }));
|
||||
|
||||
if (showComments) {
|
||||
const map = new Map<string, IThreadCommentWithUsers>();
|
||||
res.forEach((comment) => {
|
||||
map.set(comment.id, comment);
|
||||
});
|
||||
|
||||
return [...showComments, ''].map((id) => map.get(id)).filter(Boolean) as IThreadCommentWithUsers[];
|
||||
} else {
|
||||
return sort(res) as IThreadCommentWithUsers[];
|
||||
}
|
||||
}, [showComments, unit, unitComments, sortComments, subUnitId, type]);
|
||||
|
||||
const commentsSorted = useMemo(() => [
|
||||
...comments.filter((comment) => !comment.resolved),
|
||||
...comments.filter((comment) => comment.resolved),
|
||||
], [comments]);
|
||||
|
||||
const statuedComments = useMemo(() => {
|
||||
if (status === 'resolved') {
|
||||
return commentsSorted.filter((comment) => comment.resolved);
|
||||
}
|
||||
|
||||
if (status === 'unsolved') {
|
||||
return commentsSorted.filter((comment) => !comment.resolved);
|
||||
}
|
||||
if (status === 'concern_me') {
|
||||
if (!currentUser?.userID) {
|
||||
return commentsSorted;
|
||||
}
|
||||
|
||||
return commentsSorted.filter((comment) => comment?.users.has(currentUser.userID));
|
||||
}
|
||||
|
||||
return commentsSorted;
|
||||
}, [commentsSorted, currentUser?.userID, status]);
|
||||
|
||||
const renderComments = scopedTempComment
|
||||
? [scopedTempComment, ...statuedComments]
|
||||
: statuedComments;
|
||||
|
||||
const unSolvedComments = renderComments.filter((comment) => !comment.resolved);
|
||||
const solvedComments = renderComments.filter((comment) => comment.resolved);
|
||||
|
||||
const isFiltering = status !== 'all' || unit !== 'all';
|
||||
|
||||
const onReset = () => {
|
||||
setStatus('all');
|
||||
setUnit('all');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldClearActiveTarget) {
|
||||
panelService.setActiveComment(undefined);
|
||||
}
|
||||
}, [panelService, shouldClearActiveTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tempComment && (tempComment.unitId !== unitId || (subUnitId && tempComment.subUnitId !== subUnitId))) {
|
||||
onTempCommentClose?.();
|
||||
}
|
||||
}, [onTempCommentClose, subUnitId, tempComment, unitId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeCommentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const activeComment = panelService.activeCommentId;
|
||||
const target = event.target;
|
||||
if (event.button !== 0 || !activeComment || !(target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeElement = document.getElementById(
|
||||
`${location}-${activeComment.unitId}-${activeComment.subUnitId}-${activeComment.commentId}`
|
||||
);
|
||||
if (
|
||||
activeElement?.contains(target) ||
|
||||
target.closest('button, input, textarea, select, a, [contenteditable="true"], [role="menu"], [role="menuitem"], [role="option"], [role="listbox"], [role="combobox"], [role="separator"]')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
commandService.executeCommand(SetActiveCommentOperation.id);
|
||||
if (scopedTempComment && isSameThreadCommentTarget(activeComment, scopedTempComment)) {
|
||||
onTempCommentClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
return () => document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
}, [activeCommentId, commandService, location, onTempCommentClose, panelService, scopedTempComment]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeCommentId) {
|
||||
return;
|
||||
}
|
||||
if (!shouldScrollRef.current) {
|
||||
shouldScrollRef.current = true;
|
||||
return;
|
||||
}
|
||||
const { unitId, subUnitId, commentId } = activeCommentId;
|
||||
const id = `${location}-${unitId}-${subUnitId}-${commentId}`;
|
||||
document.getElementById(id)?.scrollIntoView({ block: 'center' });
|
||||
}, [activeCommentId, location]);
|
||||
|
||||
const renderComment = (section: ThreadCommentPanelSection) => (comment: IThreadComment, index: number) => (
|
||||
<ThreadCommentTree
|
||||
full
|
||||
location={location}
|
||||
getSubUnitName={getSubUnitName}
|
||||
key={getThreadCommentPanelItemKey(comment, index, section)}
|
||||
id={comment.id}
|
||||
unitId={comment.unitId}
|
||||
subUnitId={comment.subUnitId}
|
||||
refStr={comment.ref}
|
||||
displayRef={formatRef?.(comment)}
|
||||
type={type}
|
||||
showEdit={!comment.id || isSameThreadCommentTarget(activeCommentId, comment)}
|
||||
showHighlight={isSameThreadCommentTarget(activeCommentId, comment)}
|
||||
autoFocus={autoFocusActiveComment && isSameThreadCommentTarget(activeCommentId, comment)}
|
||||
onClick={() => {
|
||||
shouldScrollRef.current = false;
|
||||
if (!comment.resolved) {
|
||||
commandService.executeCommand(
|
||||
SetActiveCommentOperation.id,
|
||||
{
|
||||
unitId: comment.unitId,
|
||||
subUnitId: comment.subUnitId,
|
||||
commentId: comment.id,
|
||||
temp: false,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
commandService.executeCommand(SetActiveCommentOperation.id);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
panelService.setHoveredComment({
|
||||
unitId: comment.unitId,
|
||||
subUnitId: comment.subUnitId,
|
||||
commentId: comment.id,
|
||||
trigger: 'panel-hover',
|
||||
});
|
||||
onItemEnter?.(comment);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (isSameThreadCommentTarget(panelService.hoveredCommentId, comment)) {
|
||||
panelService.setHoveredComment(undefined);
|
||||
}
|
||||
onItemLeave?.(comment);
|
||||
}}
|
||||
onAddComment={onAddComment}
|
||||
onDeleteComment={onDeleteComment}
|
||||
onAfterDeleteComment={onAfterDeleteComment}
|
||||
onResolve={(resolved: boolean) => onResolve?.(comment.id, resolved)}
|
||||
onClose={!comment.id ? onTempCommentClose : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="univer-flex univer-min-h-full univer-flex-col univer-pb-3">
|
||||
<div className="univer-mt-3 univer-flex univer-flex-row univer-justify-between">
|
||||
{type === UniverInstanceType.UNIVER_SHEET
|
||||
? (
|
||||
<Select
|
||||
borderless
|
||||
value={unit}
|
||||
options={[
|
||||
{
|
||||
value: 'current',
|
||||
label: localeService.t<LocaleKey>('thread-comment-ui.filter.sheet.current'),
|
||||
},
|
||||
{
|
||||
value: 'all',
|
||||
label: localeService.t<LocaleKey>('thread-comment-ui.filter.sheet.all'),
|
||||
},
|
||||
]}
|
||||
onChange={setUnit}
|
||||
/>
|
||||
)
|
||||
: null}
|
||||
<Select
|
||||
borderless
|
||||
value={status}
|
||||
options={[
|
||||
{
|
||||
value: 'all',
|
||||
label: localeService.t<LocaleKey>('thread-comment-ui.filter.status.all'),
|
||||
},
|
||||
{
|
||||
value: 'resolved',
|
||||
label: localeService.t<LocaleKey>('thread-comment-ui.filter.status.resolved'),
|
||||
},
|
||||
{
|
||||
value: 'unsolved',
|
||||
label: localeService.t<LocaleKey>('thread-comment-ui.filter.status.unsolved'),
|
||||
},
|
||||
{
|
||||
value: 'concern_me',
|
||||
label: localeService.t<LocaleKey>('thread-comment-ui.filter.status.concernMe'),
|
||||
},
|
||||
]}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
</div>
|
||||
{renderComments.length === 0
|
||||
? (
|
||||
<div
|
||||
className={`
|
||||
univer-flex univer-flex-1 univer-flex-col univer-items-center univer-justify-center
|
||||
univer-text-sm univer-text-gray-600
|
||||
dark:!univer-text-gray-200
|
||||
`}
|
||||
>
|
||||
{localeService.t<LocaleKey>('thread-comment-ui.panel.empty')}
|
||||
{isFiltering
|
||||
? (
|
||||
<ActionRow className="univer-mt-2 univer-flex univer-flex-row">
|
||||
<Button onClick={onReset}>
|
||||
{localeService.t<LocaleKey>('thread-comment-ui.panel.reset')}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
)
|
||||
: !disableAdd
|
||||
? (
|
||||
<ActionRow
|
||||
className="univer-mt-2 univer-flex univer-flex-row"
|
||||
>
|
||||
<Button onClick={onAdd}>
|
||||
<IncreaseIcon className="univer-mr-1.5" />
|
||||
{localeService.t<LocaleKey>('thread-comment-ui.panel.addComment')}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="univer-mt-3 univer-flex univer-flex-col univer-gap-3">
|
||||
{unSolvedComments.map(renderComment('unsolved'))}
|
||||
{solvedComments.length > 0 && (
|
||||
<div className="univer-text-xs">
|
||||
{localeService.t<LocaleKey>('thread-comment-ui.panel.solved')}
|
||||
</div>
|
||||
)}
|
||||
{solvedComments.map(renderComment('solved'))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
714
packages/thread-comment-ui/src/views/ThreadCommentTree.tsx
Normal file
714
packages/thread-comment-ui/src/views/ThreadCommentTree.tsx
Normal file
@@ -0,0 +1,714 @@
|
||||
/**
|
||||
* 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 { IUser, LocaleType, UniverInstanceType } from '@univerjs/core';
|
||||
import type { IAddCommentCommandParams, IThreadComment, IUpdateCommentCommandParams } from '@univerjs/thread-comment';
|
||||
import type { IUniverUIConfig } from '@univerjs/ui';
|
||||
import type { LocaleKey } from '../locale/types';
|
||||
import type { IThreadCommentEditorInstance } from './ThreadCommentEditor';
|
||||
import {
|
||||
dateKit,
|
||||
generateRandomId,
|
||||
ICommandService,
|
||||
LOCALE_META,
|
||||
LocaleService,
|
||||
RegionService,
|
||||
UserManagerService,
|
||||
} from '@univerjs/core';
|
||||
import { borderClassName, clsx, Dropdown, scrollbarClassName, Tooltip } from '@univerjs/design';
|
||||
import { DeleteIcon, MoreHorizontalIcon, ReplyToCommentIcon, SuccessIcon, SuccessOutlineIcon } from '@univerjs/icons';
|
||||
import {
|
||||
AddCommentCommand,
|
||||
DeleteCommentCommand,
|
||||
DeleteCommentTreeCommand,
|
||||
getDT,
|
||||
ResolveCommentCommand,
|
||||
ThreadCommentModel,
|
||||
UpdateCommentCommand,
|
||||
} from '@univerjs/thread-comment';
|
||||
import { UI_PLUGIN_CONFIG_KEY, useConfigValue, useDependency, useEvent, useObservable } from '@univerjs/ui';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { debounceTime, map, of, startWith } from 'rxjs';
|
||||
import { SetActiveCommentOperation } from '../commands/operations/comment.operations';
|
||||
import { transformDocument2TextNodes, transformTextNodes2Document } from './thread-comment-editor/util';
|
||||
import { getThreadCommentEditorId } from './thread-comment-tree/util';
|
||||
import { ThreadCommentEditor } from './ThreadCommentEditor';
|
||||
|
||||
export enum ThreadCommentTreeLocation {
|
||||
CELL = 'CELL',
|
||||
PANEL = 'PANEL',
|
||||
};
|
||||
|
||||
export interface IThreadCommentTreeProps {
|
||||
full?: boolean;
|
||||
id?: string;
|
||||
unitId: string;
|
||||
subUnitId: string;
|
||||
type: UniverInstanceType;
|
||||
refStr?: string;
|
||||
displayRef?: string;
|
||||
showEdit?: boolean;
|
||||
onClick?: () => void;
|
||||
showHighlight?: boolean;
|
||||
onClose?: () => void;
|
||||
getSubUnitName: (subUnitId: string) => string;
|
||||
location: ThreadCommentTreeLocation;
|
||||
autoFocus?: boolean;
|
||||
onMouseEnter?: () => void;
|
||||
onMouseLeave?: () => void;
|
||||
onAddComment?: (comment: IThreadComment) => boolean | void | Promise<boolean | void>;
|
||||
onDeleteComment?: (comment: IThreadComment) => boolean;
|
||||
onAfterDeleteComment?: (comment: IThreadComment) => void | Promise<void>;
|
||||
onResolve?: (resolved: boolean) => void;
|
||||
style?: React.CSSProperties;
|
||||
DropdownComponent?: typeof Dropdown;
|
||||
EditorComponent?: typeof ThreadCommentEditor;
|
||||
}
|
||||
|
||||
export interface IThreadCommentItemProps {
|
||||
item: IThreadComment;
|
||||
unitId: string;
|
||||
subUnitId: string;
|
||||
onEditingChange?: (editing: boolean) => void;
|
||||
editing?: boolean;
|
||||
onClick?: () => void;
|
||||
resolved?: boolean;
|
||||
onReply: (user: IUser | undefined) => void;
|
||||
isRoot?: boolean;
|
||||
onClose?: () => void;
|
||||
onDeleteComment?: (comment: IThreadComment) => boolean;
|
||||
onAfterDeleteComment?: (comment: IThreadComment) => void | Promise<void>;
|
||||
type: UniverInstanceType;
|
||||
threadCommentEditorId: string;
|
||||
DropdownComponent: typeof Dropdown;
|
||||
EditorComponent: typeof ThreadCommentEditor;
|
||||
}
|
||||
|
||||
const MOCK_ID = '__mock__';
|
||||
|
||||
function formatCommentDateTime(value: string, region: LocaleType): string {
|
||||
const date = dateKit(value);
|
||||
const regionTag = LOCALE_META[region].tag;
|
||||
|
||||
return date.formatIntl(regionTag, {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
numberingSystem: 'latn',
|
||||
});
|
||||
}
|
||||
|
||||
const ThreadCommentItem = (props: IThreadCommentItemProps) => {
|
||||
const {
|
||||
item,
|
||||
unitId,
|
||||
subUnitId,
|
||||
editing,
|
||||
onEditingChange,
|
||||
onReply,
|
||||
resolved,
|
||||
isRoot,
|
||||
onClose,
|
||||
onDeleteComment,
|
||||
onAfterDeleteComment,
|
||||
type,
|
||||
threadCommentEditorId,
|
||||
DropdownComponent,
|
||||
EditorComponent,
|
||||
} = props;
|
||||
const commandService = useDependency(ICommandService);
|
||||
const localeService = useDependency(LocaleService);
|
||||
const regionService = useDependency(RegionService);
|
||||
const userManagerService = useDependency(UserManagerService);
|
||||
const user = userManagerService.getUser(item.personId);
|
||||
const currentUser = useObservable(userManagerService.currentUser$);
|
||||
const isCommentBySelf = currentUser?.userID === item.personId;
|
||||
const isMock = item.id === MOCK_ID;
|
||||
const [showReply, setShowReply] = useState(false);
|
||||
const uiConfig = useConfigValue<IUniverUIConfig>(UI_PLUGIN_CONFIG_KEY);
|
||||
const avatarFallback = uiConfig?.avatarFallback;
|
||||
const currentRegion = useObservable(regionService.currentRegion$, regionService.getCurrentRegion());
|
||||
const direction = useObservable(localeService.direction$, localeService.getDirection());
|
||||
const dateText = isMock ? null : formatCommentDateTime(item.dT, currentRegion);
|
||||
|
||||
const handleDeleteItem = async () => {
|
||||
if (onDeleteComment?.(item) === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await commandService.executeCommand(
|
||||
isRoot ? DeleteCommentTreeCommand.id : DeleteCommentCommand.id,
|
||||
{
|
||||
unitId,
|
||||
subUnitId,
|
||||
commentId: item.id,
|
||||
}
|
||||
);
|
||||
if (success) {
|
||||
try {
|
||||
await onAfterDeleteComment?.(item);
|
||||
} catch {
|
||||
// The comment is already deleted; auxiliary anchor cleanup must not reopen it.
|
||||
}
|
||||
if (isRoot) {
|
||||
onClose?.();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keep the thread visible so the user can retry after the remote failure.
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
univer-relative univer-mb-3 univer-pl-[30px]
|
||||
rtl:univer-pl-0 rtl:univer-pr-[30px]
|
||||
"
|
||||
onMouseLeave={() => setShowReply(false)}
|
||||
onMouseEnter={() => setShowReply(true)}
|
||||
>
|
||||
<div
|
||||
className={`
|
||||
univer-absolute univer-left-0 univer-top-0 univer-size-6 univer-rounded-full univer-bg-cover
|
||||
univer-bg-center univer-bg-no-repeat
|
||||
rtl:univer-left-auto rtl:univer-right-0
|
||||
`}
|
||||
style={{
|
||||
backgroundImage: `url(${user?.avatar || avatarFallback})`,
|
||||
}}
|
||||
/>
|
||||
{user
|
||||
? (
|
||||
<div className="univer-mb-1 univer-flex univer-h-6 univer-items-center univer-justify-between">
|
||||
<div className="univer-text-sm univer-font-medium univer-leading-5">
|
||||
{user?.name || ' '}
|
||||
</div>
|
||||
<div>
|
||||
{(isMock || resolved)
|
||||
? null
|
||||
: (
|
||||
showReply && user
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={localeService.t<LocaleKey>('thread-comment-ui.editor.reply')}
|
||||
className={`
|
||||
univer-ml-1 univer-inline-flex univer-size-6 univer-cursor-pointer
|
||||
univer-items-center univer-justify-center univer-rounded-sm
|
||||
univer-border-0 univer-bg-transparent univer-p-0 univer-text-base
|
||||
hover:univer-bg-gray-50
|
||||
rtl:univer-ml-0 rtl:univer-mr-1
|
||||
dark:hover:!univer-bg-gray-800
|
||||
`}
|
||||
onClick={() => onReply(user)}
|
||||
>
|
||||
<ReplyToCommentIcon />
|
||||
</button>
|
||||
)
|
||||
: null
|
||||
)}
|
||||
{isCommentBySelf && !isMock && !resolved
|
||||
? (
|
||||
<DropdownComponent
|
||||
overlay={(
|
||||
<div dir={direction} className="univer-rounded-lg">
|
||||
<ul
|
||||
className={`
|
||||
univer-m-0 univer-box-border univer-grid univer-list-none
|
||||
univer-p-1.5 univer-text-sm
|
||||
rtl:univer-text-right
|
||||
[&_button]:univer-block [&_button]:univer-w-full
|
||||
[&_button]:univer-cursor-pointer [&_button]:univer-rounded
|
||||
[&_button]:univer-border-0 [&_button]:univer-bg-transparent
|
||||
[&_button]:univer-px-2 [&_button]:univer-py-1.5
|
||||
[&_button]:univer-text-left [&_button]:univer-transition-colors
|
||||
rtl:[&_button]:univer-text-right
|
||||
`}
|
||||
>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:univer-bg-gray-200"
|
||||
onClick={() => onEditingChange?.(true)}
|
||||
>
|
||||
{localeService.t<LocaleKey>('thread-comment-ui.item.edit')}
|
||||
</button>
|
||||
</li>
|
||||
<li>
|
||||
<button
|
||||
type="button"
|
||||
className="hover:univer-bg-gray-200"
|
||||
onClick={handleDeleteItem}
|
||||
>
|
||||
{localeService.t<LocaleKey>('thread-comment-ui.item.delete')}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={localeService.t<LocaleKey>('thread-comment-ui.item.more')}
|
||||
className={`
|
||||
univer-ml-1 univer-inline-flex univer-size-6 univer-cursor-pointer
|
||||
univer-items-center univer-justify-center univer-rounded-sm
|
||||
univer-border-0 univer-bg-transparent univer-p-0 univer-text-base
|
||||
hover:univer-bg-gray-50
|
||||
rtl:univer-ml-0 rtl:univer-mr-1
|
||||
dark:hover:!univer-bg-gray-800
|
||||
`}
|
||||
>
|
||||
<MoreHorizontalIcon />
|
||||
</button>
|
||||
</DropdownComponent>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
: null}
|
||||
{dateText && (
|
||||
<time
|
||||
className={`
|
||||
univer-mb-1 univer-block univer-text-xs/normal univer-text-gray-600
|
||||
rtl:univer-text-right
|
||||
dark:!univer-text-gray-200
|
||||
`}
|
||||
>
|
||||
<bdo dir="ltr">{dateText}</bdo>
|
||||
</time>
|
||||
)}
|
||||
{editing
|
||||
? (
|
||||
<EditorComponent
|
||||
type={type}
|
||||
id={item.id}
|
||||
comment={item}
|
||||
onCancel={() => onEditingChange?.(false)}
|
||||
autoFocus
|
||||
unitId={unitId}
|
||||
subUnitId={subUnitId}
|
||||
editorId={threadCommentEditorId}
|
||||
onSave={async ({ text, attachments }) => {
|
||||
const success = await commandService.executeCommand(
|
||||
UpdateCommentCommand.id,
|
||||
{
|
||||
unitId,
|
||||
subUnitId,
|
||||
payload: {
|
||||
commentId: item.id,
|
||||
text,
|
||||
attachments,
|
||||
},
|
||||
} as IUpdateCommentCommandParams
|
||||
);
|
||||
if (success) {
|
||||
onEditingChange?.(false);
|
||||
}
|
||||
return success;
|
||||
}}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<div
|
||||
className={`
|
||||
univer-text-sm univer-text-gray-900
|
||||
dark:!univer-text-gray-0
|
||||
`}
|
||||
>
|
||||
{transformDocument2TextNodes(item.text).map((paragraph, paragraphIndex) => {
|
||||
let offset = 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={item.text.paragraphs?.[paragraphIndex]?.paragraphId}
|
||||
className="univer-break-words"
|
||||
>
|
||||
{paragraph.map((node) => {
|
||||
const key = offset;
|
||||
offset += node.type === 'mention' ? node.content.label.length : node.content.length;
|
||||
|
||||
switch (node.type) {
|
||||
case 'mention':
|
||||
return (
|
||||
<a className="univer-text-primary-600" key={key}>
|
||||
{node.content.label}
|
||||
{' '}
|
||||
</a>
|
||||
);
|
||||
default:
|
||||
return node.content;
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ThreadCommentTree = (props: IThreadCommentTreeProps) => {
|
||||
const {
|
||||
id,
|
||||
unitId,
|
||||
subUnitId,
|
||||
refStr,
|
||||
displayRef,
|
||||
showEdit = true,
|
||||
onClick,
|
||||
showHighlight,
|
||||
onClose,
|
||||
getSubUnitName,
|
||||
location,
|
||||
autoFocus,
|
||||
onMouseEnter,
|
||||
onMouseLeave,
|
||||
onAddComment,
|
||||
onDeleteComment,
|
||||
onAfterDeleteComment,
|
||||
onResolve,
|
||||
type,
|
||||
style,
|
||||
full,
|
||||
DropdownComponent = Dropdown,
|
||||
EditorComponent = ThreadCommentEditor,
|
||||
} = props;
|
||||
const threadCommentModel = useDependency(ThreadCommentModel);
|
||||
const [isHover, setIsHover] = useState(false);
|
||||
const [editingId, setEditingId] = useState('');
|
||||
const updte$ = useMemo(() => threadCommentModel.commentUpdate$.pipe(debounceTime(16)), [threadCommentModel]);
|
||||
const comments = useObservable(
|
||||
() => id
|
||||
? updte$.pipe(
|
||||
map(() => threadCommentModel.getCommentWithChildren(unitId, subUnitId, id)),
|
||||
startWith(threadCommentModel.getCommentWithChildren(unitId, subUnitId, id))
|
||||
)
|
||||
: of(null),
|
||||
null,
|
||||
false,
|
||||
[id, subUnitId, threadCommentModel, unitId, updte$]
|
||||
);
|
||||
const commandService = useDependency(ICommandService);
|
||||
const localeService = useDependency(LocaleService);
|
||||
const userManagerService = useDependency(UserManagerService);
|
||||
const resolved = comments?.root.resolved;
|
||||
const currentUser = useObservable(userManagerService.currentUser$);
|
||||
const editorRef = useRef<IThreadCommentEditorInstance>(null);
|
||||
const fallbackEditorId = useMemo(() => generateRandomId(6), []);
|
||||
const renderComments: IThreadComment[] = [
|
||||
...comments ?
|
||||
[comments.root] :
|
||||
// mock empty comment
|
||||
[{
|
||||
id: MOCK_ID,
|
||||
text: {
|
||||
dataStream: '\n\r',
|
||||
},
|
||||
personId: currentUser?.userID ?? '',
|
||||
ref: refStr ?? '',
|
||||
dT: '',
|
||||
unitId,
|
||||
subUnitId,
|
||||
threadId: '',
|
||||
}],
|
||||
...(comments?.children ?? []) as IThreadComment[],
|
||||
];
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
const handleMouseLeave = useEvent(onMouseLeave);
|
||||
const handleResolve: React.MouseEventHandler<HTMLButtonElement> = async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
const success = await commandService.executeCommand(ResolveCommentCommand.id, {
|
||||
unitId,
|
||||
subUnitId,
|
||||
commentId: id,
|
||||
resolved: !resolved,
|
||||
});
|
||||
if (!success) {
|
||||
return;
|
||||
}
|
||||
|
||||
await commandService.executeCommand(SetActiveCommentOperation.id, resolved
|
||||
? { unitId, subUnitId, commentId: id }
|
||||
: undefined);
|
||||
onResolve?.(!resolved);
|
||||
} catch {
|
||||
// Keep the current resolved state when the remote write fails.
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteRoot: React.MouseEventHandler<HTMLButtonElement> = async (e) => {
|
||||
e.stopPropagation();
|
||||
const root = comments?.root;
|
||||
if (!root || onDeleteComment?.(root) === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const success = await commandService.executeCommand(
|
||||
DeleteCommentTreeCommand.id,
|
||||
{
|
||||
unitId,
|
||||
subUnitId,
|
||||
commentId: id,
|
||||
}
|
||||
);
|
||||
if (success) {
|
||||
try {
|
||||
await onAfterDeleteComment?.(root);
|
||||
} catch {
|
||||
// The comment is already deleted; auxiliary anchor cleanup must not reopen it.
|
||||
}
|
||||
await commandService.executeCommand(SetActiveCommentOperation.id);
|
||||
onClose?.();
|
||||
}
|
||||
} catch {
|
||||
// Keep the thread visible so the user can retry after the remote failure.
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
return handleMouseLeave;
|
||||
}, [handleMouseLeave]);
|
||||
|
||||
const subUnitName = getSubUnitName(comments?.root.subUnitId ?? subUnitId);
|
||||
const editorVisible = showEdit && !editingId && !resolved;
|
||||
const title = `${displayRef ?? refStr ?? comments?.root.ref ?? ''}${subUnitName ? ' · ' : ''}${subUnitName}`;
|
||||
const threadCommentEditorId = getThreadCommentEditorId({
|
||||
location,
|
||||
unitId,
|
||||
subUnitId,
|
||||
commentId: id,
|
||||
fallbackId: fallbackEditorId,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`${location}-${unitId}-${subUnitId}-${id}`}
|
||||
className={clsx(`
|
||||
univer-relative univer-box-border univer-rounded-md univer-bg-gray-0 univer-p-4 univer-text-gray-900
|
||||
dark:!univer-bg-gray-900 dark:!univer-text-gray-0
|
||||
`, borderClassName, {
|
||||
'univer-w-[278px]': !full,
|
||||
'univer-w-full': full,
|
||||
'univer-shadow': !resolved && (showHighlight || isHover || location === ThreadCommentTreeLocation.CELL),
|
||||
})}
|
||||
style={style}
|
||||
onClick={onClick}
|
||||
onMouseEnter={() => {
|
||||
onMouseEnter?.();
|
||||
setIsHover(true);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
onMouseLeave?.();
|
||||
setIsHover(false);
|
||||
}}
|
||||
>
|
||||
{!resolved && showHighlight && (
|
||||
<div
|
||||
className={`
|
||||
univer-absolute univer-left-0 univer-right-0 univer-top-0 univer-h-1.5 univer-rounded-t-md
|
||||
univer-bg-yellow-400
|
||||
`}
|
||||
/>
|
||||
)}
|
||||
<div
|
||||
className={`
|
||||
univer-mb-4 univer-flex univer-flex-row univer-items-center univer-justify-between univer-text-sm
|
||||
univer-leading-5
|
||||
`}
|
||||
>
|
||||
<div className="univer-flex univer-flex-1 univer-flex-row univer-items-center univer-overflow-hidden">
|
||||
<div
|
||||
className={`
|
||||
univer-mr-2 univer-h-3.5 univer-w-[3px] univer-flex-shrink-0 univer-flex-grow-0
|
||||
univer-rounded-sm univer-bg-yellow-500
|
||||
rtl:univer-ml-2 rtl:univer-mr-0
|
||||
`}
|
||||
/>
|
||||
<Tooltip showIfEllipsis title={title}>
|
||||
<span
|
||||
className="univer-flex-1 univer-truncate"
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{!!comments && (
|
||||
<div className="univer-flex univer-flex-shrink-0 univer-flex-grow-0 univer-flex-row">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={localeService.t<LocaleKey>(resolved
|
||||
? 'thread-comment-ui.filter.status.unsolved'
|
||||
: 'thread-comment-ui.filter.status.resolved')}
|
||||
className={clsx(`
|
||||
univer-ml-1 univer-inline-flex univer-size-6 univer-cursor-pointer univer-items-center
|
||||
univer-justify-center univer-rounded-[3px] univer-border-0 univer-bg-transparent
|
||||
univer-p-0 univer-text-base
|
||||
hover:univer-bg-gray-50
|
||||
rtl:univer-ml-0 rtl:univer-mr-1
|
||||
dark:hover:!univer-bg-gray-800
|
||||
`, {
|
||||
'univer-text-green-500': resolved,
|
||||
})}
|
||||
onClick={handleResolve}
|
||||
>
|
||||
{resolved ? <SuccessIcon /> : <SuccessOutlineIcon />}
|
||||
</button>
|
||||
{currentUser?.userID === comments.root.personId
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={localeService.t<LocaleKey>('thread-comment-ui.item.delete')}
|
||||
className={`
|
||||
univer-ml-1 univer-inline-flex univer-size-6 univer-cursor-pointer
|
||||
univer-items-center univer-justify-center univer-rounded-[3px] univer-border-0
|
||||
univer-bg-transparent univer-p-0 univer-text-base
|
||||
hover:univer-bg-gray-50
|
||||
rtl:univer-ml-0 rtl:univer-mr-1
|
||||
dark:hover:!univer-bg-gray-800
|
||||
`}
|
||||
onClick={handleDeleteRoot}
|
||||
>
|
||||
<DeleteIcon />
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
className={clsx(
|
||||
'univer-max-h-80 univer-overflow-y-auto univer-overflow-x-hidden',
|
||||
scrollbarClassName,
|
||||
location === ThreadCommentTreeLocation.PANEL && '-univer-mx-4 univer-px-4'
|
||||
)}
|
||||
>
|
||||
{renderComments.map(
|
||||
(item) => (
|
||||
<ThreadCommentItem
|
||||
unitId={unitId}
|
||||
subUnitId={subUnitId}
|
||||
item={item}
|
||||
key={item.id}
|
||||
isRoot={item.id === comments?.root.id}
|
||||
editing={editingId === item.id}
|
||||
resolved={comments?.root.resolved}
|
||||
type={type}
|
||||
threadCommentEditorId={threadCommentEditorId}
|
||||
DropdownComponent={DropdownComponent}
|
||||
EditorComponent={EditorComponent}
|
||||
onClose={onClose}
|
||||
onEditingChange={(editing) => {
|
||||
if (editing) {
|
||||
setEditingId(item.id);
|
||||
} else {
|
||||
setEditingId('');
|
||||
}
|
||||
}}
|
||||
onReply={(user) => {
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
editorRef.current?.reply(transformTextNodes2Document([
|
||||
{
|
||||
type: 'mention',
|
||||
content: {
|
||||
id: user.userID,
|
||||
label: `@${user.name}`,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: 'text',
|
||||
content: ' ',
|
||||
},
|
||||
]));
|
||||
});
|
||||
}}
|
||||
onDeleteComment={onDeleteComment}
|
||||
onAfterDeleteComment={onAfterDeleteComment}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
{editorVisible && (
|
||||
<div>
|
||||
<EditorComponent
|
||||
key={`${autoFocus}`}
|
||||
ref={editorRef}
|
||||
type={type}
|
||||
unitId={unitId}
|
||||
subUnitId={subUnitId}
|
||||
editorId={threadCommentEditorId}
|
||||
onSave={async ({ text, attachments }) => {
|
||||
const comment: IThreadComment = {
|
||||
text,
|
||||
attachments,
|
||||
dT: getDT(),
|
||||
id: generateRandomId(),
|
||||
ref: refStr!,
|
||||
personId: currentUser?.userID ?? '',
|
||||
parentId: comments?.root.id,
|
||||
unitId,
|
||||
subUnitId,
|
||||
threadId: comments?.root.threadId ?? '',
|
||||
};
|
||||
|
||||
if (await onAddComment?.(comment) === false) {
|
||||
if (!comments) {
|
||||
onClose?.();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const success = await commandService.executeCommand(
|
||||
AddCommentCommand.id,
|
||||
{
|
||||
unitId,
|
||||
subUnitId,
|
||||
comment,
|
||||
} as IAddCommentCommandParams
|
||||
);
|
||||
if (success && scrollerRef.current) {
|
||||
scrollerRef.current.scrollTop = scrollerRef.current.scrollHeight;
|
||||
}
|
||||
if (success && !comments) {
|
||||
onClose?.();
|
||||
}
|
||||
return success;
|
||||
}}
|
||||
autoFocus={autoFocus || (!comments)}
|
||||
onCancel={() => {
|
||||
if (!comments) {
|
||||
onClose?.();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,762 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type {
|
||||
Dependency,
|
||||
ICommand,
|
||||
IDisposable,
|
||||
IDocumentBody,
|
||||
IDocumentData,
|
||||
Nullable,
|
||||
} from '@univerjs/core';
|
||||
import type { ISuccinctDocRangeParam, ITextRangeWithStyle } from '@univerjs/engine-render';
|
||||
import type { IShortcutItem } from '@univerjs/ui';
|
||||
import type { FormEvent } from 'react';
|
||||
import type { Root } from 'react-dom/client';
|
||||
import {
|
||||
CommandService,
|
||||
CommandType,
|
||||
ConfigService,
|
||||
ContextService,
|
||||
createIdentifier,
|
||||
DesktopLogService,
|
||||
DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
|
||||
ICommandService,
|
||||
IConfigService,
|
||||
IContextService,
|
||||
ILogService,
|
||||
Injector,
|
||||
IUniverInstanceService,
|
||||
LocaleService,
|
||||
LocaleType,
|
||||
LogLevel,
|
||||
toDisposable,
|
||||
UniverInstanceType,
|
||||
} from '@univerjs/core';
|
||||
import { IEditorService } from '@univerjs/docs-ui';
|
||||
import { IShortcutService, ISidebarService, KeyCode, RediContext } from '@univerjs/ui';
|
||||
import { act, createRef } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BehaviorSubject, Subject } from 'rxjs';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { SetActiveCommentOperation } from '../../commands/operations/comment.operations';
|
||||
import enUS from '../../locale/en-US';
|
||||
import { ThreadCommentPanelService } from '../../services/thread-comment-panel.service';
|
||||
import { ThreadCommentEditor } from '../ThreadCommentEditor';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const EDITOR_ID = 'thread-comment-editor';
|
||||
const REPLY_LABEL = enUS['thread-comment-ui'].editor.reply;
|
||||
const CANCEL_LABEL = enUS['thread-comment-ui'].editor.cancel;
|
||||
const testInjectors: Injector[] = [];
|
||||
const IRenderManagerService = createIdentifier<TestRenderManagerService>('engine-render.render-manager.service');
|
||||
|
||||
interface IEditorRecord {
|
||||
id: string;
|
||||
data: IDocumentData;
|
||||
selections: ISuccinctDocRangeParam[];
|
||||
focused: boolean;
|
||||
preserveHostFocus?: boolean;
|
||||
order: string[];
|
||||
}
|
||||
|
||||
class TestState {
|
||||
static records = new Map<string, IEditorRecord>();
|
||||
static savedBodies: IDocumentBody[] = [];
|
||||
static submitCount = 0;
|
||||
static cancelCount = 0;
|
||||
static shortcuts: IShortcutItem[] = [];
|
||||
static editorCommandIds: string[] = [];
|
||||
static focusRender: (() => void) | undefined;
|
||||
static blurRender: (() => void) | undefined;
|
||||
|
||||
static reset() {
|
||||
this.records = new Map();
|
||||
this.savedBodies = [];
|
||||
this.submitCount = 0;
|
||||
this.cancelCount = 0;
|
||||
this.shortcuts = [];
|
||||
this.editorCommandIds = [];
|
||||
this.focusRender = undefined;
|
||||
this.blurRender = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
class TestEditor {
|
||||
readonly input$ = new Subject<unknown>();
|
||||
readonly paste$ = new Subject<unknown>();
|
||||
readonly focus$ = new Subject<unknown>();
|
||||
readonly blur$ = new Subject<unknown>();
|
||||
readonly change$ = new Subject<unknown>();
|
||||
readonly selectionChange$ = new Subject<unknown>();
|
||||
|
||||
readonly render = {
|
||||
isDisposed: () => false,
|
||||
with: () => ({
|
||||
getSkeleton: () => ({
|
||||
getActualSize: () => ({ actualWidth: 0, actualHeight: 0 }),
|
||||
}),
|
||||
getViewModel: () => ({
|
||||
getDataModel: () => ({
|
||||
updateDocumentDataPageSize: () => undefined,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
scene: {
|
||||
transformByState: () => undefined,
|
||||
getViewport: () => undefined,
|
||||
},
|
||||
mainComponent: {
|
||||
resize: () => undefined,
|
||||
translate: () => undefined,
|
||||
},
|
||||
components: {
|
||||
get: () => undefined,
|
||||
},
|
||||
};
|
||||
|
||||
constructor(private readonly _record: IEditorRecord) {
|
||||
}
|
||||
|
||||
isFocus() {
|
||||
return this._record.focused;
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this._record.focused = true;
|
||||
this._record.order.push('editor.focus');
|
||||
TestState.focusRender?.();
|
||||
this.focus$.next(undefined);
|
||||
}
|
||||
|
||||
blur(): void {
|
||||
this._record.focused = false;
|
||||
this._record.order.push('editor.blur');
|
||||
TestState.blurRender?.();
|
||||
this.blur$.next(undefined);
|
||||
}
|
||||
|
||||
select(): void {
|
||||
}
|
||||
|
||||
setSelectionRanges(ranges: ISuccinctDocRangeParam[]): void {
|
||||
this._record.selections = ranges;
|
||||
this._record.order.push('editor.setSelectionRanges');
|
||||
this.selectionChange$.next(undefined);
|
||||
}
|
||||
|
||||
getSelectionRanges() {
|
||||
return this._record.selections;
|
||||
}
|
||||
|
||||
getEditorId() {
|
||||
return this._record.id;
|
||||
}
|
||||
|
||||
getDocumentData() {
|
||||
return this._record.data;
|
||||
}
|
||||
|
||||
setDocumentData(data: IDocumentData, ranges: ISuccinctDocRangeParam[] = []): void {
|
||||
this._record.data = data;
|
||||
this._record.selections = ranges;
|
||||
this._record.order.push('editor.setDocumentData');
|
||||
this.selectionChange$.next(undefined);
|
||||
}
|
||||
|
||||
replaceText(value: string): void {
|
||||
this._record.data = {
|
||||
...this._record.data,
|
||||
body: {
|
||||
dataStream: `${value}\r\n`,
|
||||
},
|
||||
};
|
||||
this._record.order.push('editor.replaceText');
|
||||
this.selectionChange$.next(undefined);
|
||||
}
|
||||
|
||||
clearUndoRedoHistory(): void {
|
||||
}
|
||||
|
||||
getValue() {
|
||||
return this._record.data.body?.dataStream ?? '';
|
||||
}
|
||||
|
||||
isSheetEditor() {
|
||||
return false;
|
||||
}
|
||||
|
||||
getBoundingClientRect() {
|
||||
return { width: 320, height: 64 };
|
||||
}
|
||||
}
|
||||
|
||||
class TestEditorService {
|
||||
private readonly _editors = new Map<string, TestEditor>();
|
||||
private _focusId: Nullable<string>;
|
||||
readonly blur$ = new Subject<unknown>();
|
||||
readonly focus$ = new Subject<ISuccinctDocRangeParam>();
|
||||
|
||||
getEditor(id?: string): Nullable<TestEditor> {
|
||||
return id ? this._editors.get(id) : undefined;
|
||||
}
|
||||
|
||||
register(config: { initialSnapshot: IDocumentData; preserveHostFocus?: boolean }, _container: HTMLDivElement): IDisposable {
|
||||
const snapshot = config.initialSnapshot;
|
||||
const record: IEditorRecord = {
|
||||
id: snapshot.id,
|
||||
data: snapshot,
|
||||
selections: [],
|
||||
focused: false,
|
||||
preserveHostFocus: config.preserveHostFocus,
|
||||
order: [],
|
||||
};
|
||||
const editor = new TestEditor(record);
|
||||
this._editors.set(record.id, editor);
|
||||
TestState.records.set(record.id, record);
|
||||
|
||||
return toDisposable(() => {
|
||||
this._editors.delete(record.id);
|
||||
});
|
||||
}
|
||||
|
||||
getAllEditor() {
|
||||
return this._editors;
|
||||
}
|
||||
|
||||
isEditor(editorUnitId: string) {
|
||||
return this._editors.has(editorUnitId);
|
||||
}
|
||||
|
||||
getEditorRenderConfig() {
|
||||
return null;
|
||||
}
|
||||
|
||||
isSheetEditor() {
|
||||
return false;
|
||||
}
|
||||
|
||||
blur(): void {
|
||||
this.getFocusEditor()?.blur();
|
||||
this._focusId = null;
|
||||
this.blur$.next(undefined);
|
||||
}
|
||||
|
||||
focus(editorUnitId: string): void {
|
||||
this._focusId = editorUnitId;
|
||||
this.getEditor(editorUnitId)?.focus();
|
||||
this.focus$.next({ startOffset: 0, endOffset: 0 });
|
||||
}
|
||||
|
||||
getFocusId() {
|
||||
return this._focusId;
|
||||
}
|
||||
|
||||
getFocusEditor() {
|
||||
return this._focusId ? this.getEditor(this._focusId) : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
class TestDocSelectionRenderService {
|
||||
readonly textSelectionInner$ = new BehaviorSubject({
|
||||
textRanges: [] as ITextRangeWithStyle[],
|
||||
});
|
||||
|
||||
isFocusing = false;
|
||||
|
||||
focus(): void {
|
||||
this.isFocusing = true;
|
||||
this.textSelectionInner$.next({
|
||||
textRanges: [{ startOffset: 0, endOffset: 0, collapsed: true }],
|
||||
});
|
||||
}
|
||||
|
||||
blur(): void {
|
||||
this.isFocusing = false;
|
||||
this.textSelectionInner$.next({ textRanges: [] });
|
||||
}
|
||||
|
||||
getSkeleton() {
|
||||
return { getActualSize: () => ({ actualWidth: 0, actualHeight: 0 }) };
|
||||
}
|
||||
}
|
||||
|
||||
class TestRenderManagerService {
|
||||
readonly created$ = new Subject<unknown>();
|
||||
readonly disposed$ = new Subject<string>();
|
||||
readonly defaultEngine = {};
|
||||
private readonly _renderService = new TestDocSelectionRenderService();
|
||||
private readonly _renderer = { with: () => this._renderService };
|
||||
|
||||
constructor() {
|
||||
TestState.focusRender = () => this._renderService.focus();
|
||||
TestState.blurRender = () => this._renderService.blur();
|
||||
}
|
||||
|
||||
addRender(): void {}
|
||||
createRender() { return undefined; }
|
||||
removeRender(): void {}
|
||||
getRenderUnitById() { return this._renderer; }
|
||||
getAllRenderersOfType() { return []; }
|
||||
getRenderAll() { return new Map(); }
|
||||
create(): void {}
|
||||
has() { return false; }
|
||||
registerRenderModule(): IDisposable { return toDisposable(() => undefined); }
|
||||
dispose(): void {}
|
||||
}
|
||||
|
||||
class TestShortcutService {
|
||||
readonly shortcutChanged$ = new Subject<void>();
|
||||
|
||||
forceEscape(): IDisposable { return toDisposable(() => undefined); }
|
||||
forceDisable(): IDisposable { return toDisposable(() => undefined); }
|
||||
dispatch(): undefined { return undefined; }
|
||||
registerShortcut(shortcut: IShortcutItem): IDisposable {
|
||||
TestState.shortcuts.push(shortcut);
|
||||
return toDisposable(() => undefined);
|
||||
}
|
||||
|
||||
getShortcutDisplay(): string | null { return null; }
|
||||
getShortcutDisplayOfCommand(): string | null { return null; }
|
||||
getAllShortcuts(): IShortcutItem[] { return TestState.shortcuts; }
|
||||
}
|
||||
|
||||
class TestSidebarService {
|
||||
readonly sidebarOptions$ = new BehaviorSubject({ visible: true });
|
||||
readonly scrollEvent$ = new Subject<Event>();
|
||||
private _container: HTMLElement | undefined;
|
||||
private _width: number | undefined;
|
||||
|
||||
open(): IDisposable { return toDisposable(() => undefined); }
|
||||
close(): void {
|
||||
this.sidebarOptions$.next({ visible: false });
|
||||
}
|
||||
|
||||
get visible() { return this.sidebarOptions$.getValue().visible; }
|
||||
get options() { return this.sidebarOptions$.getValue(); }
|
||||
getContainer() { return this._container; }
|
||||
setContainer(element?: HTMLElement): void { this._container = element; }
|
||||
get width() { return this._width; }
|
||||
setWidth(value: number): void { this._width = value; }
|
||||
}
|
||||
|
||||
class TestUniverInstanceService {
|
||||
getCurrentTypeOfUnit$() {
|
||||
return new BehaviorSubject({ id: 'workbook' });
|
||||
}
|
||||
|
||||
setCurrentUnitForType(): void {
|
||||
}
|
||||
}
|
||||
|
||||
const BreakLineCommand: ICommand = {
|
||||
id: 'doc.command.editor-break-line',
|
||||
type: CommandType.COMMAND,
|
||||
handler: () => true,
|
||||
};
|
||||
const DeleteLeftCommand: ICommand = {
|
||||
id: 'doc.command.delete-left',
|
||||
type: CommandType.COMMAND,
|
||||
handler: () => {
|
||||
TestState.editorCommandIds.push(DeleteLeftCommand.id);
|
||||
const record = TestState.records.get(EDITOR_ID);
|
||||
if (record) {
|
||||
record.data = { ...record.data, body: { dataStream: '\r\n' } };
|
||||
}
|
||||
return true;
|
||||
},
|
||||
};
|
||||
const DeleteRightCommand: ICommand = {
|
||||
id: 'doc.command.delete-right',
|
||||
type: CommandType.COMMAND,
|
||||
handler: () => {
|
||||
TestState.editorCommandIds.push(DeleteRightCommand.id);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
function createEditorTestBed() {
|
||||
const injector = new Injector();
|
||||
testInjectors.push(injector);
|
||||
const dependencies: Dependency[] = [
|
||||
[ICommandService, { useClass: CommandService }],
|
||||
[ILogService, { useClass: DesktopLogService }],
|
||||
[IContextService, { useClass: ContextService }],
|
||||
[IConfigService, { useClass: ConfigService }],
|
||||
[LocaleService],
|
||||
[IEditorService, { useClass: TestEditorService as never }],
|
||||
[IRenderManagerService, { useClass: TestRenderManagerService as never }],
|
||||
[IShortcutService, { useClass: TestShortcutService as never }],
|
||||
[ISidebarService, { useClass: TestSidebarService as never }],
|
||||
[IUniverInstanceService, { useClass: TestUniverInstanceService as never }],
|
||||
[ThreadCommentPanelService],
|
||||
];
|
||||
|
||||
dependencies.forEach((dependency) => injector.add(dependency));
|
||||
injector.get(ILogService).setLogLevel(LogLevel.SILENT);
|
||||
const localeService = injector.get(LocaleService);
|
||||
localeService.load({ [LocaleType.EN_US]: enUS });
|
||||
localeService.setLocale(LocaleType.EN_US);
|
||||
localeService.setDirection('ltr');
|
||||
|
||||
const commandService = injector.get(ICommandService);
|
||||
[SetActiveCommentOperation, BreakLineCommand, DeleteLeftCommand, DeleteRightCommand]
|
||||
.forEach((command) => commandService.registerCommand(command));
|
||||
|
||||
return {
|
||||
injector,
|
||||
commandService,
|
||||
panelService: injector.get(ThreadCommentPanelService),
|
||||
};
|
||||
}
|
||||
|
||||
function renderEditor(injector: Injector, element: React.ReactElement) {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<RediContext.Provider value={{ injector }}>
|
||||
{element}
|
||||
</RediContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
return { container, root };
|
||||
}
|
||||
|
||||
function getButton(container: HTMLElement, text: string): HTMLButtonElement {
|
||||
const button = Array.from(container.querySelectorAll('button')).find((item) => item.textContent?.trim() === text);
|
||||
if (!button) {
|
||||
throw new Error(`Button "${text}" was not found.`);
|
||||
}
|
||||
|
||||
return button as HTMLButtonElement;
|
||||
}
|
||||
|
||||
function body(dataStream: string): IDocumentBody {
|
||||
return { dataStream };
|
||||
}
|
||||
|
||||
describe('ThreadCommentEditor', () => {
|
||||
let root: Root | undefined;
|
||||
let container: HTMLElement | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
TestState.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root!.unmount());
|
||||
}
|
||||
container?.remove();
|
||||
testInjectors.splice(0).forEach((injector) => injector.dispose());
|
||||
root = undefined;
|
||||
container = undefined;
|
||||
});
|
||||
|
||||
it('enters editing mode from an inserted reply and keeps action buttons out of outer form submission', () => {
|
||||
const testBed = createEditorTestBed();
|
||||
const editorRef = createRef<{ reply: (text: IDocumentBody) => void }>();
|
||||
const rendered = renderEditor(
|
||||
testBed.injector,
|
||||
<form
|
||||
onSubmit={(event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
TestState.submitCount += 1;
|
||||
}}
|
||||
>
|
||||
<ThreadCommentEditor
|
||||
ref={editorRef}
|
||||
autoFocus={false}
|
||||
editorId={EDITOR_ID}
|
||||
subUnitId="subUnit"
|
||||
type={UniverInstanceType.UNIVER_SHEET}
|
||||
unitId="unit"
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
expect(container.querySelectorAll('button')).toHaveLength(0);
|
||||
|
||||
act(() => {
|
||||
editorRef.current?.reply(body('draft reply\r\n'));
|
||||
});
|
||||
|
||||
expect(getButton(container, CANCEL_LABEL).type).toBe('button');
|
||||
expect(getButton(container, REPLY_LABEL).type).toBe('button');
|
||||
expect(getButton(container, REPLY_LABEL).disabled).toBe(false);
|
||||
expect(TestState.submitCount).toBe(0);
|
||||
});
|
||||
|
||||
it('preserves the sheet host focus while editing a comment', () => {
|
||||
const testBed = createEditorTestBed();
|
||||
const rendered = renderEditor(
|
||||
testBed.injector,
|
||||
<ThreadCommentEditor
|
||||
autoFocus={false}
|
||||
editorId={EDITOR_ID}
|
||||
subUnitId="subUnit"
|
||||
type={UniverInstanceType.UNIVER_SHEET}
|
||||
unitId="unit"
|
||||
/>
|
||||
);
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
expect(TestState.records.get(EDITOR_ID)?.preserveHostFocus).toBe(true);
|
||||
});
|
||||
|
||||
it('routes Backspace and Delete to the comment editor commands while preserving host focus', async () => {
|
||||
const testBed = createEditorTestBed();
|
||||
const editorRef = createRef<{ reply: (text: IDocumentBody) => void }>();
|
||||
const rendered = renderEditor(
|
||||
testBed.injector,
|
||||
<ThreadCommentEditor
|
||||
ref={editorRef}
|
||||
autoFocus={false}
|
||||
editorId={EDITOR_ID}
|
||||
subUnitId="subUnit"
|
||||
type={UniverInstanceType.UNIVER_SLIDE}
|
||||
unitId="unit"
|
||||
/>
|
||||
);
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
act(() => editorRef.current?.reply(body('draft reply\r\n')));
|
||||
|
||||
const backspace = TestState.shortcuts.find((shortcut) => shortcut.binding === KeyCode.BACKSPACE)!;
|
||||
const deleteRight = TestState.shortcuts.find((shortcut) => shortcut.binding === KeyCode.DELETE)!;
|
||||
expect(backspace).toBeDefined();
|
||||
expect(deleteRight).toBeDefined();
|
||||
|
||||
await act(async () => {
|
||||
await testBed.commandService.executeCommand(backspace.id, backspace.staticParameters);
|
||||
await testBed.commandService.executeCommand(deleteRight.id, deleteRight.staticParameters);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(TestState.editorCommandIds).toEqual([DeleteLeftCommand.id, DeleteRightCommand.id]);
|
||||
expect(TestState.records.get(EDITOR_ID)?.preserveHostFocus).toBe(true);
|
||||
expect(getButton(container, REPLY_LABEL).disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('saves reply content, clears the editor, and does not submit an outer form', async () => {
|
||||
const testBed = createEditorTestBed();
|
||||
const editorRef = createRef<{ reply: (text: IDocumentBody) => void }>();
|
||||
const rendered = renderEditor(
|
||||
testBed.injector,
|
||||
<form
|
||||
onSubmit={(event: FormEvent<HTMLFormElement>) => {
|
||||
event.preventDefault();
|
||||
TestState.submitCount += 1;
|
||||
}}
|
||||
>
|
||||
<ThreadCommentEditor
|
||||
ref={editorRef}
|
||||
autoFocus={false}
|
||||
editorId={EDITOR_ID}
|
||||
onSave={(comment) => {
|
||||
TestState.records.get(EDITOR_ID)?.order.push('onSave');
|
||||
TestState.savedBodies.push(comment.text);
|
||||
}}
|
||||
subUnitId="subUnit"
|
||||
type={UniverInstanceType.UNIVER_SHEET}
|
||||
unitId="unit"
|
||||
/>
|
||||
</form>
|
||||
);
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
act(() => {
|
||||
editorRef.current?.reply(body('@Owner reply body\r\n'));
|
||||
});
|
||||
|
||||
expect(getButton(container, REPLY_LABEL).disabled).toBe(false);
|
||||
|
||||
await act(async () => {
|
||||
getButton(container!, REPLY_LABEL).dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(TestState.savedBodies).toEqual([body('@Owner reply body\r\n')]);
|
||||
expect(TestState.submitCount).toBe(0);
|
||||
|
||||
const record = TestState.records.get(EDITOR_ID)!;
|
||||
expect(record.data.body?.dataStream).toBe('\r\n');
|
||||
expect(record.selections).toEqual([]);
|
||||
expect(record.order.indexOf('onSave')).toBeLessThan(record.order.indexOf('editor.blur'));
|
||||
expect(record.order.indexOf('editor.blur')).toBeLessThan(record.order.indexOf('editor.replaceText'));
|
||||
});
|
||||
|
||||
it('preserves a reply draft and prevents duplicate submits when permission is revoked during save', async () => {
|
||||
const testBed = createEditorTestBed();
|
||||
const editorRef = createRef<{ reply: (text: IDocumentBody) => void }>();
|
||||
let saveCount = 0;
|
||||
let failSave: ((error: Error) => void) | undefined;
|
||||
const rendered = renderEditor(
|
||||
testBed.injector,
|
||||
<ThreadCommentEditor
|
||||
ref={editorRef}
|
||||
autoFocus={false}
|
||||
editorId={EDITOR_ID}
|
||||
onSave={() => {
|
||||
saveCount += 1;
|
||||
return new Promise<boolean>((_resolve, reject) => {
|
||||
failSave = reject;
|
||||
});
|
||||
}}
|
||||
subUnitId="subUnit"
|
||||
type={UniverInstanceType.UNIVER_SHEET}
|
||||
unitId="unit"
|
||||
/>
|
||||
);
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
act(() => {
|
||||
editorRef.current?.reply(body('retry this reply\r\n'));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
const replyButton = getButton(container!, REPLY_LABEL);
|
||||
replyButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
replyButton.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(saveCount).toBe(1);
|
||||
expect(getButton(container, REPLY_LABEL).disabled).toBe(true);
|
||||
|
||||
await act(async () => {
|
||||
failSave?.(new Error('forbidden'));
|
||||
});
|
||||
|
||||
expect(TestState.records.get(EDITOR_ID)?.data.body?.dataStream).toBe('retry this reply\r\n');
|
||||
expect(getButton(container, REPLY_LABEL).disabled).toBe(false);
|
||||
});
|
||||
|
||||
it('does not mutate a disposed editor when a pending save finishes', async () => {
|
||||
const testBed = createEditorTestBed();
|
||||
const editorRef = createRef<{ reply: (text: IDocumentBody) => void }>();
|
||||
let finishSave: ((success: boolean) => void) | undefined;
|
||||
const rendered = renderEditor(
|
||||
testBed.injector,
|
||||
<ThreadCommentEditor
|
||||
ref={editorRef}
|
||||
autoFocus={false}
|
||||
editorId={EDITOR_ID}
|
||||
onSave={() => new Promise<boolean>((resolve) => {
|
||||
finishSave = resolve;
|
||||
})}
|
||||
subUnitId="subUnit"
|
||||
type={UniverInstanceType.UNIVER_SHEET}
|
||||
unitId="unit"
|
||||
/>
|
||||
);
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
act(() => editorRef.current?.reply(body('pending reply\r\n')));
|
||||
await act(async () => {
|
||||
getButton(container!, REPLY_LABEL).dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
const record = TestState.records.get(EDITOR_ID)!;
|
||||
|
||||
act(() => root!.unmount());
|
||||
root = undefined;
|
||||
await act(async () => {
|
||||
finishSave?.(true);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(record.order).not.toContain('editor.blur');
|
||||
expect(record.data.body?.dataStream).toBe('pending reply\r\n');
|
||||
});
|
||||
|
||||
it('cancels editing by clearing content and resetting the active comment', () => {
|
||||
const testBed = createEditorTestBed();
|
||||
testBed.panelService.setActiveComment({ unitId: 'unit', subUnitId: 'subUnit', commentId: 'comment-1' });
|
||||
|
||||
const editorRef = createRef<{ reply: (text: IDocumentBody) => void }>();
|
||||
const rendered = renderEditor(
|
||||
testBed.injector,
|
||||
<ThreadCommentEditor
|
||||
ref={editorRef}
|
||||
autoFocus={false}
|
||||
editorId={EDITOR_ID}
|
||||
onCancel={() => {
|
||||
TestState.cancelCount += 1;
|
||||
}}
|
||||
subUnitId="subUnit"
|
||||
type={UniverInstanceType.UNIVER_SHEET}
|
||||
unitId="unit"
|
||||
/>
|
||||
);
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
act(() => {
|
||||
editorRef.current?.reply(body('draft reply\r\n'));
|
||||
});
|
||||
|
||||
act(() => {
|
||||
getButton(container!, CANCEL_LABEL).dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
const record = TestState.records.get(EDITOR_ID)!;
|
||||
expect(record.data.body?.dataStream).toBe('\r\n');
|
||||
expect(record.selections).toEqual([]);
|
||||
expect(TestState.cancelCount).toBe(1);
|
||||
expect(testBed.panelService.activeCommentId).toBeUndefined();
|
||||
expect(container.querySelectorAll('button')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('uses the normal document editor as the click-outside focus target for document comments', async () => {
|
||||
const testBed = createEditorTestBed();
|
||||
const editorRef = createRef<{ reply: (text: IDocumentBody) => void }>();
|
||||
const rendered = renderEditor(
|
||||
testBed.injector,
|
||||
<ThreadCommentEditor
|
||||
ref={editorRef}
|
||||
autoFocus={false}
|
||||
editorId={EDITOR_ID}
|
||||
subUnitId="subUnit"
|
||||
type={UniverInstanceType.UNIVER_DOC}
|
||||
unitId="docUnit"
|
||||
/>
|
||||
);
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
act(() => {
|
||||
editorRef.current?.reply(body('doc reply\r\n'));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 120));
|
||||
document.body.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
});
|
||||
|
||||
expect(testBed.injector.get(IEditorService).getFocusId()).toBe(DOCS_NORMAL_EDITOR_UNIT_ID_KEY);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,753 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Dependency, IDisposable, IDocumentData, Nullable } from '@univerjs/core';
|
||||
import type { ISuccinctDocRangeParam } from '@univerjs/engine-render';
|
||||
import type { IThreadComment } from '@univerjs/thread-comment';
|
||||
import type { IShortcutItem } from '@univerjs/ui';
|
||||
import type { Root } from 'react-dom/client';
|
||||
import {
|
||||
CommandService,
|
||||
ConfigService,
|
||||
ContextService,
|
||||
createIdentifier,
|
||||
DesktopLogService,
|
||||
ICommandService,
|
||||
IConfigService,
|
||||
IContextService,
|
||||
ILogService,
|
||||
Injector,
|
||||
IUniverInstanceService,
|
||||
LifecycleService,
|
||||
LifecycleStages,
|
||||
LocaleService,
|
||||
LocaleType,
|
||||
LogLevel,
|
||||
RegionService,
|
||||
toDisposable,
|
||||
UniverInstanceType,
|
||||
UserManagerService,
|
||||
} from '@univerjs/core';
|
||||
import { IEditorService } from '@univerjs/docs-ui';
|
||||
import {
|
||||
AddCommentCommand,
|
||||
AddCommentMutation,
|
||||
DeleteCommentCommand,
|
||||
DeleteCommentMutation,
|
||||
DeleteCommentTreeCommand,
|
||||
IThreadCommentDataSourceService,
|
||||
ResolveCommentCommand,
|
||||
ResolveCommentMutation,
|
||||
ThreadCommentDataSourceService,
|
||||
ThreadCommentModel,
|
||||
UpdateCommentCommand,
|
||||
UpdateCommentMutation,
|
||||
} from '@univerjs/thread-comment';
|
||||
import { IShortcutService, ISidebarService, RediContext } from '@univerjs/ui';
|
||||
import { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BehaviorSubject, Subject } from 'rxjs';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { SetActiveCommentOperation } from '../../commands/operations/comment.operations';
|
||||
import { ThreadCommentPanelService } from '../../services/thread-comment-panel.service';
|
||||
import { MobileThreadCommentPanel } from '../mobile/MobileThreadCommentPanel';
|
||||
import { transformTextNodes2Document } from '../thread-comment-editor/util';
|
||||
import { ThreadCommentPanel } from '../ThreadCommentPanel';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const UNIT_ID = 'unit-1';
|
||||
const SHEET_ID = 'sheet-1';
|
||||
const IRenderManagerService = createIdentifier<TestRenderManagerService>('engine-render.render-manager.service');
|
||||
interface IEditorRecord {
|
||||
id: string;
|
||||
data: IDocumentData;
|
||||
selections: ISuccinctDocRangeParam[];
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
class TestLocaleService {
|
||||
private readonly _currentLocale$ = new BehaviorSubject<LocaleType>(LocaleType.ZH_CN);
|
||||
readonly currentLocale$ = this._currentLocale$.asObservable();
|
||||
private readonly _direction$ = new BehaviorSubject<'ltr' | 'rtl'>('ltr');
|
||||
readonly direction$ = this._direction$.asObservable();
|
||||
|
||||
t(key: string) {
|
||||
return key;
|
||||
}
|
||||
|
||||
getCurrentLocale() {
|
||||
return this._currentLocale$.getValue();
|
||||
}
|
||||
|
||||
getDirection() {
|
||||
return this._direction$.getValue();
|
||||
}
|
||||
}
|
||||
|
||||
class TestEditor {
|
||||
readonly input$ = new Subject<unknown>();
|
||||
readonly paste$ = new Subject<unknown>();
|
||||
readonly focus$ = new Subject<unknown>();
|
||||
readonly blur$ = new Subject<unknown>();
|
||||
readonly change$ = new Subject<unknown>();
|
||||
readonly selectionChange$ = new Subject<unknown>();
|
||||
|
||||
readonly render = {
|
||||
isDisposed: () => false,
|
||||
with: () => ({
|
||||
getSkeleton: () => ({
|
||||
getActualSize: () => ({ actualWidth: 0, actualHeight: 0 }),
|
||||
}),
|
||||
getViewModel: () => ({
|
||||
getDataModel: () => ({
|
||||
updateDocumentDataPageSize: () => undefined,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
scene: {
|
||||
transformByState: () => undefined,
|
||||
getViewport: () => undefined,
|
||||
},
|
||||
mainComponent: {
|
||||
resize: () => undefined,
|
||||
translate: () => undefined,
|
||||
},
|
||||
components: {
|
||||
get: () => undefined,
|
||||
},
|
||||
};
|
||||
|
||||
constructor(private readonly _record: IEditorRecord) {
|
||||
}
|
||||
|
||||
isFocus() {
|
||||
return this._record.focused;
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this._record.focused = true;
|
||||
this.focus$.next(undefined);
|
||||
}
|
||||
|
||||
blur(): void {
|
||||
this._record.focused = false;
|
||||
this.blur$.next(undefined);
|
||||
}
|
||||
|
||||
select(): void {
|
||||
}
|
||||
|
||||
setSelectionRanges(ranges: ISuccinctDocRangeParam[]): void {
|
||||
this._record.selections = ranges;
|
||||
this.selectionChange$.next(undefined);
|
||||
}
|
||||
|
||||
getSelectionRanges() {
|
||||
return this._record.selections;
|
||||
}
|
||||
|
||||
getEditorId() {
|
||||
return this._record.id;
|
||||
}
|
||||
|
||||
getDocumentData() {
|
||||
return this._record.data;
|
||||
}
|
||||
|
||||
setDocumentData(data: IDocumentData, ranges: ISuccinctDocRangeParam[] = []): void {
|
||||
this._record.data = data;
|
||||
this._record.selections = ranges;
|
||||
this.selectionChange$.next(undefined);
|
||||
}
|
||||
|
||||
replaceText(value: string): void {
|
||||
this._record.data = {
|
||||
...this._record.data,
|
||||
body: {
|
||||
dataStream: `${value}\r\n`,
|
||||
},
|
||||
};
|
||||
this.selectionChange$.next(undefined);
|
||||
}
|
||||
|
||||
clearUndoRedoHistory(): void {
|
||||
}
|
||||
|
||||
getValue() {
|
||||
return this._record.data.body?.dataStream ?? '';
|
||||
}
|
||||
|
||||
isSheetEditor() {
|
||||
return false;
|
||||
}
|
||||
|
||||
getBoundingClientRect() {
|
||||
return { width: 320, height: 64 };
|
||||
}
|
||||
}
|
||||
|
||||
class TestEditorService {
|
||||
private readonly _editors = new Map<string, TestEditor>();
|
||||
private _focusId: Nullable<string>;
|
||||
readonly blur$ = new Subject<unknown>();
|
||||
readonly focus$ = new Subject<ISuccinctDocRangeParam>();
|
||||
|
||||
getEditor(id?: string): Nullable<TestEditor> {
|
||||
return id ? this._editors.get(id) : undefined;
|
||||
}
|
||||
|
||||
register(config: { initialSnapshot: IDocumentData }, _container: HTMLDivElement): IDisposable {
|
||||
const snapshot = config.initialSnapshot;
|
||||
const record: IEditorRecord = {
|
||||
id: snapshot.id,
|
||||
data: snapshot,
|
||||
selections: [],
|
||||
focused: false,
|
||||
};
|
||||
const editor = new TestEditor(record);
|
||||
this._editors.set(record.id, editor);
|
||||
|
||||
return toDisposable(() => {
|
||||
this._editors.delete(record.id);
|
||||
});
|
||||
}
|
||||
|
||||
getAllEditor() {
|
||||
return this._editors;
|
||||
}
|
||||
|
||||
isEditor(editorUnitId: string) {
|
||||
return this._editors.has(editorUnitId);
|
||||
}
|
||||
|
||||
getEditorRenderConfig() {
|
||||
return null;
|
||||
}
|
||||
|
||||
isSheetEditor() {
|
||||
return false;
|
||||
}
|
||||
|
||||
blur(): void {
|
||||
this.getFocusEditor()?.blur();
|
||||
this._focusId = null;
|
||||
this.blur$.next(undefined);
|
||||
}
|
||||
|
||||
focus(editorUnitId: string): void {
|
||||
this._focusId = editorUnitId;
|
||||
this.getEditor(editorUnitId)?.focus();
|
||||
this.focus$.next({ startOffset: 0, endOffset: 0 });
|
||||
}
|
||||
|
||||
getFocusId() {
|
||||
return this._focusId;
|
||||
}
|
||||
|
||||
getFocusEditor() {
|
||||
return this._focusId ? this.getEditor(this._focusId) : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
class TestRenderManagerService {
|
||||
readonly created$ = new Subject<unknown>();
|
||||
readonly disposed$ = new Subject<string>();
|
||||
readonly defaultEngine = {};
|
||||
|
||||
addRender(): void {}
|
||||
createRender() { return undefined; }
|
||||
removeRender(): void {}
|
||||
getRenderUnitById() { return undefined; }
|
||||
getAllRenderersOfType() { return []; }
|
||||
getRenderAll() { return new Map(); }
|
||||
create(): void {}
|
||||
has() { return false; }
|
||||
registerRenderModule(): IDisposable { return toDisposable(() => undefined); }
|
||||
dispose(): void {}
|
||||
}
|
||||
|
||||
class TestShortcutService {
|
||||
readonly shortcutChanged$ = new Subject<void>();
|
||||
readonly shortcuts: IShortcutItem[] = [];
|
||||
|
||||
forceEscape(): IDisposable { return toDisposable(() => undefined); }
|
||||
forceDisable(): IDisposable { return toDisposable(() => undefined); }
|
||||
dispatch(): undefined { return undefined; }
|
||||
registerShortcut(shortcut: IShortcutItem): IDisposable {
|
||||
this.shortcuts.push(shortcut);
|
||||
return toDisposable(() => undefined);
|
||||
}
|
||||
|
||||
getShortcutDisplay(): string | null { return null; }
|
||||
getShortcutDisplayOfCommand(): string | null { return null; }
|
||||
getAllShortcuts(): IShortcutItem[] { return this.shortcuts; }
|
||||
}
|
||||
|
||||
class TestSidebarService {
|
||||
readonly sidebarOptions$ = new BehaviorSubject({ visible: true });
|
||||
readonly scrollEvent$ = new Subject<Event>();
|
||||
private _container: HTMLElement | undefined;
|
||||
private _width: number | undefined;
|
||||
|
||||
open(): IDisposable { return toDisposable(() => undefined); }
|
||||
close(): void {
|
||||
this.sidebarOptions$.next({ visible: false });
|
||||
}
|
||||
|
||||
get visible() { return this.sidebarOptions$.getValue().visible; }
|
||||
get options() { return this.sidebarOptions$.getValue(); }
|
||||
getContainer() { return this._container; }
|
||||
setContainer(element?: HTMLElement): void { this._container = element; }
|
||||
get width() { return this._width; }
|
||||
setWidth(value: number): void { this._width = value; }
|
||||
}
|
||||
|
||||
class TestUniverInstanceService {
|
||||
getCurrentTypeOfUnit$() {
|
||||
return new BehaviorSubject({ id: 'workbook' });
|
||||
}
|
||||
|
||||
setCurrentUnitForType(): void {
|
||||
}
|
||||
}
|
||||
|
||||
function createComment(overrides: Partial<IThreadComment>): IThreadComment {
|
||||
const id = overrides.id ?? 'comment-1';
|
||||
|
||||
return {
|
||||
id,
|
||||
threadId: overrides.threadId ?? id,
|
||||
ref: overrides.ref ?? 'A1',
|
||||
dT: overrides.dT ?? '2026-06-17T00:00:00.000Z',
|
||||
personId: overrides.personId ?? 'owner',
|
||||
text: overrides.text ?? transformTextNodes2Document([{ type: 'text', content: id }]),
|
||||
unitId: overrides.unitId ?? UNIT_ID,
|
||||
subUnitId: overrides.subUnitId ?? SHEET_ID,
|
||||
attachments: overrides.attachments,
|
||||
children: overrides.children,
|
||||
mentions: overrides.mentions,
|
||||
parentId: overrides.parentId,
|
||||
resolved: overrides.resolved,
|
||||
updateT: overrides.updateT,
|
||||
updated: overrides.updated,
|
||||
};
|
||||
}
|
||||
|
||||
function createPanelTestBed() {
|
||||
const injector = new Injector();
|
||||
const dependencies: Dependency[] = [
|
||||
[ICommandService, { useClass: CommandService }],
|
||||
[ILogService, { useClass: DesktopLogService }],
|
||||
[IContextService, { useClass: ContextService }],
|
||||
[IConfigService, { useClass: ConfigService }],
|
||||
[LocaleService, { useClass: TestLocaleService as never }],
|
||||
[RegionService],
|
||||
[IEditorService, { useClass: TestEditorService as never }],
|
||||
[IRenderManagerService, { useClass: TestRenderManagerService as never }],
|
||||
[IShortcutService, { useClass: TestShortcutService as never }],
|
||||
[ISidebarService, { useClass: TestSidebarService as never }],
|
||||
[IUniverInstanceService, { useClass: TestUniverInstanceService as never }],
|
||||
[IThreadCommentDataSourceService, { useClass: ThreadCommentDataSourceService }],
|
||||
[LifecycleService],
|
||||
[UserManagerService],
|
||||
[ThreadCommentModel],
|
||||
[ThreadCommentPanelService],
|
||||
];
|
||||
|
||||
dependencies.forEach((dependency) => injector.add(dependency));
|
||||
injector.get(ILogService).setLogLevel(LogLevel.SILENT);
|
||||
injector.get(LifecycleService).stage = LifecycleStages.Rendered;
|
||||
injector.get(UserManagerService).setCurrentUser({ userID: 'owner', name: 'Owner', avatar: 'owner.png' });
|
||||
|
||||
const commandService = injector.get(ICommandService);
|
||||
[
|
||||
AddCommentCommand,
|
||||
AddCommentMutation,
|
||||
DeleteCommentCommand,
|
||||
DeleteCommentMutation,
|
||||
DeleteCommentTreeCommand,
|
||||
ResolveCommentCommand,
|
||||
ResolveCommentMutation,
|
||||
SetActiveCommentOperation,
|
||||
UpdateCommentCommand,
|
||||
UpdateCommentMutation,
|
||||
].forEach((command) => commandService.registerCommand(command));
|
||||
|
||||
return {
|
||||
injector,
|
||||
panelService: injector.get(ThreadCommentPanelService),
|
||||
threadCommentModel: injector.get(ThreadCommentModel),
|
||||
};
|
||||
}
|
||||
|
||||
function renderPanel(injector: Injector, element: React.ReactElement) {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<RediContext.Provider value={{ injector }}>
|
||||
{element}
|
||||
</RediContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
return { container, root };
|
||||
}
|
||||
|
||||
function renderDefaultPanel(injector: Injector) {
|
||||
return renderPanel(
|
||||
injector,
|
||||
<ThreadCommentPanel
|
||||
unitId={UNIT_ID}
|
||||
subUnitId$={new BehaviorSubject<string | undefined>(SHEET_ID)}
|
||||
type={UniverInstanceType.UNIVER_SHEET}
|
||||
onAdd={() => undefined}
|
||||
getSubUnitName={(subUnitId) => subUnitId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function addRootComment(model: ThreadCommentModel, comment: IThreadComment) {
|
||||
model.addComment(comment.unitId, comment.subUnitId, comment);
|
||||
}
|
||||
|
||||
function getPanelItem(container: HTMLElement, commentId: string) {
|
||||
const item = container.querySelector(`#PANEL-${UNIT_ID}-${SHEET_ID}-${commentId}`);
|
||||
if (!item) {
|
||||
throw new Error(`Panel item "${commentId}" was not found.`);
|
||||
}
|
||||
|
||||
return item as HTMLElement;
|
||||
}
|
||||
|
||||
describe('ThreadCommentPanel', () => {
|
||||
let root: Root | undefined;
|
||||
let container: HTMLElement | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root!.unmount());
|
||||
}
|
||||
container?.remove();
|
||||
root = undefined;
|
||||
container = undefined;
|
||||
});
|
||||
|
||||
it('keeps unresolved comments before resolved comments after applying caller sorting', () => {
|
||||
const testBed = createPanelTestBed();
|
||||
addRootComment(testBed.threadCommentModel, createComment({
|
||||
id: 'open-thread',
|
||||
ref: 'A1',
|
||||
text: transformTextNodes2Document([{ type: 'text', content: 'Open comment' }]),
|
||||
}));
|
||||
addRootComment(testBed.threadCommentModel, createComment({
|
||||
id: 'resolved-thread',
|
||||
ref: 'B2',
|
||||
resolved: true,
|
||||
text: transformTextNodes2Document([{ type: 'text', content: 'Resolved comment' }]),
|
||||
}));
|
||||
|
||||
const rendered = renderPanel(
|
||||
testBed.injector,
|
||||
<ThreadCommentPanel
|
||||
unitId={UNIT_ID}
|
||||
subUnitId$={new BehaviorSubject<string | undefined>(SHEET_ID)}
|
||||
type={UniverInstanceType.UNIVER_SHEET}
|
||||
onAdd={() => undefined}
|
||||
getSubUnitName={(subUnitId) => subUnitId}
|
||||
sortComments={(comments) => comments.slice().reverse()}
|
||||
/>
|
||||
);
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
const panelText = container.textContent ?? '';
|
||||
|
||||
expect(panelText.indexOf('Open comment')).toBeGreaterThan(-1);
|
||||
expect(panelText.indexOf('Resolved comment')).toBeGreaterThan(-1);
|
||||
expect(panelText.indexOf('Open comment')).toBeLessThan(panelText.indexOf('thread-comment-ui.panel.solved'));
|
||||
expect(panelText.indexOf('thread-comment-ui.panel.solved')).toBeLessThan(panelText.indexOf('Resolved comment'));
|
||||
});
|
||||
|
||||
it('auto-focuses the active comment when requested by the host panel', async () => {
|
||||
const testBed = createPanelTestBed();
|
||||
addRootComment(testBed.threadCommentModel, createComment({ id: 'comment-1', ref: 'A1' }));
|
||||
testBed.panelService.setActiveComment({
|
||||
unitId: UNIT_ID,
|
||||
subUnitId: SHEET_ID,
|
||||
commentId: 'comment-1',
|
||||
});
|
||||
|
||||
({ container, root } = renderPanel(
|
||||
testBed.injector,
|
||||
<ThreadCommentPanel
|
||||
unitId={UNIT_ID}
|
||||
subUnitId$={new BehaviorSubject<string | undefined>(SHEET_ID)}
|
||||
type={UniverInstanceType.UNIVER_SHEET}
|
||||
onAdd={() => undefined}
|
||||
getSubUnitName={(subUnitId) => subUnitId}
|
||||
autoFocusActiveComment
|
||||
/>
|
||||
));
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect((testBed.injector.get(IEditorService) as unknown as TestEditorService).getFocusId()).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders and closes the active comment in the mobile detail view', () => {
|
||||
const testBed = createPanelTestBed();
|
||||
addRootComment(testBed.threadCommentModel, createComment({ id: 'comment-1', ref: 'A1' }));
|
||||
testBed.panelService.setActiveComment({
|
||||
unitId: UNIT_ID,
|
||||
subUnitId: SHEET_ID,
|
||||
commentId: 'comment-1',
|
||||
});
|
||||
|
||||
({ container, root } = renderPanel(
|
||||
testBed.injector,
|
||||
<MobileThreadCommentPanel
|
||||
unitId={UNIT_ID}
|
||||
subUnitId$={new BehaviorSubject<string | undefined>(SHEET_ID)}
|
||||
type={UniverInstanceType.UNIVER_SHEET}
|
||||
onAdd={() => undefined}
|
||||
getSubUnitName={(subUnitId) => subUnitId}
|
||||
/>
|
||||
));
|
||||
|
||||
const backButton = container.querySelector<HTMLButtonElement>('[aria-label="thread-comment-ui.mobile.back"]');
|
||||
expect(backButton).not.toBeNull();
|
||||
expect(container.textContent).not.toContain('thread-comment-ui.filter.status.all');
|
||||
|
||||
act(() => backButton!.click());
|
||||
|
||||
expect(testBed.panelService.activeCommentId).toBeUndefined();
|
||||
expect(container.textContent).toContain('thread-comment-ui.filter.status.all');
|
||||
});
|
||||
|
||||
it('keeps an empty-id temporary target active and shows its editor', () => {
|
||||
const testBed = createPanelTestBed();
|
||||
const onTempCommentClose = vi.fn();
|
||||
testBed.panelService.setActiveComment({
|
||||
unitId: UNIT_ID,
|
||||
subUnitId: SHEET_ID,
|
||||
commentId: '',
|
||||
});
|
||||
const rendered = renderPanel(
|
||||
testBed.injector,
|
||||
<ThreadCommentPanel
|
||||
unitId={UNIT_ID}
|
||||
subUnitId$={new BehaviorSubject<string | undefined>(SHEET_ID)}
|
||||
type={UniverInstanceType.UNIVER_SHEET}
|
||||
onAdd={() => undefined}
|
||||
getSubUnitName={(subUnitId) => subUnitId}
|
||||
tempComment={createComment({ id: '', threadId: '', ref: '#shape-1' })}
|
||||
onTempCommentClose={onTempCommentClose}
|
||||
/>
|
||||
);
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
expect(container.textContent).toContain('thread-comment-ui.editor.placeholder');
|
||||
expect(testBed.panelService.activeCommentId).toEqual({
|
||||
unitId: UNIT_ID,
|
||||
subUnitId: SHEET_ID,
|
||||
commentId: '',
|
||||
});
|
||||
|
||||
act(() => {
|
||||
container!.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 }));
|
||||
});
|
||||
expect(testBed.panelService.activeCommentId).toBeUndefined();
|
||||
expect(onTempCommentClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('closes a temporary comment when the active subunit changes', () => {
|
||||
const testBed = createPanelTestBed();
|
||||
const activeSubUnit$ = new BehaviorSubject<string | undefined>(SHEET_ID);
|
||||
const onTempCommentClose = vi.fn();
|
||||
const rendered = renderPanel(
|
||||
testBed.injector,
|
||||
<ThreadCommentPanel
|
||||
unitId={UNIT_ID}
|
||||
subUnitId$={activeSubUnit$}
|
||||
type={UniverInstanceType.UNIVER_SHEET}
|
||||
onAdd={() => undefined}
|
||||
getSubUnitName={(subUnitId) => subUnitId}
|
||||
tempComment={createComment({ id: '', threadId: '', ref: '#shape-1' })}
|
||||
onTempCommentClose={onTempCommentClose}
|
||||
/>
|
||||
);
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
act(() => activeSubUnit$.next('sheet-2'));
|
||||
|
||||
expect(onTempCommentClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('shows only the active subunit and updates when it changes', () => {
|
||||
const testBed = createPanelTestBed();
|
||||
const activeSubUnit$ = new BehaviorSubject<string | undefined>(SHEET_ID);
|
||||
addRootComment(testBed.threadCommentModel, createComment({ id: 'sheet-1-thread' }));
|
||||
addRootComment(testBed.threadCommentModel, createComment({
|
||||
id: 'sheet-2-thread',
|
||||
subUnitId: 'sheet-2',
|
||||
}));
|
||||
const rendered = renderPanel(
|
||||
testBed.injector,
|
||||
<ThreadCommentPanel
|
||||
unitId={UNIT_ID}
|
||||
subUnitId$={activeSubUnit$}
|
||||
type={UniverInstanceType.UNIVER_SLIDE}
|
||||
onAdd={() => undefined}
|
||||
getSubUnitName={(subUnitId) => subUnitId}
|
||||
/>
|
||||
);
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
expect(container.textContent).toContain('sheet-1-thread');
|
||||
expect(container.textContent).not.toContain('sheet-2-thread');
|
||||
|
||||
act(() => activeSubUnit$.next('sheet-2'));
|
||||
|
||||
expect(container.textContent).not.toContain('sheet-1-thread');
|
||||
expect(container.textContent).toContain('sheet-2-thread');
|
||||
});
|
||||
|
||||
it('does not render a temporary comment owned by another unit', () => {
|
||||
const testBed = createPanelTestBed();
|
||||
const onTempCommentClose = vi.fn();
|
||||
const rendered = renderPanel(
|
||||
testBed.injector,
|
||||
<ThreadCommentPanel
|
||||
unitId={UNIT_ID}
|
||||
subUnitId$={new BehaviorSubject<string | undefined>(SHEET_ID)}
|
||||
type={UniverInstanceType.UNIVER_SHEET}
|
||||
onAdd={() => undefined}
|
||||
getSubUnitName={(subUnitId) => subUnitId}
|
||||
tempComment={createComment({ id: '', threadId: '', unitId: 'other-unit' })}
|
||||
onTempCommentClose={onTempCommentClose}
|
||||
/>
|
||||
);
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
expect(container.textContent).not.toContain('thread-comment-ui.editor.placeholder');
|
||||
expect(onTempCommentClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('sets active comment from unresolved panel items and clears it for resolved items', () => {
|
||||
const testBed = createPanelTestBed();
|
||||
addRootComment(testBed.threadCommentModel, createComment({ id: 'open-thread', ref: 'A1' }));
|
||||
addRootComment(testBed.threadCommentModel, createComment({ id: 'resolved-thread', ref: 'B2', resolved: true }));
|
||||
|
||||
const rendered = renderDefaultPanel(testBed.injector);
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
act(() => {
|
||||
getPanelItem(container!, 'open-thread').dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(testBed.panelService.activeCommentId).toEqual({
|
||||
unitId: UNIT_ID,
|
||||
subUnitId: SHEET_ID,
|
||||
commentId: 'open-thread',
|
||||
temp: false,
|
||||
});
|
||||
|
||||
act(() => {
|
||||
getPanelItem(container!, 'resolved-thread').dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(testBed.panelService.activeCommentId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears the active comment when the primary pointer hits a blank area', () => {
|
||||
const testBed = createPanelTestBed();
|
||||
addRootComment(testBed.threadCommentModel, createComment({ id: 'open-thread', ref: 'A1' }));
|
||||
|
||||
const rendered = renderDefaultPanel(testBed.injector);
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
const panelItem = getPanelItem(container, 'open-thread');
|
||||
|
||||
act(() => {
|
||||
panelItem.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
panelItem.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 }));
|
||||
});
|
||||
expect(testBed.panelService.activeCommentId?.commentId).toBe('open-thread');
|
||||
|
||||
act(() => {
|
||||
container!.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 }));
|
||||
});
|
||||
expect(testBed.panelService.activeCommentId).toBeUndefined();
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
document.body.appendChild(canvas);
|
||||
act(() => {
|
||||
panelItem.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
act(() => {
|
||||
canvas.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 }));
|
||||
});
|
||||
expect(testBed.panelService.activeCommentId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('clears the active target when a remote update deletes its thread', () => {
|
||||
const testBed = createPanelTestBed();
|
||||
addRootComment(testBed.threadCommentModel, createComment({ id: 'remote-thread', ref: 'A1' }));
|
||||
const rendered = renderDefaultPanel(testBed.injector);
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
act(() => {
|
||||
getPanelItem(container!, 'remote-thread').dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
expect(testBed.panelService.activeCommentId?.commentId).toBe('remote-thread');
|
||||
|
||||
act(() => {
|
||||
testBed.threadCommentModel.deleteThread(UNIT_ID, SHEET_ID, 'remote-thread');
|
||||
});
|
||||
|
||||
expect(testBed.panelService.activeCommentId).toBeUndefined();
|
||||
});
|
||||
|
||||
it('renders hundreds of comment threads within a bounded time', () => {
|
||||
const testBed = createPanelTestBed();
|
||||
for (let index = 0; index < 250; index += 1) {
|
||||
addRootComment(testBed.threadCommentModel, createComment({ id: `bulk-${index}` }));
|
||||
}
|
||||
|
||||
const startedAt = performance.now();
|
||||
const rendered = renderDefaultPanel(testBed.injector);
|
||||
const elapsed = performance.now() - startedAt;
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
expect(container.querySelectorAll(`[id^="PANEL-${UNIT_ID}-${SHEET_ID}-bulk-"]`)).toHaveLength(250);
|
||||
expect(elapsed).toBeLessThan(5_000);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,814 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Dependency, ICommand, IDisposable, IDocumentData, Nullable } from '@univerjs/core';
|
||||
import type { ISuccinctDocRangeParam } from '@univerjs/engine-render';
|
||||
import type { IThreadComment } from '@univerjs/thread-comment';
|
||||
import type { IShortcutItem } from '@univerjs/ui';
|
||||
import type { Root } from 'react-dom/client';
|
||||
import {
|
||||
CommandService,
|
||||
CommandType,
|
||||
ConfigService,
|
||||
ContextService,
|
||||
createIdentifier,
|
||||
dateKit,
|
||||
DesktopLogService,
|
||||
ICommandService,
|
||||
IConfigService,
|
||||
IContextService,
|
||||
ILogService,
|
||||
Injector,
|
||||
IUniverInstanceService,
|
||||
LifecycleService,
|
||||
LifecycleStages,
|
||||
LOCALE_META,
|
||||
LocaleService,
|
||||
LocaleType,
|
||||
LogLevel,
|
||||
RegionService,
|
||||
toDisposable,
|
||||
UniverInstanceType,
|
||||
UserManagerService,
|
||||
} from '@univerjs/core';
|
||||
import { IEditorService } from '@univerjs/docs-ui';
|
||||
import {
|
||||
AddCommentCommand,
|
||||
AddCommentMutation,
|
||||
DeleteCommentCommand,
|
||||
DeleteCommentMutation,
|
||||
DeleteCommentTreeCommand,
|
||||
IThreadCommentDataSourceService,
|
||||
ResolveCommentCommand,
|
||||
ResolveCommentMutation,
|
||||
ThreadCommentDataSourceService,
|
||||
ThreadCommentModel,
|
||||
UpdateCommentCommand,
|
||||
UpdateCommentMutation,
|
||||
} from '@univerjs/thread-comment';
|
||||
import { IShortcutService, ISidebarService, RediContext } from '@univerjs/ui';
|
||||
import { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BehaviorSubject, Subject } from 'rxjs';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import { SetActiveCommentOperation } from '../../commands/operations/comment.operations';
|
||||
import { ThreadCommentPanelService } from '../../services/thread-comment-panel.service';
|
||||
import { transformTextNodes2Document } from '../thread-comment-editor/util';
|
||||
import { ThreadCommentTree, ThreadCommentTreeLocation } from '../ThreadCommentTree';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const UNIT_ID = 'unit-1';
|
||||
const SHEET_ID = 'sheet-1';
|
||||
const REPLY_LABEL = 'thread-comment-ui.editor.reply';
|
||||
const IRenderManagerService = createIdentifier<TestRenderManagerService>('engine-render.render-manager.service');
|
||||
|
||||
interface IEditorRecord {
|
||||
id: string;
|
||||
data: IDocumentData;
|
||||
selections: ISuccinctDocRangeParam[];
|
||||
focused: boolean;
|
||||
}
|
||||
|
||||
class TestState {
|
||||
static addedComments: IThreadComment[] = [];
|
||||
static resolveStates: boolean[] = [];
|
||||
|
||||
static reset() {
|
||||
this.addedComments = [];
|
||||
this.resolveStates = [];
|
||||
}
|
||||
}
|
||||
|
||||
class TestLocaleService {
|
||||
private readonly _currentLocale$ = new BehaviorSubject<LocaleType>(LocaleType.ZH_CN);
|
||||
readonly currentLocale$ = this._currentLocale$.asObservable();
|
||||
private readonly _direction$ = new BehaviorSubject<'ltr' | 'rtl'>('ltr');
|
||||
readonly direction$ = this._direction$.asObservable();
|
||||
|
||||
t(key: string) {
|
||||
return key;
|
||||
}
|
||||
|
||||
setLocale(locale: LocaleType) {
|
||||
this._currentLocale$.next(locale);
|
||||
}
|
||||
|
||||
getCurrentLocale() {
|
||||
return this._currentLocale$.getValue();
|
||||
}
|
||||
|
||||
setDirection(direction: 'ltr' | 'rtl') {
|
||||
this._direction$.next(direction);
|
||||
}
|
||||
|
||||
getDirection() {
|
||||
return this._direction$.getValue();
|
||||
}
|
||||
}
|
||||
|
||||
class TestEditor {
|
||||
readonly input$ = new Subject<unknown>();
|
||||
readonly paste$ = new Subject<unknown>();
|
||||
readonly focus$ = new Subject<unknown>();
|
||||
readonly blur$ = new Subject<unknown>();
|
||||
readonly change$ = new Subject<unknown>();
|
||||
readonly selectionChange$ = new Subject<unknown>();
|
||||
|
||||
readonly render = {
|
||||
isDisposed: () => false,
|
||||
with: () => ({
|
||||
getSkeleton: () => ({
|
||||
getActualSize: () => ({ actualWidth: 0, actualHeight: 0 }),
|
||||
}),
|
||||
getViewModel: () => ({
|
||||
getDataModel: () => ({
|
||||
updateDocumentDataPageSize: () => undefined,
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
scene: {
|
||||
transformByState: () => undefined,
|
||||
getViewport: () => undefined,
|
||||
},
|
||||
mainComponent: {
|
||||
resize: () => undefined,
|
||||
translate: () => undefined,
|
||||
},
|
||||
components: {
|
||||
get: () => undefined,
|
||||
},
|
||||
};
|
||||
|
||||
constructor(private readonly _record: IEditorRecord) {
|
||||
}
|
||||
|
||||
isFocus() {
|
||||
return this._record.focused;
|
||||
}
|
||||
|
||||
focus(): void {
|
||||
this._record.focused = true;
|
||||
this.focus$.next(undefined);
|
||||
}
|
||||
|
||||
blur(): void {
|
||||
this._record.focused = false;
|
||||
this.blur$.next(undefined);
|
||||
}
|
||||
|
||||
select(): void {
|
||||
}
|
||||
|
||||
setSelectionRanges(ranges: ISuccinctDocRangeParam[]): void {
|
||||
this._record.selections = ranges;
|
||||
this.selectionChange$.next(undefined);
|
||||
}
|
||||
|
||||
getSelectionRanges() {
|
||||
return this._record.selections;
|
||||
}
|
||||
|
||||
getEditorId() {
|
||||
return this._record.id;
|
||||
}
|
||||
|
||||
getDocumentData() {
|
||||
return this._record.data;
|
||||
}
|
||||
|
||||
setDocumentData(data: IDocumentData, ranges: ISuccinctDocRangeParam[] = []): void {
|
||||
this._record.data = data;
|
||||
this._record.selections = ranges;
|
||||
this.selectionChange$.next(undefined);
|
||||
}
|
||||
|
||||
replaceText(value: string): void {
|
||||
this._record.data = {
|
||||
...this._record.data,
|
||||
body: {
|
||||
dataStream: `${value}\r\n`,
|
||||
},
|
||||
};
|
||||
this.selectionChange$.next(undefined);
|
||||
}
|
||||
|
||||
clearUndoRedoHistory(): void {
|
||||
}
|
||||
|
||||
getValue() {
|
||||
return this._record.data.body?.dataStream ?? '';
|
||||
}
|
||||
|
||||
isSheetEditor() {
|
||||
return false;
|
||||
}
|
||||
|
||||
getBoundingClientRect() {
|
||||
return { width: 320, height: 64 };
|
||||
}
|
||||
}
|
||||
|
||||
class TestEditorService {
|
||||
private readonly _editors = new Map<string, TestEditor>();
|
||||
private _focusId: Nullable<string>;
|
||||
readonly blur$ = new Subject<unknown>();
|
||||
readonly focus$ = new Subject<ISuccinctDocRangeParam>();
|
||||
|
||||
getEditor(id?: string): Nullable<TestEditor> {
|
||||
return id ? this._editors.get(id) : undefined;
|
||||
}
|
||||
|
||||
register(config: { initialSnapshot: IDocumentData }, _container: HTMLDivElement): IDisposable {
|
||||
const snapshot = config.initialSnapshot;
|
||||
const record: IEditorRecord = {
|
||||
id: snapshot.id,
|
||||
data: snapshot,
|
||||
selections: [],
|
||||
focused: false,
|
||||
};
|
||||
const editor = new TestEditor(record);
|
||||
this._editors.set(record.id, editor);
|
||||
|
||||
return toDisposable(() => {
|
||||
this._editors.delete(record.id);
|
||||
});
|
||||
}
|
||||
|
||||
getAllEditor() {
|
||||
return this._editors;
|
||||
}
|
||||
|
||||
isEditor(editorUnitId: string) {
|
||||
return this._editors.has(editorUnitId);
|
||||
}
|
||||
|
||||
getEditorRenderConfig() {
|
||||
return null;
|
||||
}
|
||||
|
||||
isSheetEditor() {
|
||||
return false;
|
||||
}
|
||||
|
||||
blur(): void {
|
||||
this.getFocusEditor()?.blur();
|
||||
this._focusId = null;
|
||||
this.blur$.next(undefined);
|
||||
}
|
||||
|
||||
focus(editorUnitId: string): void {
|
||||
this._focusId = editorUnitId;
|
||||
this.getEditor(editorUnitId)?.focus();
|
||||
this.focus$.next({ startOffset: 0, endOffset: 0 });
|
||||
}
|
||||
|
||||
getFocusId() {
|
||||
return this._focusId;
|
||||
}
|
||||
|
||||
getFocusEditor() {
|
||||
return this._focusId ? this.getEditor(this._focusId) : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
class TestRenderManagerService {
|
||||
readonly created$ = new Subject<unknown>();
|
||||
readonly disposed$ = new Subject<string>();
|
||||
readonly defaultEngine = {};
|
||||
|
||||
addRender(): void {}
|
||||
createRender() { return undefined; }
|
||||
removeRender(): void {}
|
||||
getRenderUnitById() { return undefined; }
|
||||
getAllRenderersOfType() { return []; }
|
||||
getRenderAll() { return new Map(); }
|
||||
create(): void {}
|
||||
has() { return false; }
|
||||
registerRenderModule(): IDisposable { return toDisposable(() => undefined); }
|
||||
dispose(): void {}
|
||||
}
|
||||
|
||||
class TestShortcutService {
|
||||
readonly shortcutChanged$ = new Subject<void>();
|
||||
readonly shortcuts: IShortcutItem[] = [];
|
||||
|
||||
forceEscape(): IDisposable { return toDisposable(() => undefined); }
|
||||
forceDisable(): IDisposable { return toDisposable(() => undefined); }
|
||||
dispatch(): undefined { return undefined; }
|
||||
registerShortcut(shortcut: IShortcutItem): IDisposable {
|
||||
this.shortcuts.push(shortcut);
|
||||
return toDisposable(() => undefined);
|
||||
}
|
||||
|
||||
getShortcutDisplay(): string | null { return null; }
|
||||
getShortcutDisplayOfCommand(): string | null { return null; }
|
||||
getAllShortcuts(): IShortcutItem[] { return this.shortcuts; }
|
||||
}
|
||||
|
||||
class TestSidebarService {
|
||||
readonly sidebarOptions$ = new BehaviorSubject({ visible: true });
|
||||
readonly scrollEvent$ = new Subject<Event>();
|
||||
private _container: HTMLElement | undefined;
|
||||
private _width: number | undefined;
|
||||
|
||||
open(): IDisposable { return toDisposable(() => undefined); }
|
||||
close(): void {
|
||||
this.sidebarOptions$.next({ visible: false });
|
||||
}
|
||||
|
||||
get visible() { return this.sidebarOptions$.getValue().visible; }
|
||||
get options() { return this.sidebarOptions$.getValue(); }
|
||||
getContainer() { return this._container; }
|
||||
setContainer(element?: HTMLElement): void { this._container = element; }
|
||||
get width() { return this._width; }
|
||||
setWidth(value: number): void { this._width = value; }
|
||||
}
|
||||
|
||||
class TestUniverInstanceService {
|
||||
getCurrentTypeOfUnit$() {
|
||||
return new BehaviorSubject({ id: 'workbook' });
|
||||
}
|
||||
|
||||
setCurrentUnitForType(): void {
|
||||
}
|
||||
}
|
||||
|
||||
const TestRichTextEditingMutation: ICommand = {
|
||||
id: 'doc.mutation.rich-text-editing',
|
||||
type: CommandType.MUTATION,
|
||||
handler: () => true,
|
||||
};
|
||||
|
||||
function createComment(overrides: Partial<IThreadComment>): IThreadComment {
|
||||
const id = overrides.id ?? 'thread-1';
|
||||
|
||||
return {
|
||||
id,
|
||||
threadId: overrides.threadId ?? id,
|
||||
ref: overrides.ref ?? 'A1',
|
||||
dT: overrides.dT ?? '2026-06-17T00:00:00.000Z',
|
||||
personId: overrides.personId ?? 'owner',
|
||||
text: overrides.text ?? transformTextNodes2Document([{ type: 'text', content: id }]),
|
||||
unitId: overrides.unitId ?? UNIT_ID,
|
||||
subUnitId: overrides.subUnitId ?? SHEET_ID,
|
||||
attachments: overrides.attachments,
|
||||
children: overrides.children,
|
||||
mentions: overrides.mentions,
|
||||
parentId: overrides.parentId,
|
||||
resolved: overrides.resolved,
|
||||
updateT: overrides.updateT,
|
||||
updated: overrides.updated,
|
||||
};
|
||||
}
|
||||
|
||||
function createTreeTestBed() {
|
||||
const injector = new Injector();
|
||||
const dependencies: Dependency[] = [
|
||||
[ICommandService, { useClass: CommandService }],
|
||||
[ILogService, { useClass: DesktopLogService }],
|
||||
[IContextService, { useClass: ContextService }],
|
||||
[IConfigService, { useClass: ConfigService }],
|
||||
[LocaleService, { useClass: TestLocaleService as never }],
|
||||
[RegionService],
|
||||
[IEditorService, { useClass: TestEditorService as never }],
|
||||
[IRenderManagerService, { useClass: TestRenderManagerService as never }],
|
||||
[IShortcutService, { useClass: TestShortcutService as never }],
|
||||
[ISidebarService, { useClass: TestSidebarService as never }],
|
||||
[IUniverInstanceService, { useClass: TestUniverInstanceService as never }],
|
||||
[IThreadCommentDataSourceService, { useClass: ThreadCommentDataSourceService }],
|
||||
[LifecycleService],
|
||||
[UserManagerService],
|
||||
[ThreadCommentModel],
|
||||
[ThreadCommentPanelService],
|
||||
];
|
||||
|
||||
dependencies.forEach((dependency) => injector.add(dependency));
|
||||
injector.get(ILogService).setLogLevel(LogLevel.SILENT);
|
||||
injector.get(LifecycleService).stage = LifecycleStages.Rendered;
|
||||
injector.get(UserManagerService).setCurrentUser({ userID: 'owner', name: 'Owner', avatar: 'owner.png' });
|
||||
|
||||
const commandService = injector.get(ICommandService);
|
||||
[
|
||||
AddCommentCommand,
|
||||
AddCommentMutation,
|
||||
DeleteCommentCommand,
|
||||
DeleteCommentMutation,
|
||||
DeleteCommentTreeCommand,
|
||||
ResolveCommentCommand,
|
||||
ResolveCommentMutation,
|
||||
SetActiveCommentOperation,
|
||||
UpdateCommentCommand,
|
||||
UpdateCommentMutation,
|
||||
TestRichTextEditingMutation,
|
||||
].forEach((command) => commandService.registerCommand(command));
|
||||
|
||||
return {
|
||||
commandService,
|
||||
injector,
|
||||
editorService: injector.get(IEditorService),
|
||||
panelService: injector.get(ThreadCommentPanelService),
|
||||
threadCommentModel: injector.get(ThreadCommentModel),
|
||||
};
|
||||
}
|
||||
|
||||
function renderTree(injector: Injector, element: React.ReactElement) {
|
||||
const container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
const root = createRoot(container);
|
||||
|
||||
act(() => {
|
||||
root.render(
|
||||
<RediContext.Provider value={{ injector }}>
|
||||
{element}
|
||||
</RediContext.Provider>
|
||||
);
|
||||
});
|
||||
|
||||
return { container, root };
|
||||
}
|
||||
|
||||
function renderDefaultTree(injector: Injector, commentId?: string) {
|
||||
return renderTree(
|
||||
injector,
|
||||
<ThreadCommentTree
|
||||
id={commentId}
|
||||
unitId={UNIT_ID}
|
||||
subUnitId={SHEET_ID}
|
||||
refStr="A1"
|
||||
type={UniverInstanceType.UNIVER_SHEET}
|
||||
getSubUnitName={(subUnitId) => subUnitId}
|
||||
location={ThreadCommentTreeLocation.PANEL}
|
||||
onAddComment={(comment) => {
|
||||
TestState.addedComments.push(comment);
|
||||
return true;
|
||||
}}
|
||||
onResolve={(resolved) => {
|
||||
TestState.resolveStates.push(resolved);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function getTree(container: HTMLElement, commentId: string) {
|
||||
const item = container.querySelector(`#PANEL-${UNIT_ID}-${SHEET_ID}-${commentId}`);
|
||||
if (!item) {
|
||||
throw new Error(`Tree item "${commentId}" was not found.`);
|
||||
}
|
||||
|
||||
return item as HTMLElement;
|
||||
}
|
||||
|
||||
function getButton(container: HTMLElement, text: string): HTMLButtonElement {
|
||||
const button = Array.from(container.querySelectorAll('button')).find((item) => item.textContent?.trim() === text);
|
||||
if (!button) {
|
||||
throw new Error(`Button "${text}" was not found.`);
|
||||
}
|
||||
|
||||
return button as HTMLButtonElement;
|
||||
}
|
||||
|
||||
function getResolveAction(container: HTMLElement, commentId: string) {
|
||||
const tree = getTree(container, commentId);
|
||||
const header = tree.firstElementChild as HTMLElement | null;
|
||||
const controls = header?.lastElementChild as HTMLElement | null;
|
||||
const action = controls?.firstElementChild;
|
||||
if (!action) {
|
||||
throw new Error('Resolve action was not found.');
|
||||
}
|
||||
|
||||
return action as HTMLElement;
|
||||
}
|
||||
|
||||
function getRootCommentItem(container: HTMLElement, commentId: string) {
|
||||
const scroller = getTree(container, commentId).children[1];
|
||||
const item = scroller?.firstElementChild;
|
||||
if (!item) {
|
||||
throw new Error('Root comment item was not found.');
|
||||
}
|
||||
|
||||
return item as HTMLElement;
|
||||
}
|
||||
|
||||
function getReplyAction(container: HTMLElement, commentId: string) {
|
||||
const item = getRootCommentItem(container, commentId);
|
||||
const nameRow = item.children[1] as HTMLElement | undefined;
|
||||
const controls = nameRow?.lastElementChild as HTMLElement | undefined;
|
||||
const action = controls?.firstElementChild;
|
||||
if (!action) {
|
||||
throw new Error('Reply action was not found.');
|
||||
}
|
||||
|
||||
return action as HTMLElement;
|
||||
}
|
||||
|
||||
function getMoreAction(container: HTMLElement, commentId: string) {
|
||||
const item = getRootCommentItem(container, commentId);
|
||||
const nameRow = item.children[1] as HTMLElement | undefined;
|
||||
const controls = nameRow?.lastElementChild as HTMLElement | undefined;
|
||||
const action = controls?.lastElementChild;
|
||||
if (!action) {
|
||||
throw new Error('More action was not found.');
|
||||
}
|
||||
|
||||
return action as HTMLElement;
|
||||
}
|
||||
|
||||
function getOnlyEditor(testBed: ReturnType<typeof createTreeTestBed>) {
|
||||
const editors = Array.from(testBed.editorService.getAllEditor().values());
|
||||
if (editors.length !== 1) {
|
||||
throw new Error(`Expected one editor, received ${editors.length}.`);
|
||||
}
|
||||
|
||||
return editors[0];
|
||||
}
|
||||
|
||||
function addRootComment(model: ThreadCommentModel, comment: IThreadComment) {
|
||||
model.addComment(comment.unitId, comment.subUnitId, comment);
|
||||
}
|
||||
|
||||
function waitForTreeRefresh() {
|
||||
return new Promise((resolve) => setTimeout(resolve, 40));
|
||||
}
|
||||
|
||||
function waitForFrame() {
|
||||
return new Promise((resolve) => setTimeout(resolve, 16));
|
||||
}
|
||||
|
||||
describe('ThreadCommentTree', () => {
|
||||
let root: Root | undefined;
|
||||
let container: HTMLElement | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = '';
|
||||
TestState.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (root) {
|
||||
act(() => root!.unmount());
|
||||
}
|
||||
container?.remove();
|
||||
root = undefined;
|
||||
container = undefined;
|
||||
});
|
||||
|
||||
it('keeps timestamps left-to-right inside RTL comment rows', () => {
|
||||
const testBed = createTreeTestBed();
|
||||
addRootComment(testBed.threadCommentModel, createComment({ id: 'root-thread', ref: 'A1', dT: '2026/07/07 20:35' }));
|
||||
|
||||
const rendered = renderDefaultTree(testBed.injector, 'root-thread');
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
const item = getRootCommentItem(container, 'root-thread');
|
||||
const time = item.querySelector('time');
|
||||
|
||||
expect(time?.querySelector('bdo')?.getAttribute('dir')).toBe('ltr');
|
||||
});
|
||||
|
||||
it('right-aligns the comment action menu in RTL', () => {
|
||||
const testBed = createTreeTestBed();
|
||||
const localeService = testBed.injector.get(LocaleService);
|
||||
addRootComment(testBed.threadCommentModel, createComment({ id: 'root-thread', ref: 'A1' }));
|
||||
|
||||
const rendered = renderDefaultTree(testBed.injector, 'root-thread');
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
act(() => localeService.setDirection('rtl'));
|
||||
act(() => getMoreAction(container!, 'root-thread').click());
|
||||
|
||||
const menu = document.body.querySelector('[data-slot="popover-content"] ul');
|
||||
|
||||
expect(menu).not.toBeNull();
|
||||
expect(menu?.parentElement?.getAttribute('dir')).toBe('rtl');
|
||||
});
|
||||
|
||||
it('formats comment timestamps with dateKit intl using the current region', () => {
|
||||
const testBed = createTreeTestBed();
|
||||
const rawDate = '2026/07/07 20:35';
|
||||
testBed.injector.get(LocaleService).setLocale(LocaleType.ZH_CN);
|
||||
testBed.injector.get(RegionService).setRegion(LocaleType.EN_US);
|
||||
addRootComment(testBed.threadCommentModel, createComment({ id: 'root-thread', ref: 'A1', dT: rawDate }));
|
||||
|
||||
const rendered = renderDefaultTree(testBed.injector, 'root-thread');
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
const time = getRootCommentItem(container, 'root-thread').querySelector('time');
|
||||
const expected = dateKit(rawDate).formatIntl(LOCALE_META[LocaleType.EN_US].tag, {
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
|
||||
expect(time?.textContent).toBe(expected);
|
||||
expect(time?.textContent).not.toBe(rawDate);
|
||||
});
|
||||
|
||||
it.each([LocaleType.AR_SA, LocaleType.FA_IR])('uses Latin digits for %s comment timestamps', (region) => {
|
||||
const testBed = createTreeTestBed();
|
||||
const rawDate = '2026/07/07 20:35';
|
||||
testBed.injector.get(RegionService).setRegion(region);
|
||||
addRootComment(testBed.threadCommentModel, createComment({ id: 'root-thread', ref: 'A1', dT: rawDate }));
|
||||
|
||||
const rendered = renderDefaultTree(testBed.injector, 'root-thread');
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
const dateText = getRootCommentItem(container, 'root-thread').querySelector('time')?.textContent ?? '';
|
||||
|
||||
expect(dateText).toMatch(/[0-9]/);
|
||||
expect(dateText).not.toMatch(/[\u0660-\u0669\u06F0-\u06F9]/u);
|
||||
});
|
||||
|
||||
it('does not render a timestamp for a new comment', () => {
|
||||
const testBed = createTreeTestBed();
|
||||
|
||||
const rendered = renderDefaultTree(testBed.injector);
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
expect(container.querySelector('time')).toBeNull();
|
||||
});
|
||||
|
||||
it('treats HTML-looking comment content as text instead of DOM', () => {
|
||||
const testBed = createTreeTestBed();
|
||||
addRootComment(testBed.threadCommentModel, createComment({
|
||||
id: 'untrusted-thread',
|
||||
text: { dataStream: '<img src=x onerror=alert(1)><script>alert(2)</script>\r\n' },
|
||||
}));
|
||||
|
||||
const rendered = renderDefaultTree(testBed.injector, 'untrusted-thread');
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
expect(container.querySelector('img[src="x"]')).toBeNull();
|
||||
expect(container.querySelector('script')).toBeNull();
|
||||
});
|
||||
|
||||
it('exposes icon-only thread actions as labeled keyboard controls', () => {
|
||||
const testBed = createTreeTestBed();
|
||||
addRootComment(testBed.threadCommentModel, createComment({ id: 'accessible-thread' }));
|
||||
|
||||
const rendered = renderDefaultTree(testBed.injector, 'accessible-thread');
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
expect(container.querySelector('button[aria-label="thread-comment-ui.item.more"]')).not.toBeNull();
|
||||
expect(container.querySelector('button[aria-label="thread-comment-ui.filter.status.resolved"]')).not.toBeNull();
|
||||
expect(container.querySelector('button[aria-label="thread-comment-ui.item.delete"]')).not.toBeNull();
|
||||
});
|
||||
|
||||
it('keeps comment content visible when its author is no longer in the user directory', () => {
|
||||
const testBed = createTreeTestBed();
|
||||
addRootComment(testBed.threadCommentModel, createComment({
|
||||
id: 'removed-author-thread',
|
||||
personId: 'removed-user',
|
||||
text: transformTextNodes2Document([{ type: 'text', content: 'kept after user removal' }]),
|
||||
}));
|
||||
|
||||
const rendered = renderDefaultTree(testBed.injector, 'removed-author-thread');
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
expect(container.textContent).toContain('kept after user removal');
|
||||
});
|
||||
|
||||
it('adds a reply through the tree editor and stores it under the root thread', async () => {
|
||||
const testBed = createTreeTestBed();
|
||||
addRootComment(testBed.threadCommentModel, createComment({ id: 'root-thread', ref: 'A1' }));
|
||||
|
||||
const rendered = renderDefaultTree(testBed.injector, 'root-thread');
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
await act(async () => {
|
||||
getRootCommentItem(container!, 'root-thread').dispatchEvent(new MouseEvent('mouseover', { bubbles: true }));
|
||||
await waitForFrame();
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getReplyAction(container!, 'root-thread').dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await waitForFrame();
|
||||
});
|
||||
|
||||
act(() => {
|
||||
getOnlyEditor(testBed).replaceText('Reply from tree');
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getButton(container!, REPLY_LABEL).dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await waitForTreeRefresh();
|
||||
});
|
||||
|
||||
const thread = testBed.threadCommentModel.getCommentWithChildren(UNIT_ID, SHEET_ID, 'root-thread');
|
||||
expect(thread?.children).toHaveLength(1);
|
||||
expect(thread?.children[0].parentId).toBe('root-thread');
|
||||
expect(thread?.children[0].threadId).toBe('root-thread');
|
||||
expect(thread?.children[0].personId).toBe('owner');
|
||||
expect(thread?.children[0].text.dataStream).toBe('Reply from tree\r\n');
|
||||
expect(TestState.addedComments).toHaveLength(1);
|
||||
expect(TestState.addedComments[0].parentId).toBe('root-thread');
|
||||
});
|
||||
|
||||
it('awaits a product-handled root add and closes the temporary thread without adding it twice', async () => {
|
||||
const testBed = createTreeTestBed();
|
||||
let closeCount = 0;
|
||||
const handledComments: IThreadComment[] = [];
|
||||
const rendered = renderTree(
|
||||
testBed.injector,
|
||||
<ThreadCommentTree
|
||||
unitId={UNIT_ID}
|
||||
subUnitId={SHEET_ID}
|
||||
refStr="A1"
|
||||
type={UniverInstanceType.UNIVER_SHEET}
|
||||
getSubUnitName={() => ''}
|
||||
location={ThreadCommentTreeLocation.PANEL}
|
||||
onAddComment={async (comment) => {
|
||||
await Promise.resolve();
|
||||
handledComments.push(comment);
|
||||
return false;
|
||||
}}
|
||||
onClose={() => {
|
||||
closeCount += 1;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
await act(async () => {
|
||||
const editorMouseTarget = container!.lastElementChild?.lastElementChild?.firstElementChild?.firstElementChild;
|
||||
if (!editorMouseTarget) {
|
||||
throw new Error('Editor mouse target was not found.');
|
||||
}
|
||||
editorMouseTarget.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }));
|
||||
const editor = getOnlyEditor(testBed);
|
||||
editor.replaceText('Handled by host');
|
||||
await testBed.commandService.executeCommand(TestRichTextEditingMutation.id, { unitId: editor.getEditorId() });
|
||||
await waitForFrame();
|
||||
});
|
||||
await act(async () => {
|
||||
getButton(container!, REPLY_LABEL).dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await waitForTreeRefresh();
|
||||
});
|
||||
|
||||
expect(handledComments).toHaveLength(1);
|
||||
expect(testBed.threadCommentModel.getUnit(UNIT_ID)).toEqual([]);
|
||||
expect(closeCount).toBe(1);
|
||||
});
|
||||
|
||||
it('toggles the resolved state and updates the active comment target', async () => {
|
||||
const testBed = createTreeTestBed();
|
||||
addRootComment(testBed.threadCommentModel, createComment({ id: 'root-thread', ref: 'A1' }));
|
||||
testBed.panelService.setActiveComment({
|
||||
unitId: UNIT_ID,
|
||||
subUnitId: SHEET_ID,
|
||||
commentId: 'root-thread',
|
||||
});
|
||||
|
||||
const rendered = renderDefaultTree(testBed.injector, 'root-thread');
|
||||
root = rendered.root;
|
||||
container = rendered.container;
|
||||
|
||||
await act(async () => {
|
||||
getResolveAction(container!, 'root-thread').dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await waitForTreeRefresh();
|
||||
});
|
||||
|
||||
expect(testBed.threadCommentModel.getComment(UNIT_ID, SHEET_ID, 'root-thread')?.resolved).toBe(true);
|
||||
expect(testBed.panelService.activeCommentId).toBeUndefined();
|
||||
expect(TestState.resolveStates).toEqual([true]);
|
||||
|
||||
await act(async () => {
|
||||
getResolveAction(container!, 'root-thread').dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await waitForTreeRefresh();
|
||||
});
|
||||
|
||||
expect(testBed.threadCommentModel.getComment(UNIT_ID, SHEET_ID, 'root-thread')?.resolved).toBe(false);
|
||||
expect(testBed.panelService.activeCommentId).toEqual({
|
||||
unitId: UNIT_ID,
|
||||
subUnitId: SHEET_ID,
|
||||
commentId: 'root-thread',
|
||||
});
|
||||
expect(TestState.resolveStates).toEqual([true, false]);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import type { IThreadCommentEditorInstance, IThreadCommentEditorProps } from '../ThreadCommentEditor';
|
||||
import { MobileActionRowGroup } from '@univerjs/design';
|
||||
import { forwardRef } from 'react';
|
||||
import { ThreadCommentEditor } from '../ThreadCommentEditor';
|
||||
|
||||
export const MobileThreadCommentEditor = forwardRef<IThreadCommentEditorInstance, IThreadCommentEditorProps>(
|
||||
(props, ref) => <ThreadCommentEditor {...props} ref={ref} ActionRowComponent={MobileActionRowGroup} />
|
||||
);
|
||||
@@ -0,0 +1,384 @@
|
||||
/**
|
||||
* 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 { ISelectProps } from '@univerjs/design';
|
||||
import type { IThreadComment } from '@univerjs/thread-comment';
|
||||
import type { LocaleKey } from '../../locale/types';
|
||||
import type { ThreadCommentPanelSection } from '../thread-comment-panel/util';
|
||||
import type { IThreadCommentPanelProps } from '../ThreadCommentPanel';
|
||||
import { ICommandService, LocaleService, UniverInstanceType, UserManagerService } from '@univerjs/core';
|
||||
import { Button, clsx, MobileActionRowGroup, resetButtonClassName } from '@univerjs/design';
|
||||
import { IncreaseIcon, MoreLeftIcon } from '@univerjs/icons';
|
||||
import { ThreadCommentModel } from '@univerjs/thread-comment';
|
||||
import { useDependency, useObservable } from '@univerjs/ui';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { SetActiveCommentOperation } from '../../commands/operations/comment.operations';
|
||||
import { ThreadCommentPanelService } from '../../services/thread-comment-panel.service';
|
||||
import { getThreadCommentPanelItemKey, isSameThreadCommentTarget, shouldClearThreadCommentTarget } from '../thread-comment-panel/util';
|
||||
import { ThreadCommentTreeLocation } from '../ThreadCommentTree';
|
||||
import { MobileThreadCommentTree } from './MobileThreadCommentTree';
|
||||
|
||||
interface IThreadCommentWithUsers extends IThreadComment {
|
||||
users: Set<string>;
|
||||
}
|
||||
|
||||
function MobileCommentFilter({ value, options = [], onChange }: ISelectProps) {
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
univer-flex univer-gap-1 univer-overflow-x-auto univer-rounded-xl univer-bg-gray-100 univer-p-1
|
||||
dark:!univer-bg-gray-800
|
||||
"
|
||||
>
|
||||
{options.flatMap((option) => option.options ?? [option]).map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
type="button"
|
||||
aria-pressed={option.value === value}
|
||||
className={clsx(resetButtonClassName, `
|
||||
univer-h-10 univer-shrink-0 univer-rounded-lg univer-px-3 univer-text-sm univer-text-gray-700
|
||||
active:univer-scale-95
|
||||
dark:!univer-text-gray-200
|
||||
`, option.value === value && `
|
||||
univer-bg-gray-0 univer-font-medium univer-text-primary-600 univer-shadow-sm
|
||||
dark:!univer-bg-gray-700 dark:!univer-text-primary-300
|
||||
`)}
|
||||
onClick={() => option.value != null && onChange?.(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileCommentHeader({ comment: activeComment, displayRef }: { comment: IThreadComment; displayRef?: string }) {
|
||||
const localeService = useDependency(LocaleService);
|
||||
const commandService = useDependency(ICommandService);
|
||||
return (
|
||||
<div
|
||||
className="
|
||||
univer-mt-2 univer-flex univer-min-h-12 univer-items-center univer-gap-2 univer-border-0 univer-border-b
|
||||
univer-border-solid univer-border-gray-200 univer-pb-2
|
||||
dark:!univer-border-gray-700
|
||||
"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={localeService.t<LocaleKey>('thread-comment-ui.mobile.back')}
|
||||
className={`
|
||||
${resetButtonClassName}
|
||||
univer-flex univer-size-12 univer-shrink-0 univer-items-center univer-justify-center univer-rounded-xl
|
||||
univer-text-2xl univer-text-gray-700
|
||||
active:univer-bg-gray-100
|
||||
dark:!univer-text-gray-200
|
||||
dark:active:!univer-bg-gray-700
|
||||
`}
|
||||
onClick={() => commandService.executeCommand(SetActiveCommentOperation.id)}
|
||||
>
|
||||
<MoreLeftIcon />
|
||||
</button>
|
||||
<div className="univer-min-w-0 univer-flex-1">
|
||||
<div
|
||||
className="
|
||||
univer-truncate univer-text-base univer-font-semibold univer-text-gray-900
|
||||
dark:!univer-text-gray-0
|
||||
"
|
||||
>
|
||||
{localeService.t<LocaleKey>('thread-comment-ui.panel.title')}
|
||||
</div>
|
||||
{activeComment.ref && (
|
||||
<div
|
||||
className="
|
||||
univer-truncate univer-text-xs univer-text-gray-500
|
||||
dark:!univer-text-gray-400
|
||||
"
|
||||
>
|
||||
{displayRef ?? activeComment.ref}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MobileAddComment({ onAdd }: { onAdd: () => void }) {
|
||||
const localeService = useDependency(LocaleService);
|
||||
return (
|
||||
<MobileActionRowGroup className="univer-mt-3">
|
||||
<Button className="univer-w-full" onClick={onAdd}>
|
||||
<IncreaseIcon className="univer-mr-1.5" />
|
||||
{localeService.t<LocaleKey>('thread-comment-ui.panel.addComment')}
|
||||
</Button>
|
||||
</MobileActionRowGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export function MobileThreadCommentPanel(props: IThreadCommentPanelProps) {
|
||||
const {
|
||||
unitId,
|
||||
subUnitId$,
|
||||
type,
|
||||
onAdd,
|
||||
getSubUnitName,
|
||||
onResolve,
|
||||
sortComments,
|
||||
onItemLeave,
|
||||
onItemEnter,
|
||||
disableAdd,
|
||||
tempComment,
|
||||
onAddComment,
|
||||
onDeleteComment,
|
||||
onAfterDeleteComment,
|
||||
showComments,
|
||||
formatRef,
|
||||
onTempCommentClose,
|
||||
autoFocusActiveComment = false,
|
||||
} = props;
|
||||
const [unit, setUnit] = useState('all');
|
||||
const [status, setStatus] = useState('all');
|
||||
const localeService = useDependency(LocaleService);
|
||||
const userService = useDependency(UserManagerService);
|
||||
const threadCommentModel = useDependency(ThreadCommentModel);
|
||||
const panelService = useDependency(ThreadCommentPanelService);
|
||||
const commandService = useDependency(ICommandService);
|
||||
const activeCommentId = useObservable(panelService.activeCommentId$);
|
||||
useObservable(threadCommentModel.commentUpdate$);
|
||||
const subUnitId = useObservable(subUnitId$);
|
||||
const currentUser = useObservable(userService.currentUser$);
|
||||
const shouldScrollRef = useRef(true);
|
||||
const location = ThreadCommentTreeLocation.PANEL;
|
||||
const unitComments = threadCommentModel.getUnit(unitId);
|
||||
const scopedTempComment = tempComment?.unitId === unitId ? tempComment : null;
|
||||
const activeExists = activeCommentId
|
||||
? Boolean(
|
||||
threadCommentModel.getComment(activeCommentId.unitId, activeCommentId.subUnitId, activeCommentId.commentId) ||
|
||||
(scopedTempComment && isSameThreadCommentTarget(activeCommentId, scopedTempComment))
|
||||
)
|
||||
: false;
|
||||
const shouldClearActiveTarget = shouldClearThreadCommentTarget(activeCommentId, unitId, activeExists);
|
||||
const comments = useMemo(() => {
|
||||
const allComments = unit === 'all' && type !== UniverInstanceType.UNIVER_SLIDE
|
||||
? unitComments
|
||||
: unitComments.filter((comment) => comment.subUnitId === subUnitId);
|
||||
const sortedComments = sortComments ?? ((commentList) => commentList);
|
||||
const result: IThreadCommentWithUsers[] = allComments.map((comment) => ({
|
||||
...comment.root,
|
||||
children: comment.children ?? [],
|
||||
users: comment.relativeUsers,
|
||||
}));
|
||||
|
||||
if (!showComments) {
|
||||
return sortedComments(result) as IThreadCommentWithUsers[];
|
||||
}
|
||||
|
||||
const commentMap = new Map(result.map((comment) => [comment.id, comment]));
|
||||
return [...showComments, ''].map((id) => commentMap.get(id)).filter(Boolean) as IThreadCommentWithUsers[];
|
||||
}, [showComments, sortComments, subUnitId, type, unit, unitComments]);
|
||||
const commentsSorted = useMemo(() => [
|
||||
...comments.filter((comment) => !comment.resolved),
|
||||
...comments.filter((comment) => comment.resolved),
|
||||
], [comments]);
|
||||
const statusComments = useMemo(() => {
|
||||
if (status === 'resolved') {
|
||||
return commentsSorted.filter((comment) => comment.resolved);
|
||||
}
|
||||
if (status === 'unsolved') {
|
||||
return commentsSorted.filter((comment) => !comment.resolved);
|
||||
}
|
||||
if (status === 'concern_me' && currentUser?.userID) {
|
||||
return commentsSorted.filter((comment) => comment.users.has(currentUser.userID));
|
||||
}
|
||||
return commentsSorted;
|
||||
}, [commentsSorted, currentUser?.userID, status]);
|
||||
const renderComments = scopedTempComment ? [scopedTempComment, ...statusComments] : statusComments;
|
||||
const activeComment = activeCommentId
|
||||
? renderComments.find((comment) => isSameThreadCommentTarget(activeCommentId, comment))
|
||||
: undefined;
|
||||
const visibleComments = activeComment ? [activeComment] : renderComments;
|
||||
const unsolvedComments = visibleComments.filter((comment) => !comment.resolved);
|
||||
const solvedComments = visibleComments.filter((comment) => comment.resolved);
|
||||
const isFiltering = status !== 'all' || unit !== 'all';
|
||||
const unitFilterOptions = [
|
||||
{ value: 'current', label: localeService.t<LocaleKey>('thread-comment-ui.filter.sheet.current') },
|
||||
{ value: 'all', label: localeService.t<LocaleKey>('thread-comment-ui.filter.sheet.all') },
|
||||
];
|
||||
const statusFilterOptions = [
|
||||
{ value: 'all', label: localeService.t<LocaleKey>('thread-comment-ui.filter.status.all') },
|
||||
{ value: 'resolved', label: localeService.t<LocaleKey>('thread-comment-ui.filter.status.resolved') },
|
||||
{ value: 'unsolved', label: localeService.t<LocaleKey>('thread-comment-ui.filter.status.unsolved') },
|
||||
{ value: 'concern_me', label: localeService.t<LocaleKey>('thread-comment-ui.filter.status.concernMe') },
|
||||
];
|
||||
|
||||
const handleReset = () => {
|
||||
setStatus('all');
|
||||
setUnit('all');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldClearActiveTarget) {
|
||||
panelService.setActiveComment(undefined);
|
||||
}
|
||||
}, [panelService, shouldClearActiveTarget]);
|
||||
|
||||
useEffect(() => {
|
||||
if (tempComment && (tempComment.unitId !== unitId || (subUnitId && tempComment.subUnitId !== subUnitId))) {
|
||||
onTempCommentClose?.();
|
||||
}
|
||||
}, [onTempCommentClose, subUnitId, tempComment, unitId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeCommentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: PointerEvent) => {
|
||||
const activeTarget = panelService.activeCommentId;
|
||||
const target = event.target;
|
||||
if (event.button !== 0 || !activeTarget || !(target instanceof Element)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const activeElement = document.getElementById(
|
||||
`${location}-${activeTarget.unitId}-${activeTarget.subUnitId}-${activeTarget.commentId}`
|
||||
);
|
||||
if (
|
||||
activeElement?.contains(target) ||
|
||||
target.closest('button, input, textarea, select, a, [contenteditable="true"], [role="menu"], [role="menuitem"], [role="option"], [role="listbox"], [role="combobox"], [role="separator"]')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
commandService.executeCommand(SetActiveCommentOperation.id);
|
||||
if (scopedTempComment && isSameThreadCommentTarget(activeTarget, scopedTempComment)) {
|
||||
onTempCommentClose?.();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown, true);
|
||||
return () => document.removeEventListener('pointerdown', handlePointerDown, true);
|
||||
}, [activeCommentId, commandService, location, onTempCommentClose, panelService, scopedTempComment]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeCommentId) {
|
||||
return;
|
||||
}
|
||||
if (!shouldScrollRef.current) {
|
||||
shouldScrollRef.current = true;
|
||||
return;
|
||||
}
|
||||
const { unitId: activeUnitId, subUnitId: activeSubUnitId, commentId } = activeCommentId;
|
||||
document.getElementById(`${location}-${activeUnitId}-${activeSubUnitId}-${commentId}`)?.scrollIntoView({ block: 'center' });
|
||||
}, [activeCommentId, location]);
|
||||
|
||||
const renderComment = (section: ThreadCommentPanelSection) => (comment: IThreadComment, index: number) => (
|
||||
<MobileThreadCommentTree
|
||||
full
|
||||
location={location}
|
||||
getSubUnitName={getSubUnitName}
|
||||
key={getThreadCommentPanelItemKey(comment, index, section)}
|
||||
id={comment.id}
|
||||
unitId={comment.unitId}
|
||||
subUnitId={comment.subUnitId}
|
||||
refStr={comment.ref}
|
||||
displayRef={formatRef?.(comment)}
|
||||
type={type}
|
||||
showEdit={!comment.id || isSameThreadCommentTarget(activeCommentId, comment)}
|
||||
showHighlight={isSameThreadCommentTarget(activeCommentId, comment)}
|
||||
autoFocus={autoFocusActiveComment && isSameThreadCommentTarget(activeCommentId, comment)}
|
||||
onClick={() => {
|
||||
shouldScrollRef.current = false;
|
||||
if (comment.resolved) {
|
||||
commandService.executeCommand(SetActiveCommentOperation.id);
|
||||
return;
|
||||
}
|
||||
commandService.executeCommand(SetActiveCommentOperation.id, {
|
||||
unitId: comment.unitId,
|
||||
subUnitId: comment.subUnitId,
|
||||
commentId: comment.id,
|
||||
temp: false,
|
||||
});
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
panelService.setHoveredComment({
|
||||
unitId: comment.unitId,
|
||||
subUnitId: comment.subUnitId,
|
||||
commentId: comment.id,
|
||||
trigger: 'panel-hover',
|
||||
});
|
||||
onItemEnter?.(comment);
|
||||
}}
|
||||
onMouseLeave={() => {
|
||||
if (isSameThreadCommentTarget(panelService.hoveredCommentId, comment)) {
|
||||
panelService.setHoveredComment(undefined);
|
||||
}
|
||||
onItemLeave?.(comment);
|
||||
}}
|
||||
onAddComment={onAddComment}
|
||||
onDeleteComment={onDeleteComment}
|
||||
onAfterDeleteComment={onAfterDeleteComment}
|
||||
onResolve={(resolved) => onResolve?.(comment.id, resolved)}
|
||||
onClose={!comment.id ? onTempCommentClose : undefined}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="univer-flex univer-min-h-full univer-flex-col univer-pb-3">
|
||||
{activeComment && <MobileCommentHeader comment={activeComment} displayRef={formatRef?.(activeComment)} />}
|
||||
{!activeComment && (
|
||||
<div className="univer-mt-3 univer-flex univer-flex-col univer-gap-2">
|
||||
{type === UniverInstanceType.UNIVER_SHEET && (
|
||||
<MobileCommentFilter value={unit} options={unitFilterOptions} onChange={setUnit} />
|
||||
)}
|
||||
<MobileCommentFilter value={status} options={statusFilterOptions} onChange={setStatus} />
|
||||
</div>
|
||||
)}
|
||||
{!activeComment && !disableAdd && !scopedTempComment && <MobileAddComment onAdd={onAdd} />}
|
||||
{visibleComments.length === 0
|
||||
? (
|
||||
<div
|
||||
className="
|
||||
univer-flex univer-flex-1 univer-flex-col univer-items-center univer-justify-center
|
||||
univer-text-sm univer-text-gray-600
|
||||
dark:!univer-text-gray-200
|
||||
"
|
||||
>
|
||||
{localeService.t<LocaleKey>('thread-comment-ui.panel.empty')}
|
||||
{isFiltering
|
||||
? (
|
||||
<MobileActionRowGroup className="univer-mt-2 univer-flex univer-flex-row">
|
||||
<Button onClick={handleReset}>
|
||||
{localeService.t<LocaleKey>('thread-comment-ui.panel.reset')}
|
||||
</Button>
|
||||
</MobileActionRowGroup>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
)
|
||||
: (
|
||||
<div className="univer-mt-3 univer-flex univer-flex-col univer-gap-3">
|
||||
{unsolvedComments.map(renderComment('unsolved'))}
|
||||
{solvedComments.length > 0 && (
|
||||
<div className="univer-text-xs">
|
||||
{localeService.t<LocaleKey>('thread-comment-ui.panel.solved')}
|
||||
</div>
|
||||
)}
|
||||
{solvedComments.map(renderComment('solved'))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
import type { IThreadCommentTreeProps } from '../ThreadCommentTree';
|
||||
import { MobileDropdown } from '@univerjs/design';
|
||||
import { ThreadCommentTree } from '../ThreadCommentTree';
|
||||
import { MobileThreadCommentEditor } from './MobileThreadCommentEditor';
|
||||
|
||||
export function MobileThreadCommentTree(props: IThreadCommentTreeProps) {
|
||||
return (
|
||||
<ThreadCommentTree
|
||||
{...props}
|
||||
DropdownComponent={MobileDropdown}
|
||||
EditorComponent={MobileThreadCommentEditor}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 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 { focusThreadCommentEditor } from '../util';
|
||||
|
||||
class TestEditorService {
|
||||
focusedEditorId = '';
|
||||
|
||||
focus(editorId: string): void {
|
||||
this.focusedEditorId = editorId;
|
||||
}
|
||||
}
|
||||
|
||||
class TestEditor {
|
||||
focused = false;
|
||||
|
||||
focus(): void {
|
||||
this.focused = true;
|
||||
}
|
||||
}
|
||||
|
||||
describe('focusThreadCommentEditor', () => {
|
||||
it('focuses the editor instance even when the editor service already tracks the id', () => {
|
||||
const editorId = 'comment-editor-id';
|
||||
const editorService = new TestEditorService();
|
||||
const editor = new TestEditor();
|
||||
|
||||
focusThreadCommentEditor(editorService, editorId, editor);
|
||||
|
||||
expect(editorService.focusedEditorId).toBe(editorId);
|
||||
expect(editor.focused).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDocumentBody } from '@univerjs/core';
|
||||
import type { IThreadCommentMention } from '@univerjs/thread-comment';
|
||||
import { createParagraphId, createSectionId, CustomRangeType, getBodySlice } from '@univerjs/core';
|
||||
|
||||
interface IThreadCommentEditorFocusService {
|
||||
focus: (editorId: string) => void;
|
||||
}
|
||||
|
||||
interface IFocusableThreadCommentEditor {
|
||||
focus: () => void;
|
||||
}
|
||||
|
||||
export type TextNode = {
|
||||
type: 'text';
|
||||
content: string;
|
||||
} | {
|
||||
type: 'mention';
|
||||
content: IThreadCommentMention;
|
||||
};
|
||||
|
||||
const transformDocument2TextNodesInParagraph = (doc: IDocumentBody) => {
|
||||
const { dataStream, customRanges } = doc;
|
||||
const end = dataStream.endsWith('\r\n') ? dataStream.length - 2 : dataStream.length;
|
||||
const textNodes: TextNode[] = [];
|
||||
|
||||
let lastIndex = 0;
|
||||
|
||||
customRanges?.forEach((range) => {
|
||||
if (lastIndex < range.startIndex) {
|
||||
textNodes.push({
|
||||
type: 'text',
|
||||
content: dataStream.slice(lastIndex, range.startIndex),
|
||||
});
|
||||
}
|
||||
textNodes.push({
|
||||
type: 'mention',
|
||||
content: {
|
||||
label: dataStream.slice(range.startIndex, range.endIndex + 1),
|
||||
id: range.rangeId,
|
||||
},
|
||||
});
|
||||
lastIndex = range.endIndex + 1;
|
||||
});
|
||||
|
||||
textNodes.push({
|
||||
type: 'text',
|
||||
content: dataStream.slice(lastIndex, end),
|
||||
});
|
||||
return textNodes;
|
||||
};
|
||||
|
||||
export const transformDocument2TextNodes = (doc: IDocumentBody) => {
|
||||
if (!doc) {
|
||||
return [];
|
||||
}
|
||||
const { paragraphs = [] } = doc;
|
||||
|
||||
return paragraphs.map((paragraph, index) => {
|
||||
const lastIndex = index === 0 ? 0 : paragraphs[index - 1].startIndex + 1;
|
||||
const body = getBodySlice(doc, lastIndex, paragraph.startIndex);
|
||||
return transformDocument2TextNodesInParagraph(body);
|
||||
});
|
||||
};
|
||||
|
||||
export const transformTextNodes2Document = (nodes: TextNode[]): IDocumentBody => {
|
||||
let str = '';
|
||||
const customRanges: Required<IDocumentBody['customRanges']> = [];
|
||||
|
||||
nodes.forEach((node) => {
|
||||
switch (node.type) {
|
||||
case 'text':
|
||||
str += node.content;
|
||||
break;
|
||||
case 'mention': {
|
||||
const start = str.length;
|
||||
str += node.content.label;
|
||||
const end = str.length - 1;
|
||||
customRanges.push({
|
||||
rangeId: node.content.id,
|
||||
rangeType: CustomRangeType.MENTION,
|
||||
startIndex: start,
|
||||
endIndex: end,
|
||||
properties: {},
|
||||
wholeEntity: true,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
str += '\r\n';
|
||||
|
||||
return {
|
||||
textRuns: [],
|
||||
paragraphs: [
|
||||
{
|
||||
startIndex: str.length - 2,
|
||||
paragraphId: createParagraphId(new Set()),
|
||||
paragraphStyle: {},
|
||||
},
|
||||
],
|
||||
sectionBreaks: [
|
||||
{
|
||||
sectionId: createSectionId(new Set()),
|
||||
startIndex: str.length - 1,
|
||||
},
|
||||
],
|
||||
dataStream: str,
|
||||
customRanges,
|
||||
};
|
||||
};
|
||||
|
||||
export function focusThreadCommentEditor(
|
||||
editorService: IThreadCommentEditorFocusService,
|
||||
editorId: string,
|
||||
editor?: IFocusableThreadCommentEditor | null
|
||||
) {
|
||||
editorService.focus(editorId);
|
||||
editor?.focus();
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IThreadComment } from '@univerjs/thread-comment';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getThreadCommentPanelItemKey, isSameThreadCommentTarget, shouldClearThreadCommentTarget } from '../util';
|
||||
|
||||
describe('getThreadCommentPanelItemKey', () => {
|
||||
it('returns unique keys for comments with empty ids', () => {
|
||||
const comments = [
|
||||
{
|
||||
id: '',
|
||||
unitId: 'unit-1',
|
||||
subUnitId: 'sub-unit-1',
|
||||
threadId: '',
|
||||
ref: 'A1',
|
||||
dT: '',
|
||||
},
|
||||
{
|
||||
id: '',
|
||||
unitId: 'unit-1',
|
||||
subUnitId: 'sub-unit-1',
|
||||
threadId: '',
|
||||
ref: 'A1',
|
||||
dT: '',
|
||||
},
|
||||
] as IThreadComment[];
|
||||
|
||||
const keys = comments.map((comment, index) => getThreadCommentPanelItemKey(comment, index, 'unsolved'));
|
||||
|
||||
expect(new Set(keys).size).toBe(keys.length);
|
||||
expect(keys).not.toContain('');
|
||||
});
|
||||
|
||||
it('keeps persisted comment ids as keys', () => {
|
||||
const comment = {
|
||||
id: 'comment-1',
|
||||
unitId: 'unit-1',
|
||||
subUnitId: 'sub-unit-1',
|
||||
} as IThreadComment;
|
||||
|
||||
expect(getThreadCommentPanelItemKey(comment, 0, 'unsolved')).toBe('comment-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('isSameThreadCommentTarget', () => {
|
||||
const comment = { id: 'comment-1', unitId: 'unit-1', subUnitId: 'sub-unit-1' } as IThreadComment;
|
||||
|
||||
it('requires the unit, subunit, and comment ids to match', () => {
|
||||
expect(isSameThreadCommentTarget({ unitId: 'unit-1', subUnitId: 'sub-unit-1', commentId: 'comment-1' }, comment)).toBe(true);
|
||||
expect(isSameThreadCommentTarget({ unitId: 'unit-2', subUnitId: 'sub-unit-1', commentId: 'comment-1' }, comment)).toBe(false);
|
||||
expect(isSameThreadCommentTarget({ unitId: 'unit-1', subUnitId: 'sub-unit-2', commentId: 'comment-1' }, comment)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('shouldClearThreadCommentTarget', () => {
|
||||
it('clears deleted targets and targets from a previously active unit', () => {
|
||||
expect(shouldClearThreadCommentTarget({ unitId: 'unit-1' }, 'unit-1', true)).toBe(false);
|
||||
expect(shouldClearThreadCommentTarget({ unitId: 'unit-1' }, 'unit-1', false)).toBe(true);
|
||||
expect(shouldClearThreadCommentTarget({ unitId: 'unit-1' }, 'unit-2', true)).toBe(true);
|
||||
expect(shouldClearThreadCommentTarget(undefined, 'unit-1', false)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IThreadComment } from '@univerjs/thread-comment';
|
||||
|
||||
export type ThreadCommentPanelSection = 'unsolved' | 'solved';
|
||||
|
||||
export function isSameThreadCommentTarget(
|
||||
active: { unitId: string; subUnitId: string; commentId: string } | null | undefined | void,
|
||||
comment: Pick<IThreadComment, 'id' | 'unitId' | 'subUnitId'>
|
||||
): boolean {
|
||||
return active?.unitId === comment.unitId &&
|
||||
active.subUnitId === comment.subUnitId &&
|
||||
active.commentId === comment.id;
|
||||
}
|
||||
|
||||
export function shouldClearThreadCommentTarget(
|
||||
active: { unitId: string } | null | undefined | void,
|
||||
currentUnitId: string,
|
||||
commentExists: boolean
|
||||
): boolean {
|
||||
if (!active) {
|
||||
return false;
|
||||
}
|
||||
return active.unitId !== currentUnitId || !commentExists;
|
||||
}
|
||||
|
||||
export function getThreadCommentPanelItemKey(
|
||||
comment: IThreadComment,
|
||||
index: number,
|
||||
section: ThreadCommentPanelSection
|
||||
) {
|
||||
if (comment.id) {
|
||||
return comment.id;
|
||||
}
|
||||
|
||||
return [
|
||||
'thread-comment-panel-temp',
|
||||
section,
|
||||
comment.unitId,
|
||||
comment.subUnitId,
|
||||
comment.threadId,
|
||||
comment.ref,
|
||||
comment.dT,
|
||||
index,
|
||||
].join('-');
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { DOCS_COMMENT_EDITOR_UNIT_ID_KEY } from '@univerjs/core';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ThreadCommentTreeLocation } from '../../ThreadCommentTree';
|
||||
import { getThreadCommentEditorId } from '../util';
|
||||
|
||||
describe('getThreadCommentEditorId', () => {
|
||||
it('returns different editor ids for different comments in the same location', () => {
|
||||
const first = getThreadCommentEditorId({
|
||||
location: ThreadCommentTreeLocation.PANEL,
|
||||
unitId: 'unit-1',
|
||||
subUnitId: 'sub-unit-1',
|
||||
commentId: 'comment-1',
|
||||
fallbackId: 'fallback',
|
||||
});
|
||||
const second = getThreadCommentEditorId({
|
||||
location: ThreadCommentTreeLocation.PANEL,
|
||||
unitId: 'unit-1',
|
||||
subUnitId: 'sub-unit-1',
|
||||
commentId: 'comment-2',
|
||||
fallbackId: 'fallback',
|
||||
});
|
||||
|
||||
expect(first).not.toBe(second);
|
||||
expect(first.startsWith(DOCS_COMMENT_EDITOR_UNIT_ID_KEY)).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the fallback id when the comment id is empty', () => {
|
||||
expect(getThreadCommentEditorId({
|
||||
location: ThreadCommentTreeLocation.PANEL,
|
||||
unitId: 'unit-1',
|
||||
subUnitId: 'sub-unit-1',
|
||||
commentId: '',
|
||||
fallbackId: 'fallback-1',
|
||||
})).toBe(`${DOCS_COMMENT_EDITOR_UNIT_ID_KEY}_PANEL_unit-1_sub-unit-1_fallback-1`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* 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 { ThreadCommentTreeLocation } from '../ThreadCommentTree';
|
||||
import { DOCS_COMMENT_EDITOR_UNIT_ID_KEY } from '@univerjs/core';
|
||||
|
||||
interface IGetThreadCommentEditorIdParams {
|
||||
location: ThreadCommentTreeLocation;
|
||||
unitId: string;
|
||||
subUnitId: string;
|
||||
commentId?: string;
|
||||
fallbackId: string;
|
||||
}
|
||||
|
||||
export function getThreadCommentEditorId(params: IGetThreadCommentEditorIdParams) {
|
||||
const { location, unitId, subUnitId, commentId, fallbackId } = params;
|
||||
|
||||
return `${DOCS_COMMENT_EDITOR_UNIT_ID_KEY}_${location}_${unitId}_${subUnitId}_${commentId || fallbackId}`;
|
||||
}
|
||||
Reference in New Issue
Block a user