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

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

View File

@@ -0,0 +1,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();
});
});

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
@tailwind components;
@tailwind utilities;

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

View File

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

View File

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

View File

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

View 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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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;

View File

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

View File

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

View File

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

View 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);
}
}

View 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);
});
}
}

View File

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

View File

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

View File

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

View File

@@ -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,
]);
});
});

View File

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

View File

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

View File

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

View 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>
);

View 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>
);
};

View 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>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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('-');
}

View File

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

View File

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