diff --git a/package-lock.json b/package-lock.json index 432447121..64cab7005 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", "@types/node": "^22.13.1", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.47.0", "@typescript-eslint/parser": "^8.47.0", "@vitest/coverage-v8": "^3.2.4", @@ -3108,6 +3109,13 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", "license": "MIT" }, + "node_modules/@types/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.48.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz", @@ -5258,8 +5266,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "devOptional": true, "license": "ISC", + "optional": true, "engines": { "node": ">=10" } @@ -8417,8 +8425,8 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "devOptional": true, "license": "ISC", + "optional": true, "dependencies": { "minipass": "^3.0.0" }, @@ -8430,8 +8438,8 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "devOptional": true, "license": "ISC", + "optional": true, "dependencies": { "yallist": "^4.0.0" }, @@ -12156,8 +12164,8 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "devOptional": true, "license": "MIT", + "optional": true, "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" @@ -12170,8 +12178,8 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "devOptional": true, "license": "ISC", + "optional": true, "dependencies": { "yallist": "^4.0.0" }, @@ -19130,8 +19138,8 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "devOptional": true, "license": "ISC", + "optional": true, "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -19208,8 +19216,8 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "devOptional": true, "license": "ISC", + "optional": true, "engines": { "node": ">=8" } @@ -22342,8 +22350,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "devOptional": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/yaml": { "version": "2.8.1", diff --git a/package.json b/package.json index 67c7c145a..701ac1205 100644 --- a/package.json +++ b/package.json @@ -75,6 +75,7 @@ "@semantic-release/changelog": "^6.0.3", "@semantic-release/git": "^10.0.1", "@types/node": "^22.13.1", + "@types/uuid": "^10.0.0", "@typescript-eslint/eslint-plugin": "^8.47.0", "@typescript-eslint/parser": "^8.47.0", "@vitest/coverage-v8": "^3.2.4", diff --git a/packages/superdoc/index.html b/packages/superdoc/index.html index 24a3a6694..6a67b8940 100644 --- a/packages/superdoc/index.html +++ b/packages/superdoc/index.html @@ -15,6 +15,6 @@
- + diff --git a/packages/superdoc/package.json b/packages/superdoc/package.json index 731e1b270..63b8eb7d4 100644 --- a/packages/superdoc/package.json +++ b/packages/superdoc/package.json @@ -44,7 +44,7 @@ "postbuild": "node ./scripts/ensure-types.cjs", "build:es": "cd ../super-editor && npm run build && cd ../superdoc && vite build", "watch:es": "nodemon --watch src --watch ../super-editor/src --ext js,ts,vue --exec \"npm run build:es\" --delay 100ms", - "build:umd": "vite build --config vite.config.umd.js", + "build:umd": "vite build --config vite.config.umd.ts", "clean": "rm -rf dist", "pack:local": "npm run build:es && npm pack && mv $(ls superdoc-*.tgz) ./superdoc.tgz", "pack": "npm run build:es && npm pack && mv $(ls superdoc-*.tgz) ./superdoc.tgz", diff --git a/packages/superdoc/src/SuperDoc.test.js b/packages/superdoc/src/SuperDoc.test.ts similarity index 67% rename from packages/superdoc/src/SuperDoc.test.js rename to packages/superdoc/src/SuperDoc.test.ts index 30dbd7dc1..1f01b8a6c 100644 --- a/packages/superdoc/src/SuperDoc.test.js +++ b/packages/superdoc/src/SuperDoc.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { mount } from '@vue/test-utils'; -import { h, defineComponent, ref, reactive, nextTick } from 'vue'; +import { mount, VueWrapper } from '@vue/test-utils'; +import { h, defineComponent, ref, reactive, nextTick, DefineComponent } from 'vue'; import { DOCX } from '@superdoc/common'; import { Schema } from 'prosemirror-model'; import { EditorState, TextSelection } from 'prosemirror-state'; @@ -8,17 +8,17 @@ import { Extension } from '../../super-editor/src/core/Extension.js'; import { CommentsPlugin, CommentsPluginKey } from '../../super-editor/src/extensions/comment/comments-plugin.js'; import { CommentMarkName } from '../../super-editor/src/extensions/comment/comments-constants.js'; -const isRef = (value) => value && typeof value === 'object' && 'value' in value; +const isRef = (value: unknown): boolean => value !== null && typeof value === 'object' && 'value' in value; // Mock state for PresentationEditor -const mockState = { instances: new Map() }; +const mockState: { instances: Map } = { instances: new Map() }; vi.mock('pinia', async () => { const actual = await vi.importActual('pinia'); return { ...actual, - storeToRefs: (store) => { - const result = {}; + storeToRefs: (store: Record) => { + const result: Record = {}; for (const key of Object.keys(store)) { if (isRef(store[key])) { result[key] = store[key]; @@ -29,8 +29,8 @@ vi.mock('pinia', async () => { }; }); -let superdocStoreStub; -let commentsStoreStub; +let superdocStoreStub: Record; +let commentsStoreStub: Record; vi.mock('@superdoc/stores/superdoc-store', () => ({ useSuperdocStore: () => superdocStoreStub, @@ -40,7 +40,7 @@ vi.mock('@superdoc/stores/comments-store', () => ({ useCommentsStore: () => commentsStoreStub, })); -const useSelectionMock = vi.fn((params) => ({ +const useSelectionMock = vi.fn((params: Record) => ({ selectionBounds: params.selectionBounds || {}, getValues: () => ({ ...params }), })); @@ -73,7 +73,7 @@ vi.mock('@superdoc/composables/use-high-contrast-mode', () => ({ useHighContrastMode: () => ({ isHighContrastMode: ref(false) }), })); -const stubComponent = (name) => +const stubComponent = (name: string): DefineComponent => defineComponent({ name, props: ['comment', 'autoFocus', 'parent', 'documentData', 'config', 'documentId', 'fileSource', 'state', 'options'], @@ -106,13 +106,13 @@ vi.mock('@harbour-enterprises/super-editor', () => ({ SuperEditor: SuperEditorStub, AIWriter: AIWriterStub, PresentationEditor: class PresentationEditorMock { - static getInstance(documentId) { + static getInstance(documentId: string): unknown { return mockState.instances.get(documentId); } - static setGlobalZoom(zoom) { - mockState.instances.forEach((instance) => { - instance?.setZoom?.(zoom); + static setGlobalZoom(zoom: number): void { + mockState.instances.forEach((instance: unknown) => { + (instance as { setZoom?: (zoom: number) => void })?.setZoom?.(zoom); }); } }, @@ -155,7 +155,7 @@ vi.mock('naive-ui', () => ({ }), })); -const buildSuperdocStore = () => { +const buildSuperdocStore = (): Record => { const documents = ref([ { id: 'doc-1', @@ -181,11 +181,11 @@ const buildSuperdocStore = () => { modules: reactive({ comments: { readOnly: false }, ai: {}, 'hrbr-fields': [] }), handlePageReady: vi.fn(), user: { name: 'Ada', email: 'ada@example.com' }, - getDocument: vi.fn((id) => documents.value.find((d) => d.id === id)), + getDocument: vi.fn((id: string) => documents.value.find((d) => d.id === id)), }; }; -const buildCommentsStore = () => ({ +const buildCommentsStore = (): Record => ({ init: vi.fn(), showAddComment: vi.fn(), handleEditorLocationsUpdate: vi.fn(), @@ -222,11 +222,11 @@ const buildCommentsStore = () => ({ isCommentHighlighted: ref(false), }); -const mountComponent = async (superdocStub) => { +const mountComponent = async (superdocStub: Record): Promise => { superdocStoreStub = buildSuperdocStore(); commentsStoreStub = buildCommentsStore(); - superdocStoreStub.modules.ai = { endpoint: '/ai' }; - commentsStoreStub.documentsWithConverations.value = [{ id: 'doc-1' }]; + (superdocStoreStub.modules as Record).ai = { endpoint: '/ai' }; + (commentsStoreStub.documentsWithConverations as { value: unknown[] }).value = [{ id: 'doc-1' }]; const component = (await import('./SuperDoc.vue')).default; @@ -246,11 +246,11 @@ const mountComponent = async (superdocStub) => { }, directives: { 'click-outside': { - mounted(el, binding) { - el.__clickOutside = binding.value; + mounted(el: HTMLElement, binding: { value: unknown }) { + (el as HTMLElement & { __clickOutside?: unknown }).__clickOutside = binding.value; }, - unmounted(el) { - delete el.__clickOutside; + unmounted(el: HTMLElement) { + delete (el as HTMLElement & { __clickOutside?: unknown }).__clickOutside; }, }, }, @@ -258,7 +258,7 @@ const mountComponent = async (superdocStub) => { }); }; -const createSuperdocStub = () => { +const createSuperdocStub = (): Record => { const toolbar = { config: { aiApiKey: 'abc' }, setActiveEditor: vi.fn(), updateToolbarState: vi.fn() }; return { config: { @@ -287,7 +287,7 @@ const createSuperdocStub = () => { }; }; -const createFloatingCommentsSchema = () => +const createFloatingCommentsSchema = (): Schema => new Schema({ nodes: { doc: { content: 'block+' }, @@ -304,7 +304,7 @@ const createFloatingCommentsSchema = () => }, }); -const createImportedCommentDoc = (threadId) => { +const createImportedCommentDoc = (threadId: string): { schema: Schema; doc: unknown } => { const schema = createFloatingCommentsSchema(); const importedMark = schema.marks[CommentMarkName].create({ importedId: threadId, internal: true }); const paragraph = schema.node('paragraph', null, [schema.text('Imported', [importedMark])]); @@ -313,28 +313,34 @@ const createImportedCommentDoc = (threadId) => { return { schema, doc }; }; -const createCommentsPluginEnvironment = ({ schema, doc }) => { - const selection = TextSelection.create(doc, 1); - let state = EditorState.create({ schema, doc, selection }); - - const editor = { +const createCommentsPluginEnvironment = ({ + schema, + doc, +}: { + schema: Schema; + doc: unknown; +}): Record => { + const selection = TextSelection.create(doc as never, 1); + let state = EditorState.create({ schema, doc: doc as never, selection }); + + const editor: Record = { options: { documentId: 'doc-1' }, emit: vi.fn(), view: null, }; - const extension = Extension.create(CommentsPlugin.config); + const extension = Extension.create(CommentsPlugin.config as never); extension.addCommands = CommentsPlugin.config.addCommands.bind(extension); extension.addPmPlugins = CommentsPlugin.config.addPmPlugins.bind(extension); - extension.editor = editor; + (extension as Record).editor = editor; const [plugin] = extension.addPmPlugins(); - state = EditorState.create({ schema, doc, selection, plugins: [plugin] }); + state = EditorState.create({ schema, doc: doc as never, selection, plugins: [plugin] }); - const view = { + const view: Record = { state, - dispatch: vi.fn((tr) => { - state = state.apply(tr); + dispatch: vi.fn((tr: unknown) => { + state = state.apply(tr as never); view.state = state; }), focus: vi.fn(), @@ -342,7 +348,7 @@ const createCommentsPluginEnvironment = ({ schema, doc }) => { }; editor.view = view; - const pluginView = plugin.spec.view?.(view); + const pluginView = (plugin.spec as { view?: (view: unknown) => unknown }).view?.(view); return { editor, view, pluginView }; }; @@ -357,7 +363,7 @@ describe('SuperDoc.vue', () => { // Set up default mock presentation editor instances for common document IDs const mockPresentationEditor = { getSelectionBounds: vi.fn(() => null), - getCommentBounds: vi.fn((positions) => positions), + getCommentBounds: vi.fn((positions: unknown[]) => positions), getRangeRects: vi.fn(() => []), getPages: vi.fn(() => []), getLayoutError: vi.fn(() => null), @@ -373,7 +379,7 @@ describe('SuperDoc.vue', () => { addListener: vi.fn(), removeListener: vi.fn(), dispatchEvent: vi.fn(), - }); + }) as typeof window.matchMedia; } }); @@ -385,8 +391,8 @@ describe('SuperDoc.vue', () => { const editorComponent = wrapper.findComponent(SuperEditorStub); expect(editorComponent.exists()).toBe(true); - const options = editorComponent.props('options'); - const editorMock = { + const options = editorComponent.props('options') as Record; + const editorMock: Record = { options: { documentId: 'doc-1' }, commands: { togglePagination: vi.fn(), @@ -396,7 +402,7 @@ describe('SuperDoc.vue', () => { goToSearchResult: vi.fn(), }, view: { - coordsAtPos: vi.fn((pos) => + coordsAtPos: vi.fn((pos: number) => pos === 1 ? { top: 100, bottom: 120, left: 10, right: 20 } : { top: 130, bottom: 160, left: 60, right: 80 }, ), state: { selection: { empty: true } }, @@ -404,35 +410,47 @@ describe('SuperDoc.vue', () => { getPageStyles: vi.fn(() => ({ pageMargins: {} })), }; - options.onBeforeCreate({ editor: editorMock }); + (options.onBeforeCreate as (params: { editor: unknown }) => void)({ editor: editorMock }); expect(superdocStub.broadcastEditorBeforeCreate).toHaveBeenCalled(); - options.onCreate({ editor: editorMock }); - expect(superdocStoreStub.documents.value[0].setEditor).toHaveBeenCalledWith(editorMock); + (options.onCreate as (params: { editor: unknown }) => void)({ editor: editorMock }); + expect( + (superdocStoreStub.documents as { value: { setEditor: ReturnType }[] }).value[0].setEditor, + ).toHaveBeenCalledWith(editorMock); expect(superdocStub.setActiveEditor).toHaveBeenCalledWith(editorMock); expect(superdocStub.broadcastEditorCreate).toHaveBeenCalled(); expect(useAiMock).toHaveBeenCalled(); - options.onSelectionUpdate({ + (options.onSelectionUpdate as (params: { editor: unknown; transaction: unknown }) => void)({ editor: editorMock, transaction: { selection: { $from: { pos: 1 }, $to: { pos: 3 } } }, }); expect(useSelectionMock).toHaveBeenCalled(); - options.onCommentsUpdate({ activeCommentId: 'c1', type: 'trackedChange' }); + (options.onCommentsUpdate as (params: { activeCommentId: string; type: string }) => void)({ + activeCommentId: 'c1', + type: 'trackedChange', + }); expect(commentsStoreStub.handleTrackedChangeUpdate).toHaveBeenCalled(); await nextTick(); expect(commentsStoreStub.setActiveComment).toHaveBeenCalledWith(superdocStub, 'c1'); - options.onCollaborationReady({ editor: editorMock }); + (options.onCollaborationReady as (params: { editor: unknown }) => void)({ editor: editorMock }); expect(superdocStub.emit).toHaveBeenCalledWith('collaboration-ready', { editor: editorMock }); await nextTick(); - expect(superdocStoreStub.isReady.value).toBe(true); + expect((superdocStoreStub.isReady as { value: boolean }).value).toBe(true); - options.onDocumentLocked({ editor: editorMock, isLocked: true, lockedBy: { name: 'A' } }); + (options.onDocumentLocked as (params: { editor: unknown; isLocked: boolean; lockedBy: unknown }) => void)({ + editor: editorMock, + isLocked: true, + lockedBy: { name: 'A' }, + }); expect(superdocStub.lockSuperdoc).toHaveBeenCalledWith(true, { name: 'A' }); - options.onException({ error: new Error('boom'), editor: editorMock }); + (options.onException as (params: { error: Error; editor: unknown }) => void)({ + error: new Error('boom'), + editor: editorMock, + }); expect(superdocStub.emit).toHaveBeenCalledWith('exception', { error: expect.any(Error), editor: editorMock }); }); @@ -441,15 +459,15 @@ describe('SuperDoc.vue', () => { const wrapper = await mountComponent(superdocStub); await nextTick(); - const options = wrapper.findComponent(SuperEditorStub).props('options'); - const editorMock = { + const options = wrapper.findComponent(SuperEditorStub).props('options') as Record; + const editorMock: Record = { options: { documentId: 'doc-1' }, commands: { togglePagination: vi.fn(), insertAiMark: vi.fn(), }, view: { - coordsAtPos: vi.fn((pos) => + coordsAtPos: vi.fn((pos: number) => pos === 1 ? { top: 100, bottom: 140, left: 10, right: 30 } : { top: 120, bottom: 160, left: 70, right: 90 }, ), state: { selection: { empty: true } }, @@ -457,15 +475,15 @@ describe('SuperDoc.vue', () => { getPageStyles: vi.fn(() => ({ pageMargins: {} })), }; await nextTick(); - options.onSelectionUpdate({ + (options.onSelectionUpdate as (params: { editor: unknown; transaction: unknown }) => void)({ editor: editorMock, transaction: { selection: { $from: { pos: 1 }, $to: { pos: 6 } } }, }); await nextTick(); - const setupState = wrapper.vm.$.setupState; - setupState.toolsMenuPosition.top = '12px'; - setupState.toolsMenuPosition.right = '0px'; - setupState.selectionPosition.value = { + const setupState = wrapper.vm.$.setupState as Record; + (setupState.toolsMenuPosition as Record).top = '12px'; + (setupState.toolsMenuPosition as Record).right = '0px'; + (setupState.selectionPosition as { value: unknown }).value = { left: 10, right: 40, top: 20, @@ -474,42 +492,45 @@ describe('SuperDoc.vue', () => { }; await nextTick(); - const handleToolClick = wrapper.vm.$.setupState.handleToolClick; + const handleToolClick = setupState.handleToolClick as (tool: string) => void; handleToolClick('comments'); expect(commentsStoreStub.showAddComment).toHaveBeenCalledWith(superdocStub); handleToolClick('ai'); - const aiMockResult = useAiMock.mock.results.at(-1)?.value; + const aiMockResult = useAiMock.mock.results.at(-1)?.value as Record; expect(aiMockResult?.handleAiToolClick).toHaveBeenCalled(); - commentsStoreStub.pendingComment.value = { commentId: 'new', selection: { getValues: () => ({}) } }; + (commentsStoreStub.pendingComment as { value: unknown }).value = { + commentId: 'new', + selection: { getValues: () => ({}) }, + }; await nextTick(); - const toggleArg = superdocStub.broadcastSidebarToggle.mock.calls.at(-1)[0]; + const toggleArg = (superdocStub.broadcastSidebarToggle as ReturnType).mock.calls.at(-1)[0]; expect(toggleArg).toEqual(expect.objectContaining({ commentId: 'new' })); expect(wrapper.findComponent(CommentDialogStub).exists()).toBe(true); - superdocStoreStub.isReady.value = true; + (superdocStoreStub.isReady as { value: boolean }).value = true; await nextTick(); - commentsStoreStub.getFloatingComments.value = [{ id: 'f1' }]; + (commentsStoreStub.getFloatingComments as { value: unknown[] }).value = [{ id: 'f1' }]; await nextTick(); await nextTick(); - expect(commentsStoreStub.hasInitializedLocations.value).toBe(true); + expect((commentsStoreStub.hasInitializedLocations as { value: boolean }).value).toBe(true); }); it('hides comment interactions when comments module is disabled', async () => { const superdocStub = createSuperdocStub(); - superdocStub.config.modules.comments = false; + (superdocStub.config as Record).modules = { comments: false }; const wrapper = await mountComponent(superdocStub); await nextTick(); - superdocStoreStub.modules.comments = false; + (superdocStoreStub.modules as Record).comments = false; await nextTick(); expect(wrapper.find('.superdoc__selection-layer').exists()).toBe(false); - const options = wrapper.findComponent(SuperEditorStub).props('options'); - const editorMock = { + const options = wrapper.findComponent(SuperEditorStub).props('options') as Record; + const editorMock: Record = { options: { documentId: 'doc-1' }, commands: { togglePagination: vi.fn() }, view: { @@ -519,13 +540,13 @@ describe('SuperDoc.vue', () => { getPageStyles: vi.fn(() => ({ pageMargins: {} })), }; - options.onSelectionUpdate({ + (options.onSelectionUpdate as (params: { editor: unknown; transaction: unknown }) => void)({ editor: editorMock, transaction: { selection: { $from: { pos: 1 }, $to: { pos: 4 } } }, }); await nextTick(); - expect(superdocStoreStub.activeSelection.value).toBeNull(); + expect((superdocStoreStub.activeSelection as { value: unknown }).value).toBeNull(); expect(wrapper.find('.superdoc__tools').exists()).toBe(false); }); @@ -534,10 +555,12 @@ describe('SuperDoc.vue', () => { const wrapper = await mountComponent(superdocStub); await nextTick(); - const options = wrapper.findComponent(SuperEditorStub).props('options'); - commentsStoreStub.handleEditorLocationsUpdate.mockImplementation((positions) => { - commentsStoreStub.getFloatingComments.value = Object.values(positions); - }); + const options = wrapper.findComponent(SuperEditorStub).props('options') as Record; + (commentsStoreStub.handleEditorLocationsUpdate as ReturnType).mockImplementation( + (positions: Record) => { + (commentsStoreStub.getFloatingComments as { value: unknown[] }).value = Object.values(positions); + }, + ); const importedComment = { commentId: null, importedId: 'import-1', @@ -546,26 +569,39 @@ describe('SuperDoc.vue', () => { createdTime: Date.now(), }; - options.onCommentsUpdate({ type: 'add', comment: importedComment }); + (options.onCommentsUpdate as (params: { type: string; comment: unknown }) => void)({ + type: 'add', + comment: importedComment, + }); await nextTick(); const { schema, doc } = createImportedCommentDoc('import-1'); const { view, editor, pluginView } = createCommentsPluginEnvironment({ schema, doc }); expect(pluginView).toBeDefined(); - view.coordsAtPos.mockReturnValue({ top: 20, bottom: 40, left: 10, right: 30 }); - editor.emit = vi.fn((event, payload) => { - if (event === 'comment-positions') { - options.onCommentLocationsUpdate({ - allCommentPositions: payload.allCommentPositions, - allCommentIds: Object.keys(payload.allCommentPositions), - }); - } - }); + (view.coordsAtPos as ReturnType).mockReturnValue({ top: 20, bottom: 40, left: 10, right: 30 }); + (editor.emit as ReturnType) = vi.fn( + (event: string, payload: { allCommentPositions: unknown; allCommentIds?: string[] }) => { + if (event === 'comment-positions') { + ( + options.onCommentLocationsUpdate as (params: { + allCommentPositions: unknown; + allCommentIds: string[]; + }) => void + )({ + allCommentPositions: payload.allCommentPositions, + allCommentIds: Object.keys(payload.allCommentPositions as Record), + }); + } + }, + ); - const forceTr = view.state.tr.setMeta(CommentsPluginKey, { type: 'force' }); - view.dispatch(forceTr); - pluginView.update(view); + const forceTr = (view.state as { tr: { setMeta: (key: unknown, value: unknown) => unknown } }).tr.setMeta( + CommentsPluginKey, + { type: 'force' }, + ); + (view.dispatch as ReturnType)(forceTr); + (pluginView as { update: (view: unknown) => void }).update(view); expect(editor.emit).toHaveBeenCalledWith( 'comment-positions', @@ -585,10 +621,10 @@ describe('SuperDoc.vue', () => { ); await nextTick(); - superdocStoreStub.isReady.value = true; + (superdocStoreStub.isReady as { value: boolean }).value = true; await nextTick(); - expect(wrapper.vm.showCommentsSidebar).toBe(true); + expect((wrapper.vm as { showCommentsSidebar: boolean }).showCommentsSidebar).toBe(true); expect(wrapper.find('.floating-comments').exists()).toBe(true); }); diff --git a/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js b/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.ts similarity index 66% rename from packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js rename to packages/superdoc/src/components/CommentsLayer/CommentDialog.test.ts index a690255a0..90300fbcd 100644 --- a/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.js +++ b/packages/superdoc/src/components/CommentsLayer/CommentDialog.test.ts @@ -1,9 +1,9 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { mount } from '@vue/test-utils'; -import { ref, reactive, h, defineComponent, nextTick } from 'vue'; +import { mount, VueWrapper } from '@vue/test-utils'; +import { ref, reactive, h, defineComponent, nextTick, DefineComponent } from 'vue'; -let superdocStoreStub; -let commentsStoreStub; +let superdocStoreStub: Record; +let commentsStoreStub: Record; vi.mock('@superdoc/stores/superdoc-store', () => ({ useSuperdocStore: () => superdocStoreStub, @@ -14,7 +14,10 @@ vi.mock('@superdoc/stores/comments-store', () => ({ })); vi.mock('@superdoc/helpers/use-selection', () => ({ - default: vi.fn((params) => ({ getValues: () => ({ ...params }), selectionBounds: params.selectionBounds || {} })), + default: vi.fn((params: Record) => ({ + getValues: () => ({ ...params }), + selectionBounds: params.selectionBounds || {}, + })), })); vi.mock('@harbour-enterprises/super-editor', () => ({ @@ -26,7 +29,7 @@ vi.mock('@harbour-enterprises/super-editor', () => ({ }), })); -const simpleStub = (name, emits = []) => +const simpleStub = (name: string, emits: string[] = []): DefineComponent => defineComponent({ name, props: ['comment', 'config', 'state', 'isDisabled', 'timestamp', 'users'], @@ -52,11 +55,15 @@ const CommentHeaderStub = defineComponent({ emits: ['resolve', 'reject', 'overflow-select'], setup(props, { emit }) { return () => - h('div', { class: 'comment-header-stub', 'data-comment-id': props.comment.commentId }, [ - h('button', { class: 'resolve-btn', onClick: () => emit('resolve') }, 'resolve'), - h('button', { class: 'reject-btn', onClick: () => emit('reject') }, 'reject'), - h('button', { class: 'overflow-btn', onClick: () => emit('overflow-select', 'edit') }, 'edit'), - ]); + h( + 'div', + { class: 'comment-header-stub', 'data-comment-id': (props.comment as { commentId: string }).commentId }, + [ + h('button', { class: 'resolve-btn', onClick: () => emit('resolve') }, 'resolve'), + h('button', { class: 'reject-btn', onClick: () => emit('reject') }, 'reject'), + h('button', { class: 'overflow-btn', onClick: () => emit('overflow-select', 'edit') }, 'edit'), + ], + ); }, }); @@ -68,7 +75,7 @@ const InternalDropdownStub = defineComponent({ return () => h('div', { class: 'internal-dropdown-stub', - onClick: () => emit('select', props.state === 'internal' ? 'external' : 'internal'), + onClick: () => emit('select', (props.state as string) === 'internal' ? 'external' : 'internal'), }); }, }); @@ -99,8 +106,42 @@ vi.mock('@superdoc/core/collaboration/permissions.js', () => ({ isAllowed: () => true, })); -const mountDialog = async ({ baseCommentOverrides = {}, extraComments = [], props = {} } = {}) => { - const baseComment = reactive({ +interface BaseComment { + uid: string; + commentId: string; + parentCommentId: string | null; + email: string; + commentText: string; + fileId: string; + fileType: string; + setActive: ReturnType; + setText: ReturnType; + setIsInternal: ReturnType; + resolveComment: ReturnType; + trackedChange: boolean; + importedId: string | null; + trackedChangeType: string | null; + trackedChangeText: string | null; + deletedText: string | null; + selection: { + getValues: () => { selectionBounds: { top: number; bottom: number; left: number; right: number } }; + selectionBounds: { top: number; bottom: number; left: number; right: number }; + }; + [key: string]: unknown; +} + +interface MountOptions { + baseCommentOverrides?: Partial; + extraComments?: unknown[]; + props?: Record; +} + +const mountDialog = async ({ baseCommentOverrides = {}, extraComments = [], props = {} }: MountOptions = {}): Promise<{ + wrapper: VueWrapper; + baseComment: BaseComment; + superdocStub: Record; +}> => { + const baseComment: BaseComment = reactive({ uid: 'uid-1', commentId: 'comment-1', parentCommentId: null, @@ -197,11 +238,11 @@ const mountDialog = async ({ baseCommentOverrides = {}, extraComments = [], prop }, directives: { 'click-outside': { - mounted(el, binding) { - el.__clickOutside = binding.value; + mounted(el: HTMLElement, binding: { value: unknown }) { + (el as HTMLElement & { __clickOutside?: unknown }).__clickOutside = binding.value; }, - unmounted(el) { - delete el.__clickOutside; + unmounted(el: HTMLElement) { + delete (el as HTMLElement & { __clickOutside?: unknown }).__clickOutside; }, }, }, @@ -222,10 +263,12 @@ describe('CommentDialog.vue', () => { await nextTick(); expect(baseComment.setActive).toHaveBeenCalledWith(superdocStub); - expect(superdocStub.activeEditor.commands.setCursorById).toHaveBeenCalledWith(baseComment.commentId); - expect(commentsStoreStub.activeComment.value).toBe(baseComment.commentId); + expect( + (superdocStub.activeEditor as { commands: { setCursorById: ReturnType } }).commands.setCursorById, + ).toHaveBeenCalledWith(baseComment.commentId); + expect((commentsStoreStub.activeComment as { value: string }).value).toBe(baseComment.commentId); - commentsStoreStub.pendingComment.value = { + (commentsStoreStub.pendingComment as { value: unknown }).value = { commentId: 'pending-1', selection: baseComment.selection, isInternal: true, @@ -233,7 +276,7 @@ describe('CommentDialog.vue', () => { await nextTick(); const addButton = wrapper.findAll('button.sd-button.primary').find((btn) => btn.text() === 'Comment'); - await addButton.trigger('click'); + await addButton?.trigger('click'); expect(commentsStoreStub.getPendingComment).toHaveBeenCalled(); expect(commentsStoreStub.addComment).toHaveBeenCalledWith({ superdoc: superdocStub, @@ -253,19 +296,25 @@ describe('CommentDialog.vue', () => { const header = wrapper.findComponent(CommentHeaderStub); header.vm.$emit('resolve'); - expect(superdocStub.activeEditor.commands.acceptTrackedChangeById).toHaveBeenCalledWith(baseComment.commentId); + expect( + (superdocStub.activeEditor as { commands: { acceptTrackedChangeById: ReturnType } }).commands + .acceptTrackedChangeById, + ).toHaveBeenCalledWith(baseComment.commentId); expect(baseComment.resolveComment).toHaveBeenCalledWith({ - email: superdocStoreStub.user.email, - name: superdocStoreStub.user.name, + email: (superdocStoreStub.user as { email: string }).email, + name: (superdocStoreStub.user as { name: string }).name, superdoc: expect.any(Object), }); header.vm.$emit('reject'); - expect(superdocStub.activeEditor.commands.rejectTrackedChangeById).toHaveBeenCalledWith(baseComment.commentId); + expect( + (superdocStub.activeEditor as { commands: { rejectTrackedChangeById: ReturnType } }).commands + .rejectTrackedChangeById, + ).toHaveBeenCalledWith(baseComment.commentId); }); it('supports editing threaded comments and toggling internal state', async () => { - const childComment = reactive({ + const childComment: BaseComment = reactive({ uid: 'uid-2', commentId: 'child-1', parentCommentId: 'comment-1', @@ -278,6 +327,10 @@ describe('CommentDialog.vue', () => { setIsInternal: vi.fn(), resolveComment: vi.fn(), trackedChange: false, + importedId: null, + trackedChangeType: null, + trackedChangeText: null, + deletedText: null, selection: { getValues: () => ({ selectionBounds: { top: 120, bottom: 150, left: 20, right: 40 } }), selectionBounds: { top: 120, bottom: 150, left: 20, right: 40 }, @@ -290,13 +343,13 @@ describe('CommentDialog.vue', () => { const headers = wrapper.findAllComponents(CommentHeaderStub); headers[1].vm.$emit('overflow-select', 'edit'); - expect(commentsStoreStub.editingCommentId.value).toBe(childComment.commentId); + expect((commentsStoreStub.editingCommentId as { value: string }).value).toBe(childComment.commentId); expect(commentsStoreStub.setActiveComment).toHaveBeenCalledWith(superdocStub, childComment.commentId); - commentsStoreStub.currentCommentText.value = '

Updated

'; + (commentsStoreStub.currentCommentText as { value: string }).value = '

Updated

'; await nextTick(); const updateButton = wrapper.findAll('button.sd-button.primary').find((btn) => btn.text() === 'Update'); - await updateButton.trigger('click'); + await updateButton?.trigger('click'); expect(childComment.setText).toHaveBeenCalledWith({ text: '

Updated

', superdoc: superdocStub }); expect(commentsStoreStub.removePendingComment).toHaveBeenCalledWith(superdocStub); @@ -313,11 +366,18 @@ describe('CommentDialog.vue', () => { it('emits dialog-exit when clicking outside active comment and no track changes highlighted', async () => { const { wrapper, baseComment } = await mountDialog(); - commentsStoreStub.activeComment.value = baseComment.commentId; + (commentsStoreStub.activeComment as { value: string }).value = baseComment.commentId; const eventTarget = document.createElement('div'); - const handler = wrapper.element.__clickOutside; - handler({ target: eventTarget, classList: { contains: () => false } }); + const handler = ( + wrapper.element as HTMLElement & { + __clickOutside?: (event: { + target: HTMLElement; + classList: { contains: (className: string) => boolean }; + }) => void; + } + ).__clickOutside; + handler?.({ target: eventTarget, classList: { contains: () => false } }); expect(commentsStoreStub.setActiveComment).toHaveBeenCalledWith(expect.any(Object), null); expect(wrapper.emitted('dialog-exit')).toHaveLength(1); @@ -325,12 +385,19 @@ describe('CommentDialog.vue', () => { it('does not emit dialog-exit when track changes highlighted', async () => { const { wrapper, baseComment } = await mountDialog(); - commentsStoreStub.activeComment.value = baseComment.commentId; - commentsStoreStub.isCommentHighlighted.value = true; + (commentsStoreStub.activeComment as { value: string }).value = baseComment.commentId; + (commentsStoreStub.isCommentHighlighted as { value: boolean }).value = true; const eventTarget = document.createElement('div'); - const handler = wrapper.element.__clickOutside; - handler({ target: eventTarget, classList: { contains: () => false } }); + const handler = ( + wrapper.element as HTMLElement & { + __clickOutside?: (event: { + target: HTMLElement; + classList: { contains: (className: string) => boolean }; + }) => void; + } + ).__clickOutside; + handler?.({ target: eventTarget, classList: { contains: () => false } }); expect(commentsStoreStub.setActiveComment).not.toHaveBeenCalled(); expect(wrapper.emitted()).not.toHaveProperty('dialog-exit'); diff --git a/packages/superdoc/src/components/CommentsLayer/comment-schemas.js b/packages/superdoc/src/components/CommentsLayer/comment-schemas.js deleted file mode 100644 index 27cf7670c..000000000 --- a/packages/superdoc/src/components/CommentsLayer/comment-schemas.js +++ /dev/null @@ -1,17 +0,0 @@ -export const conversation = { - conversationId: null, - documentId: null, - creatorEmail: null, - creatorName: null, - comments: [], - selection: null, -}; - -export const comment = { - comment: null, - user: { - name: null, - email: null, - }, - timestamp: null, -}; diff --git a/packages/superdoc/src/components/CommentsLayer/comment-schemas.ts b/packages/superdoc/src/components/CommentsLayer/comment-schemas.ts new file mode 100644 index 000000000..8167d6c70 --- /dev/null +++ b/packages/superdoc/src/components/CommentsLayer/comment-schemas.ts @@ -0,0 +1,60 @@ +import type { Comment } from './types'; + +/** + * Default conversation schema structure + */ +export interface ConversationSchema { + /** Unique identifier for the conversation */ + conversationId: string | null; + /** ID of the document the conversation belongs to */ + documentId: string | null; + /** Email of the conversation creator */ + creatorEmail: string | null; + /** Name of the conversation creator */ + creatorName: string | null; + /** Array of comments in the conversation */ + comments: Comment[]; + /** Selection information for the conversation */ + selection: unknown | null; +} + +/** + * Default comment schema structure + */ +export interface CommentSchema { + /** The comment data */ + comment: Comment | null; + /** User information */ + user: { + /** User's name */ + name: string | null; + /** User's email */ + email: string | null; + }; + /** Timestamp of the comment */ + timestamp: number | null; +} + +/** + * Default conversation object + */ +export const conversation: ConversationSchema = { + conversationId: null, + documentId: null, + creatorEmail: null, + creatorName: null, + comments: [], + selection: null, +}; + +/** + * Default comment object + */ +export const comment: CommentSchema = { + comment: null, + user: { + name: null, + email: null, + }, + timestamp: null, +}; diff --git a/packages/superdoc/src/components/CommentsLayer/commentsList/super-comments-list.js b/packages/superdoc/src/components/CommentsLayer/commentsList/super-comments-list.js deleted file mode 100644 index e7e16079a..000000000 --- a/packages/superdoc/src/components/CommentsLayer/commentsList/super-comments-list.js +++ /dev/null @@ -1,56 +0,0 @@ -import EventEmitter from 'eventemitter3'; -import { createApp } from 'vue'; - -import { vClickOutside } from '@superdoc/common'; -import CommentsList from './commentsList.vue'; - -/** - * Comments list renderer (not floating comments) - * - * This renders a list of comments into an element, connected to main SuperDoc instance - */ -export class SuperComments extends EventEmitter { - element; - - config = { - comments: [], - element: null, - commentsStore: null, - }; - - constructor(options, superdoc) { - super(); - this.config = { ...this.config, ...options }; - this.element = this.config.element; - this.app = null; - this.superdoc = superdoc; - this.open(); - } - - createVueApp() { - this.app = createApp(CommentsList); - this.app.directive('click-outside', vClickOutside); - this.app.config.globalProperties.$superdoc = this.superdoc; - - if (!this.element && this.config.selector) { - this.element = document.getElementById(this.config.selector); - } - - this.container = this.app.mount(this.element); - } - - close() { - if (this.app) { - this.app.unmount(); - this.app = null; - this.container = null; - this.element = null; - } - } - - open() { - if (!this.app) { - this.createVueApp(); - } - } -} diff --git a/packages/superdoc/src/components/CommentsLayer/commentsList/super-comments-list.ts b/packages/superdoc/src/components/CommentsLayer/commentsList/super-comments-list.ts new file mode 100644 index 000000000..bce0829dc --- /dev/null +++ b/packages/superdoc/src/components/CommentsLayer/commentsList/super-comments-list.ts @@ -0,0 +1,127 @@ +import EventEmitter from 'eventemitter3'; +import { createApp, type App, type ComponentPublicInstance } from 'vue'; + +import { vClickOutside } from '@superdoc/common'; +import type { SuperDoc } from '../../../core/types'; +import CommentsList from './commentsList.vue'; + +/** + * Comments store interface (minimal definition) + */ +interface CommentsStore { + /** Comments data */ + [key: string]: unknown; +} + +/** + * Configuration options for SuperComments + */ +export interface SuperCommentsConfig { + /** Array of comments to display */ + comments?: unknown[]; + /** DOM element to mount the comments list */ + element?: HTMLElement | null; + /** CSS selector for the element to mount to */ + selector?: string; + /** Comments store instance */ + commentsStore?: CommentsStore | null; +} + +/** + * Comments list renderer (not floating comments) + * + * This renders a list of comments into an element, connected to main SuperDoc instance. + * It creates a Vue application that displays comments in a sidebar or panel. + * + * @example + * const commentsList = new SuperComments({ + * element: document.getElementById('comments-panel'), + * comments: [], + * commentsStore: commentsStore + * }, superdoc); + * + * // Later, to close + * commentsList.close(); + */ +export class SuperComments extends EventEmitter { + /** DOM element where the comments list is mounted */ + element: HTMLElement | null; + + /** Configuration for the comments list */ + config: SuperCommentsConfig = { + comments: [], + element: null, + commentsStore: null, + }; + + /** Vue application instance */ + app: App | null; + + /** SuperDoc instance */ + superdoc: SuperDoc; + + /** Mounted Vue component instance */ + container: ComponentPublicInstance | null; + + /** + * Create a new SuperComments instance + * + * @param options - Configuration options + * @param superdoc - The SuperDoc instance this comments list is connected to + */ + constructor(options: SuperCommentsConfig, superdoc: SuperDoc) { + super(); + this.config = { ...this.config, ...options }; + this.element = this.config.element || null; + this.app = null; + this.superdoc = superdoc; + this.container = null; + this.open(); + } + + /** + * Create and configure the Vue application + * + * Sets up the Vue app with the CommentsList component, registers + * the click-outside directive, and mounts the app to the target element. + */ + createVueApp(): void { + this.app = createApp(CommentsList); + this.app.directive('click-outside', vClickOutside); + this.app.config.globalProperties.$superdoc = this.superdoc; + + if (!this.element && this.config.selector) { + const foundElement = document.getElementById(this.config.selector); + this.element = foundElement; + } + + if (this.element) { + this.container = this.app.mount(this.element); + } + } + + /** + * Close and unmount the comments list + * + * Cleans up the Vue application and removes it from the DOM. + */ + close(): void { + if (this.app && this.element) { + this.app.unmount(); + this.app = null; + this.container = null; + this.element = null; + } + } + + /** + * Open the comments list + * + * Creates the Vue app if it doesn't exist. + */ + open(): void { + if (!this.app) { + this.createVueApp(); + } + } +} diff --git a/packages/superdoc/src/components/CommentsLayer/helpers.js b/packages/superdoc/src/components/CommentsLayer/helpers.ts similarity index 59% rename from packages/superdoc/src/components/CommentsLayer/helpers.js rename to packages/superdoc/src/components/CommentsLayer/helpers.ts index d99d1d7cc..e7df305d9 100644 --- a/packages/superdoc/src/components/CommentsLayer/helpers.js +++ b/packages/superdoc/src/components/CommentsLayer/helpers.ts @@ -1,10 +1,16 @@ /** - * Comments helper to format dates from timestamp + * Format a timestamp into a human-readable date string * - * @param {Number} timestamp The timestamp to format - * @returns {String} The formatted date + * Formats a timestamp into a string with the format: "h:mmAM/PM Mon DD" + * For example: "3:45PM Nov 25" + * + * @param timestamp - The timestamp to format (in milliseconds) + * @returns The formatted date string + * + * @example + * formatDate(1700927100000) // "3:45PM Nov 25" */ -export function formatDate(timestamp) { +export function formatDate(timestamp: number): string { const date = new Date(timestamp); const hours = date.getHours(); const minutes = date.getMinutes(); diff --git a/packages/superdoc/src/components/CommentsLayer/types.js b/packages/superdoc/src/components/CommentsLayer/types.js deleted file mode 100644 index 7d7668935..000000000 --- a/packages/superdoc/src/components/CommentsLayer/types.js +++ /dev/null @@ -1,79 +0,0 @@ -/** - * @typedef {Object} Comment - * @property {string} commentId - Unique identifier for the comment - * @property {string} parentCommentId - Parent's comment ID - * @property {string} fileId - ID of the file the comment belongs to - * @property {string} fileType - MIME type of the file (e.g., "application/vnd.openxmlformats-officedocument.wordprocessingml.document") - * @property {Array} mentions - Array of mentioned users/entities - * @property {string} creatorName - Name of the comment creator - * @property {number} createdTime - Timestamp when the comment was created - * @property {string} importedId - Imported comment's ID - * @property {Object} importedAuthor - Information about imported author - * @property {string} importedAuthor.name - The name of the imported author - * @property {boolean} isInternal - Whether the comment is internal - * @property {string} commentText - HTML text content of the comment - * @property {Object} selection - Selection information for the comment - * @property {string} selection.documentId - The ID of the document - * @property {number} selection.page - The page number where the comment is located - * @property {Object} selection.selectionBounds - The bounds of the selected text - * @property {boolean} trackedChange - Whether this is a tracked change - * @property {string|null} trackedChangeText - Text of the tracked change - * @property {'trackInsert' | 'trackDelete' | 'both' | 'trackFormat'} trackedChangeType - Type of tracked change - * @property {string|null} deletedText - Text that was deleted - * @property {number|null} resolvedTime - Timestamp when comment was resolved - * @property {string|null} resolvedByEmail - Email of user who resolved the comment - * @property {string|null} resolvedByName - Name of user who resolved the comment - * @property {CommentJSON} commentJSON - Structured JSON representation of the comment content - */ - -/** - * @typedef {Object} CommentContent - * @property {string} type - The type of content (e.g., "text") - * @property {Array} marks - Array of text marks/formatting - * @property {string} marks[].type - The type of mark (e.g., "textStyle") - * @property {Object} marks[].attrs - The attributes of the text mark - * @property {string} marks[].attrs.color - Text color - * @property {string} marks[].attrs.fontFamily - Font family - * @property {string} marks[].attrs.fontSize - Font size (e.g., "10pt") - * @property {string|null} marks[].attrs.styleId - Style identifier - * @property {string} text - The actual text content - */ - -/** - * @typedef {Object} CommentJSON - * @property {string} type - The type of content (e.g., "paragraph") - * @property {Object} attrs - Paragraph attributes - * @property {string|null} attrs.lineHeight - Line height for Paragraph - * @property {string|null} attrs.textIndent - Text indentation - * @property {string|null} attrs.paraId - Paragraph ID - * @property {string|null} attrs.textId - Text ID - * @property {string|null} attrs.rsidR - Revision Identifier for Paragraph - * @property {string|null} attrs.rsidRDefault - Default Revision Identifier for Runs - * @property {string|null} attrs.rsidP - Revision Identifier for Paragraph Properties - * @property {string|null} attrs.rsidRPr - Revision Identifier for Paragraph Glyph Formatting - * @property {string|null} attrs.rsidDel - Revision Identifier for Paragraph Deletion - * @property {Object} attrs.spacing - Spacing configuration - * @property {number} attrs.spacing.lineSpaceAfter - Line spacing after the paragraph - * @property {number} attrs.spacing.lineSpaceBefore - Line spacing before the paragraph - * @property {number} attrs.spacing.line - Line spacing value - * @property {string|null} attrs.spacing.lineRule - Line spacing rule - * @property {Object} attrs.extraAttrs - Additional attributes - * @property {Array|null} attrs.marksAttrs - Marks attributes - * @property {any} attrs.indent - Indentation settings - * @property {any} attrs.borders - Border settings - * @property {string|null} attrs.class - CSS class - * @property {string|null} attrs.styleId - Style identifier - * @property {string|null} attrs.sdBlockId - SuperDoc block identifier (uuid) - * @property {any} attrs.attributes - Additional attributes - * @property {string|null} attrs.filename - Associated filename - * @property {boolean | null} attrs.keepLines - Keep lines together setting - * @property {boolean|null} attrs.keepNext - Keep with next paragraph setting - * @property {Object|null} attrs.paragraphProperties - Paragraph properties - * @property {string|null} attrs.dropcap - Drop cap settings - * @property {string|null} attrs.pageBreakSource - Page break source - * @property {any} attrs.justify - Text justification - * @property {any} attrs.tabStops - Tab stops configuration - * @property {Array} content - Array of content elements - */ - -export {}; diff --git a/packages/superdoc/src/components/CommentsLayer/types.ts b/packages/superdoc/src/components/CommentsLayer/types.ts new file mode 100644 index 000000000..4a459db37 --- /dev/null +++ b/packages/superdoc/src/components/CommentsLayer/types.ts @@ -0,0 +1,230 @@ +/** + * Tracked change type for comments + */ +export type TrackedChangeType = 'trackInsert' | 'trackDelete' | 'both' | 'trackFormat'; + +/** + * Imported author information + */ +export interface ImportedAuthor { + /** The name of the imported author */ + name?: string; + /** The email of the imported author */ + email?: string; +} + +/** + * Text mark/formatting attributes + */ +export interface MarkAttrs { + /** Text color */ + color?: string; + /** Font family */ + fontFamily?: string; + /** Font size (e.g., "10pt") */ + fontSize?: string; + /** Style identifier */ + styleId?: string | null; +} + +/** + * Text mark/formatting information + */ +export interface Mark { + /** The type of mark (e.g., "textStyle") */ + type: string; + /** The attributes of the text mark */ + attrs: MarkAttrs; +} + +/** + * Comment content element + */ +export interface CommentContent { + /** The type of content (e.g., "text") */ + type: string; + /** Array of text marks/formatting */ + marks?: Mark[]; + /** The actual text content */ + text?: string; +} + +/** + * Spacing configuration for paragraph + */ +export interface ParagraphSpacing { + /** Line spacing after the paragraph */ + lineSpaceAfter?: number; + /** Line spacing before the paragraph */ + lineSpaceBefore?: number; + /** Line spacing value */ + line?: number; + /** Line spacing rule */ + lineRule?: string | null; +} + +/** + * Paragraph attributes for comment JSON + */ +export interface ParagraphAttrs { + /** Line height for Paragraph */ + lineHeight?: string | null; + /** Text indentation */ + textIndent?: string | null; + /** Paragraph ID */ + paraId?: string | null; + /** Text ID */ + textId?: string | null; + /** Revision Identifier for Paragraph */ + rsidR?: string | null; + /** Default Revision Identifier for Runs */ + rsidRDefault?: string | null; + /** Revision Identifier for Paragraph Properties */ + rsidP?: string | null; + /** Revision Identifier for Paragraph Glyph Formatting */ + rsidRPr?: string | null; + /** Revision Identifier for Paragraph Deletion */ + rsidDel?: string | null; + /** Spacing configuration */ + spacing?: ParagraphSpacing; + /** Additional attributes */ + extraAttrs?: Record; + /** Marks attributes */ + marksAttrs?: unknown[] | null; + /** Indentation settings */ + indent?: unknown; + /** Border settings */ + borders?: unknown; + /** CSS class */ + class?: string | null; + /** Style identifier */ + styleId?: string | null; + /** SuperDoc block identifier (uuid) */ + sdBlockId?: string | null; + /** Additional attributes */ + attributes?: unknown; + /** Associated filename */ + filename?: string | null; + /** Keep lines together setting */ + keepLines?: boolean | null; + /** Keep with next paragraph setting */ + keepNext?: boolean | null; + /** Paragraph properties */ + paragraphProperties?: Record | null; + /** Drop cap settings */ + dropcap?: string | null; + /** Page break source */ + pageBreakSource?: string | null; + /** Text justification */ + justify?: unknown; + /** Tab stops configuration */ + tabStops?: unknown; +} + +/** + * Structured JSON representation of the comment content + */ +export interface CommentJSON { + /** The type of content (e.g., "paragraph") */ + type: string; + /** Paragraph attributes */ + attrs?: ParagraphAttrs; + /** Array of content elements */ + content?: CommentContent[]; +} + +/** + * Selection bounds for comment positioning + */ +export interface SelectionBounds { + /** Top position */ + top?: number; + /** Left position */ + left?: number; + /** Right position */ + right?: number; + /** Bottom position */ + bottom?: number; + /** Width */ + width?: number; + /** Height */ + height?: number; +} + +/** + * Selection information for a comment + */ +export interface CommentSelection { + /** The ID of the document */ + documentId: string; + /** The page number where the comment is located */ + page: number; + /** The bounds of the selected text */ + selectionBounds: SelectionBounds; + /** Optional source of the selection */ + source?: string; +} + +/** + * Mention of a user in a comment + */ +export interface Mention { + /** Name of the mentioned user */ + name: string; + /** Email of the mentioned user */ + email: string; +} + +/** + * A comment in the document + */ +export interface Comment { + /** Unique identifier for the comment */ + commentId: string; + /** Parent's comment ID */ + parentCommentId?: string; + /** ID of the file the comment belongs to */ + fileId?: string; + /** MIME type of the file (e.g., "application/vnd.openxmlformats-officedocument.wordprocessingml.document") */ + fileType?: string; + /** Array of mentioned users/entities */ + mentions?: Mention[]; + /** Name of the comment creator */ + creatorName?: string; + /** Email of the comment creator */ + creatorEmail?: string; + /** Image/avatar of the comment creator */ + creatorImage?: string; + /** Timestamp when the comment was created */ + createdTime?: number; + /** Imported comment's ID */ + importedId?: string; + /** Information about imported author */ + importedAuthor?: ImportedAuthor | null; + /** Whether the comment is internal */ + isInternal?: boolean; + /** HTML text content of the comment */ + commentText?: string; + /** Selection information for the comment */ + selection?: CommentSelection; + /** Whether this is a tracked change */ + trackedChange?: boolean; + /** Text of the tracked change */ + trackedChangeText?: string | null; + /** Type of tracked change */ + trackedChangeType?: TrackedChangeType | null; + /** Text that was deleted */ + deletedText?: string | null; + /** Timestamp when comment was resolved */ + resolvedTime?: number | null; + /** Email of user who resolved the comment */ + resolvedByEmail?: string | null; + /** Name of user who resolved the comment */ + resolvedByName?: string | null; + /** Structured JSON representation of the comment content */ + commentJSON?: CommentJSON; + /** Version number when comment was created */ + createdAtVersionNumber?: number; + /** Unique user identifier */ + uid?: string; +} diff --git a/packages/superdoc/src/components/CommentsLayer/use-comment.js b/packages/superdoc/src/components/CommentsLayer/use-comment.js deleted file mode 100644 index aeaedda60..000000000 --- a/packages/superdoc/src/components/CommentsLayer/use-comment.js +++ /dev/null @@ -1,288 +0,0 @@ -import { ref, reactive } from 'vue'; -import { v4 as uuidv4 } from 'uuid'; - -import { syncCommentsToClients } from '@superdoc/core/collaboration/helpers.js'; -import { comments_module_events } from '@superdoc/common'; -import useSelection from '@superdoc/helpers/use-selection'; - -/** - * Comment composable - * - * @param {Object} params The initial values of the comment - * @returns {Object} The comment composable - */ -export default function useComment(params) { - const uid = ref(params.uid); - const commentId = params.commentId || uuidv4(); - const importedId = params.importedId; - const parentCommentId = params.parentCommentId; - const fileId = params.fileId; - const fileType = params.fileType; - const createdAtVersionNumber = params.createdAtVersionNumber; - const isInternal = ref(params.isInternal !== undefined ? params.isInternal : true); - - const mentions = ref([]); - - const commentElement = ref(null); - const isFocused = ref(params.isFocused || false); - - const creatorEmail = params.creatorEmail; - const creatorName = params.creatorName; - const creatorImage = params.creatorImage; - const createdTime = params.createdTime || Date.now(); - const importedAuthor = ref(params.importedAuthor || null); - - const commentText = ref(params.commentText || ''); - - const selection = params.selection - ? useSelection(params.selection) - : useSelection({ - documentId: fileId, - page: 1, - selectionBounds: {}, - }); - - const floatingPosition = params.selection?.selectionBounds - ? { ...params.selection.selectionBounds } - : { top: 0, left: 0, right: 0, bottom: 0 }; - - // Tracked changes aka suggestions - const trackedChange = ref(params.trackedChange); - const trackedChangeType = ref(params.trackedChangeType || null); - const trackedChangeText = ref(params.trackedChangeText || null); - const deletedText = ref(params.deletedText || null); - - const resolvedTime = ref(params.resolvedTime || null); - const resolvedByEmail = ref(params.resolvedByEmail || null); - const resolvedByName = ref(params.resolvedByName || null); - - /** - * Mark this conversation as resolved with UTC date - * - * @param {String} email The email of the user marking this conversation as done - * @param {String} name The name of the user marking this conversation as done - * @returns {void} - */ - const resolveComment = ({ email, name, superdoc }) => { - if (resolvedTime.value) return; - resolvedTime.value = Date.now(); - resolvedByEmail.value = email; - resolvedByName.value = name; - - if (trackedChange.value) { - const emitData = { type: comments_module_events.RESOLVED, comment: getValues() }; - propagateUpdate(superdoc, emitData); - superdoc.activeEditor?.commands?.resolveComment({ commentId, importedId }); - return; - } - - const emitData = { type: comments_module_events.RESOLVED, comment: getValues() }; - propagateUpdate(superdoc, emitData); - superdoc.activeEditor?.commands?.resolveComment({ commentId, importedId }); - }; - - /** - * Update the isInternal value of this comment - * - * @param {Object} param0 - * @param {Boolean} param0.isInternal The new isInternal value - * @param {Object} param0.superdoc The SuperDoc instance - * @returns {void} - */ - const setIsInternal = ({ isInternal: newIsInternal, superdoc }) => { - const previousValue = isInternal.value; - if (previousValue === newIsInternal) return; - - // Update the isInternal value - isInternal.value = newIsInternal; - - const emitData = { - type: comments_module_events.UPDATE, - changes: [{ key: 'isInternal', value: newIsInternal, previousValue }], - comment: getValues(), - }; - propagateUpdate(superdoc, emitData); - - const activeEditor = superdoc.activeEditor; - if (!activeEditor) return; - - activeEditor.commands.setCommentInternal({ commentId, importedId, isInternal: newIsInternal }); - }; - - /** - * Set this comment as the active comment in the editor - * - * @param {Object} superdoc The SuperDoc instance - * @returns {void} - */ - const setActive = (superdoc) => { - const { activeEditor } = superdoc; - activeEditor?.commands.setActiveComment({ commentId, importedId }); - }; - - /** - * Update the text value of this comment - * - * @param {Object} param0 - * @param {String} param0.text The new text value - * @param {Object} param0.superdoc The SuperDoc instance - * @returns {void} - */ - const setText = ({ text, superdoc, suppressUpdate }) => { - commentText.value = text; - - // Track mentions - mentions.value = extractMentions(text); - - if (suppressUpdate) return; - - const emitData = { - type: comments_module_events.UPDATE, - changes: [{ key: 'text', value: text }], - comment: getValues(), - }; - propagateUpdate(superdoc, emitData); - }; - - /** - * Extract mentions from comment contents - * - * @param {String} htmlString - * @returns {Array[Object]} An array of unique mentions - */ - const extractMentions = (htmlString) => { - const parser = new DOMParser(); - const doc = parser.parseFromString(htmlString, 'text/html'); - - const mentionElements = [...doc.querySelectorAll('span[data-type="mention"]')]; - - const uniqueMentions = []; - mentionElements.forEach((span) => { - const alreadyExists = uniqueMentions.some((m) => { - const hasEmail = m.email === span.getAttribute('email'); - const hasName = m.name === span.getAttribute('name'); - return hasEmail && hasName; - }); - - if (!alreadyExists) { - uniqueMentions.push({ - name: span.getAttribute('name'), - email: span.getAttribute('email'), - }); - } - }); - - return uniqueMentions; - }; - - /** - * Update the selection bounds of this comment - * - * @param {Object} coords Object containing the selection bounds - * @param {*} source Specifies the source of the selection bounds - */ - const updatePosition = (coords, parentElement) => { - selection.source = 'super-editor'; - const parentTop = parentElement?.getBoundingClientRect()?.top; - - const newCoords = { - top: coords.top - parentTop, - left: coords.left, - right: coords.right, - bottom: coords.bottom - parentTop, - }; - selection.selectionBounds = newCoords; - }; - - const getCommentUser = () => { - const user = importedAuthor.value - ? { name: importedAuthor.value.name || '(Imported)', email: importedAuthor.value.email } - : { name: creatorName, email: creatorEmail, image: creatorImage }; - - return user; - }; - - /** - * Emit updates to the end client, and sync with collaboration if necessary - * - * @param {Object} superdoc The SuperDoc instance - * @param {Object} event The data to emit to the client - * @returns {void} - */ - const propagateUpdate = (superdoc, event) => { - superdoc.emit('comments-update', event); - syncCommentsToClients(superdoc, event); - }; - - /** - * Get the raw values of this comment - * - * @returns {Object} - The raw values of this comment - */ - const getValues = () => { - return { - uid: uid.value, - commentId, - importedId, - parentCommentId, - fileId, - fileType, - mentions: mentions.value.map((u) => { - return { ...u, name: u.name ? u.name : u.email }; - }), - createdAtVersionNumber, - creatorEmail, - creatorName, - creatorImage, - createdTime, - importedAuthor: importedAuthor.value, - isInternal: isInternal.value, - commentText: commentText.value, - selection: selection ? selection.getValues() : null, - trackedChange: trackedChange.value, - trackedChangeText: trackedChangeText.value, - trackedChangeType: trackedChangeType.value, - deletedText: deletedText.value, - resolvedTime: resolvedTime.value, - resolvedByEmail: resolvedByEmail.value, - resolvedByName: resolvedByName.value, - }; - }; - - return reactive({ - uid, - commentId, - importedId, - parentCommentId, - fileId, - fileType, - mentions, - commentElement, - isFocused, - creatorEmail, - creatorName, - creatorImage, - createdTime, - isInternal, - commentText, - selection, - floatingPosition, - trackedChange, - deletedText, - trackedChangeType, - trackedChangeText, - resolvedTime, - resolvedByEmail, - resolvedByName, - importedAuthor, - - // Actions - setText, - getValues, - resolveComment, - setIsInternal, - setActive, - updatePosition, - getCommentUser, - }); -} diff --git a/packages/superdoc/src/components/CommentsLayer/use-comment.test.js b/packages/superdoc/src/components/CommentsLayer/use-comment.test.ts similarity index 84% rename from packages/superdoc/src/components/CommentsLayer/use-comment.test.js rename to packages/superdoc/src/components/CommentsLayer/use-comment.test.ts index c15fd0641..ef7b40d23 100644 --- a/packages/superdoc/src/components/CommentsLayer/use-comment.test.js +++ b/packages/superdoc/src/components/CommentsLayer/use-comment.test.ts @@ -3,7 +3,7 @@ import useComment from './use-comment.js'; const { syncCommentsMock, useSelectionMock, uuidMock } = vi.hoisted(() => { const syncCommentsMock = vi.fn(); - const useSelectionMock = vi.fn((params) => ({ + const useSelectionMock = vi.fn((params: Record) => ({ ...params, selectionBounds: params.selectionBounds || {}, getValues: () => ({ ...params }), @@ -24,8 +24,19 @@ vi.mock('uuid', () => ({ v4: uuidMock, })); +interface MockSuperdoc { + emit: ReturnType; + activeEditor: { + commands: { + resolveComment: ReturnType; + setActiveComment: ReturnType; + setCommentInternal: ReturnType; + }; + }; +} + describe('useComment composable', () => { - const createSuperdoc = () => ({ + const createSuperdoc = (): MockSuperdoc => ({ emit: vi.fn(), activeEditor: { commands: { @@ -54,7 +65,7 @@ describe('useComment composable', () => { comment.setText({ text: '

', - superdoc, + superdoc: superdoc as never, }); expect(comment.commentText).toBe('

'); @@ -77,7 +88,7 @@ describe('useComment composable', () => { trackedChange: true, }); - const payload = { email: 'resolver@example.com', name: 'Resolver', superdoc }; + const payload = { email: 'resolver@example.com', name: 'Resolver', superdoc: superdoc as never }; comment.resolveComment(payload); const firstResolved = comment.resolvedTime; @@ -101,10 +112,10 @@ describe('useComment composable', () => { isInternal: true, }); - comment.setIsInternal({ isInternal: true, superdoc }); + comment.setIsInternal({ isInternal: true, superdoc: superdoc as never }); expect(superdoc.emit).not.toHaveBeenCalled(); - comment.setIsInternal({ isInternal: false, superdoc }); + comment.setIsInternal({ isInternal: false, superdoc: superdoc as never }); expect(comment.isInternal).toBe(false); expect(superdoc.emit).toHaveBeenCalledWith( 'comments-update', @@ -118,7 +129,6 @@ describe('useComment composable', () => { }); it('updates selection bounds relative to parent', () => { - const superdoc = createSuperdoc(); const comment = useComment({ commentId: 'comment-3', creatorEmail: 'author@example.com', @@ -135,9 +145,12 @@ describe('useComment composable', () => { getBoundingClientRect: () => ({ top: 20 }), }; - comment.updatePosition({ top: 120, bottom: 160, left: 40, right: 80 }, parent); + comment.updatePosition({ top: 120, bottom: 160, left: 40, right: 80 }, parent as HTMLElement); - expect(comment.selection.selectionBounds).toEqual({ top: 100, bottom: 140, left: 40, right: 80 }); + expect( + (comment.selection as { selectionBounds: { top: number; bottom: number; left: number; right: number } }) + .selectionBounds, + ).toEqual({ top: 100, bottom: 140, left: 40, right: 80 }); }); it('exposes getValues and setActive helpers', () => { @@ -154,7 +167,7 @@ describe('useComment composable', () => { expect(values.commentId).toBe('comment-4'); expect(values.selection).toEqual(expect.objectContaining({ documentId: 'doc-1' })); - comment.setActive(superdoc); + comment.setActive(superdoc as never); expect(superdoc.activeEditor.commands.setActiveComment).toHaveBeenCalledWith({ commentId: 'comment-4', importedId: undefined, diff --git a/packages/superdoc/src/components/CommentsLayer/use-comment.ts b/packages/superdoc/src/components/CommentsLayer/use-comment.ts new file mode 100644 index 000000000..6ff1cf3ff --- /dev/null +++ b/packages/superdoc/src/components/CommentsLayer/use-comment.ts @@ -0,0 +1,581 @@ +import { ref, reactive, type Ref, type UnwrapNestedRefs } from 'vue'; +import { v4 as uuidv4 } from 'uuid'; + +import { syncCommentsToClients } from '../../core/collaboration/helpers'; +import { comments_module_events } from '@superdoc/common'; +import useSelection, { type UseSelectionReturn } from '../../helpers/use-selection'; +import type { SuperDoc } from '../../core/types'; +import type { Mention, ImportedAuthor, TrackedChangeType, SelectionBounds } from './types'; + +/** + * Comment event data for updates + */ +interface CommentEventData { + /** Event type */ + type: string; + /** The comment data */ + comment: CommentValues; + /** Array of changes (for update events) */ + changes?: Array<{ key: string; value: unknown; previousValue?: unknown }>; + /** Additional properties for various event types */ + [key: string]: unknown; +} + +/** + * User information + */ +export interface CommentUser { + /** User's name */ + name: string; + /** User's email */ + email: string; + /** User's image/avatar (optional) */ + image?: string; +} + +/** + * Selection parameters for comment initialization + */ +interface CommentSelectionParams { + /** Document ID */ + documentId: string; + /** Page number */ + page: number; + /** Selection bounds */ + selectionBounds: SelectionBounds; + /** Optional source identifier */ + source?: string; +} + +/** + * Parameters for initializing a comment + */ +export interface UseCommentParams { + /** Unique user identifier */ + uid?: string; + /** Unique identifier for the comment */ + commentId?: string; + /** Imported comment's ID */ + importedId?: string; + /** Parent's comment ID */ + parentCommentId?: string; + /** ID of the file the comment belongs to */ + fileId?: string; + /** MIME type of the file */ + fileType?: string; + /** Version number when comment was created */ + createdAtVersionNumber?: number; + /** Whether the comment is internal */ + isInternal?: boolean; + /** Whether the comment is focused */ + isFocused?: boolean; + /** Email of the comment creator */ + creatorEmail?: string; + /** Name of the comment creator */ + creatorName?: string; + /** Image/avatar of the comment creator */ + creatorImage?: string; + /** Timestamp when the comment was created */ + createdTime?: number; + /** Information about imported author */ + importedAuthor?: ImportedAuthor | null; + /** HTML text content of the comment */ + commentText?: string; + /** Selection information for the comment */ + selection?: CommentSelectionParams; + /** Whether this is a tracked change */ + trackedChange?: boolean; + /** Type of tracked change */ + trackedChangeType?: TrackedChangeType | null; + /** Text of the tracked change */ + trackedChangeText?: string | null; + /** Text that was deleted */ + deletedText?: string | null; + /** Timestamp when comment was resolved */ + resolvedTime?: number | null; + /** Email of user who resolved the comment */ + resolvedByEmail?: string | null; + /** Name of user who resolved the comment */ + resolvedByName?: string | null; +} + +/** + * Comment values object returned by getValues() + */ +export interface CommentValues { + /** Unique user identifier */ + uid?: string; + /** Unique identifier for the comment */ + commentId: string; + /** Imported comment's ID */ + importedId?: string; + /** Parent's comment ID */ + parentCommentId?: string; + /** ID of the file the comment belongs to */ + fileId?: string; + /** MIME type of the file */ + fileType?: string; + /** Array of mentioned users */ + mentions: Mention[]; + /** Version number when comment was created */ + createdAtVersionNumber?: number; + /** Email of the comment creator */ + creatorEmail?: string; + /** Name of the comment creator */ + creatorName?: string; + /** Image/avatar of the comment creator */ + creatorImage?: string; + /** Timestamp when the comment was created */ + createdTime?: number; + /** Information about imported author */ + importedAuthor?: ImportedAuthor | null; + /** Whether the comment is internal */ + isInternal: boolean; + /** HTML text content of the comment */ + commentText: string; + /** Selection information for the comment */ + selection: ReturnType | null; + /** Whether this is a tracked change */ + trackedChange?: boolean; + /** Text of the tracked change */ + trackedChangeText?: string | null; + /** Type of tracked change */ + trackedChangeType?: TrackedChangeType | null; + /** Text that was deleted */ + deletedText?: string | null; + /** Timestamp when comment was resolved */ + resolvedTime?: number | null; + /** Email of user who resolved the comment */ + resolvedByEmail?: string | null; + /** Name of user who resolved the comment */ + resolvedByName?: string | null; +} + +/** + * Parameters for setText method + */ +export interface SetTextParams { + /** The new text value */ + text: string; + /** The SuperDoc instance */ + superdoc: SuperDoc; + /** Whether to suppress propagating the update */ + suppressUpdate?: boolean; +} + +/** + * Parameters for resolveComment method + */ +export interface ResolveCommentParams { + /** Email of the user resolving the comment */ + email: string; + /** Name of the user resolving the comment */ + name: string; + /** The SuperDoc instance */ + superdoc: SuperDoc; +} + +/** + * Parameters for setIsInternal method + */ +export interface SetIsInternalParams { + /** The new isInternal value */ + isInternal: boolean; + /** The SuperDoc instance */ + superdoc: SuperDoc; +} + +/** + * Return type of the useComment composable + */ +export interface UseCommentReturn { + /** Unique user identifier */ + uid: Ref; + /** Unique identifier for the comment */ + commentId: string; + /** Imported comment's ID */ + importedId?: string; + /** Parent's comment ID */ + parentCommentId?: string; + /** ID of the file the comment belongs to */ + fileId?: string; + /** MIME type of the file */ + fileType?: string; + /** Array of mentioned users */ + mentions: Ref; + /** Reference to the comment DOM element */ + commentElement: Ref; + /** Whether the comment is focused */ + isFocused: Ref; + /** Email of the comment creator */ + creatorEmail?: string; + /** Name of the comment creator */ + creatorName?: string; + /** Image/avatar of the comment creator */ + creatorImage?: string; + /** Timestamp when the comment was created */ + createdTime?: number; + /** Whether the comment is internal */ + isInternal: Ref; + /** HTML text content of the comment */ + commentText: Ref; + /** Selection information for the comment */ + selection: UseSelectionReturn; + /** Floating position coordinates */ + floatingPosition: { top: number; left: number; right: number; bottom: number }; + /** Whether this is a tracked change */ + trackedChange: Ref; + /** Text that was deleted */ + deletedText: Ref; + /** Type of tracked change */ + trackedChangeType: Ref; + /** Text of the tracked change */ + trackedChangeText: Ref; + /** Timestamp when comment was resolved */ + resolvedTime: Ref; + /** Email of user who resolved the comment */ + resolvedByEmail: Ref; + /** Name of user who resolved the comment */ + resolvedByName: Ref; + /** Information about imported author */ + importedAuthor: Ref; + /** Set the text content of the comment */ + setText: (params: SetTextParams) => void; + /** Get the raw values of the comment */ + getValues: () => CommentValues; + /** Mark this comment as resolved */ + resolveComment: (params: ResolveCommentParams) => void; + /** Update the isInternal value */ + setIsInternal: (params: SetIsInternalParams) => void; + /** Set this comment as active in the editor */ + setActive: (superdoc: SuperDoc) => void; + /** Update the position of the comment */ + updatePosition: ( + coords: { top: number; left: number; right: number; bottom: number }, + parentElement: HTMLElement, + ) => void; + /** Get the user information for this comment */ + getCommentUser: () => CommentUser; +} + +/** + * Vue composable for managing individual comment state and actions + * + * This composable provides comprehensive comment management including: + * - Reactive state for all comment properties + * - Text content management with mention extraction + * - Resolution and internal/external status tracking + * - Position updates for floating comments + * - Collaboration sync with other clients + * + * @param params - Comment initialization parameters + * @returns Comment state and action methods + * + * @example + * const comment = useComment({ + * commentId: 'comment-123', + * fileId: 'doc-456', + * creatorEmail: 'user@example.com', + * creatorName: 'John Doe', + * commentText: '

This is a comment

', + * selection: { + * documentId: 'doc-456', + * page: 1, + * selectionBounds: { top: 100, left: 50, width: 200, height: 20 } + * } + * }); + * + * comment.setText({ text: 'Updated text', superdoc }); + * comment.resolveComment({ email: 'user@example.com', name: 'John Doe', superdoc }); + */ +export default function useComment(params: UseCommentParams): UnwrapNestedRefs { + const uid = ref(params.uid); + const commentId = params.commentId || uuidv4(); + const importedId = params.importedId; + const parentCommentId = params.parentCommentId; + const fileId = params.fileId; + const fileType = params.fileType; + const createdAtVersionNumber = params.createdAtVersionNumber; + const isInternal = ref(params.isInternal !== undefined ? params.isInternal : true); + + const mentions = ref([]); + + const commentElement = ref(null); + const isFocused = ref(params.isFocused || false); + + const creatorEmail = params.creatorEmail; + const creatorName = params.creatorName; + const creatorImage = params.creatorImage; + const createdTime = params.createdTime || Date.now(); + const importedAuthor = ref(params.importedAuthor || null); + + const commentText = ref(params.commentText || ''); + + const selection = params.selection + ? useSelection(params.selection) + : useSelection({ + documentId: fileId || '', + page: 1, + selectionBounds: {}, + }); + + const floatingPosition = params.selection?.selectionBounds + ? { + ...params.selection.selectionBounds, + top: params.selection.selectionBounds.top || 0, + left: params.selection.selectionBounds.left || 0, + right: params.selection.selectionBounds.right || 0, + bottom: params.selection.selectionBounds.bottom || 0, + } + : { top: 0, left: 0, right: 0, bottom: 0 }; + + // Tracked changes aka suggestions + const trackedChange = ref(params.trackedChange); + const trackedChangeType = ref(params.trackedChangeType || null); + const trackedChangeText = ref(params.trackedChangeText || null); + const deletedText = ref(params.deletedText || null); + + const resolvedTime = ref(params.resolvedTime || null); + const resolvedByEmail = ref(params.resolvedByEmail || null); + const resolvedByName = ref(params.resolvedByName || null); + + /** + * Mark this conversation as resolved with UTC date + * + * @param params - Resolution parameters including user email, name, and superdoc instance + */ + const resolveComment = ({ email, name, superdoc }: ResolveCommentParams): void => { + if (resolvedTime.value) return; + resolvedTime.value = Date.now(); + resolvedByEmail.value = email; + resolvedByName.value = name; + + if (trackedChange.value) { + const emitData: CommentEventData = { type: comments_module_events.RESOLVED, comment: getValues() }; + propagateUpdate(superdoc, emitData); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (superdoc.activeEditor as any)?.commands?.resolveComment({ commentId, importedId }); + return; + } + + const emitData: CommentEventData = { type: comments_module_events.RESOLVED, comment: getValues() }; + propagateUpdate(superdoc, emitData); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (superdoc.activeEditor as any)?.commands?.resolveComment({ commentId, importedId }); + }; + + /** + * Update the isInternal value of this comment + * + * @param params - Parameters including new isInternal value and superdoc instance + */ + const setIsInternal = ({ isInternal: newIsInternal, superdoc }: SetIsInternalParams): void => { + const previousValue = isInternal.value; + if (previousValue === newIsInternal) return; + + // Update the isInternal value + isInternal.value = newIsInternal; + + const emitData: CommentEventData = { + type: comments_module_events.UPDATE, + changes: [{ key: 'isInternal', value: newIsInternal, previousValue }], + comment: getValues(), + }; + propagateUpdate(superdoc, emitData); + + const activeEditor = superdoc.activeEditor; + if (!activeEditor) return; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (activeEditor as any).commands.setCommentInternal({ commentId, importedId, isInternal: newIsInternal }); + }; + + /** + * Set this comment as the active comment in the editor + * + * @param superdoc - The SuperDoc instance + */ + const setActive = (superdoc: SuperDoc): void => { + const { activeEditor } = superdoc; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (activeEditor as any)?.commands.setActiveComment({ commentId, importedId }); + }; + + /** + * Update the text value of this comment + * + * @param params - Parameters including new text, superdoc instance, and optional suppressUpdate flag + */ + const setText = ({ text, superdoc, suppressUpdate }: SetTextParams): void => { + commentText.value = text; + + // Track mentions + mentions.value = extractMentions(text); + + if (suppressUpdate) return; + + const emitData: CommentEventData = { + type: comments_module_events.UPDATE, + changes: [{ key: 'text', value: text }], + comment: getValues(), + }; + propagateUpdate(superdoc, emitData); + }; + + /** + * Extract mentions from comment contents + * + * @param htmlString - HTML string containing mention elements + * @returns An array of unique mentions + */ + const extractMentions = (htmlString: string): Mention[] => { + const parser = new DOMParser(); + const doc = parser.parseFromString(htmlString, 'text/html'); + + const mentionElements = Array.from(doc.querySelectorAll('span[data-type="mention"]')); + + const uniqueMentions: Mention[] = []; + mentionElements.forEach((span) => { + const alreadyExists = uniqueMentions.some((m) => { + const hasEmail = m.email === span.getAttribute('email'); + const hasName = m.name === span.getAttribute('name'); + return hasEmail && hasName; + }); + + if (!alreadyExists) { + uniqueMentions.push({ + name: span.getAttribute('name') || '', + email: span.getAttribute('email') || '', + }); + } + }); + + return uniqueMentions; + }; + + /** + * Update the selection bounds of this comment + * + * @param coords - Object containing the selection bounds + * @param parentElement - The parent element to calculate relative position from + */ + const updatePosition = ( + coords: { top: number; left: number; right: number; bottom: number }, + parentElement: HTMLElement, + ): void => { + if (selection.source) { + selection.source.value = 'super-editor'; + } + const parentTop = parentElement?.getBoundingClientRect()?.top || 0; + + const newCoords = { + top: coords.top - parentTop, + left: coords.left, + right: coords.right, + bottom: coords.bottom - parentTop, + }; + Object.assign(selection.selectionBounds, newCoords); + }; + + /** + * Get the user information for this comment + * + * @returns User object with name, email, and optional image + */ + const getCommentUser = (): CommentUser => { + const user = importedAuthor.value + ? { name: importedAuthor.value.name || '(Imported)', email: importedAuthor.value.email || '' } + : { name: creatorName || '', email: creatorEmail || '', image: creatorImage }; + + return user; + }; + + /** + * Emit updates to the end client, and sync with collaboration if necessary + * + * @param superdoc - The SuperDoc instance + * @param event - The data to emit to the client + */ + const propagateUpdate = (superdoc: SuperDoc, event: CommentEventData): void => { + // Emit the event directly - it contains type, comment, and changes properties + superdoc.emit?.('comments-update', event as unknown as { type: string; data: object }); + // Cast through unknown to allow the type assertion - syncCommentsToClients has stricter types + // but accepts the same data at runtime + syncCommentsToClients( + superdoc as unknown as Parameters[0], + event as unknown as Parameters[1], + ); + }; + + /** + * Get the raw values of this comment + * + * @returns The raw values of this comment as a plain object + */ + const getValues = (): CommentValues => { + return { + uid: uid.value, + commentId, + importedId, + parentCommentId, + fileId, + fileType, + mentions: mentions.value.map((u) => { + return { ...u, name: u.name ? u.name : u.email }; + }), + createdAtVersionNumber, + creatorEmail, + creatorName, + creatorImage, + createdTime, + importedAuthor: importedAuthor.value, + isInternal: isInternal.value, + commentText: commentText.value, + selection: selection ? selection.getValues() : null, + trackedChange: trackedChange.value, + trackedChangeText: trackedChangeText.value, + trackedChangeType: trackedChangeType.value, + deletedText: deletedText.value, + resolvedTime: resolvedTime.value, + resolvedByEmail: resolvedByEmail.value, + resolvedByName: resolvedByName.value, + }; + }; + + return reactive({ + uid, + commentId, + importedId, + parentCommentId, + fileId, + fileType, + mentions, + commentElement, + isFocused, + creatorEmail, + creatorName, + creatorImage, + createdTime, + isInternal, + commentText, + selection, + floatingPosition, + trackedChange, + deletedText, + trackedChangeType, + trackedChangeText, + resolvedTime, + resolvedByEmail, + resolvedByName, + importedAuthor, + + // Actions + setText, + getValues, + resolveComment, + setIsInternal, + setActive, + updatePosition, + getCommentUser, + }); +} diff --git a/packages/superdoc/src/components/CommentsLayer/use-conversation.js b/packages/superdoc/src/components/CommentsLayer/use-conversation.js deleted file mode 100644 index d42240b07..000000000 --- a/packages/superdoc/src/components/CommentsLayer/use-conversation.js +++ /dev/null @@ -1,92 +0,0 @@ -import { ref, reactive } from 'vue'; -import { v4 as uuidv4 } from 'uuid'; -import useSelection from '@superdoc/helpers/use-selection'; -import useComment from '@superdoc/components/CommentsLayer/use-comment'; - -export default function useConversation(params) { - const conversationId = params.conversationId || uuidv4(); - const documentId = params.documentId; - const creatorEmail = params.creatorEmail; - const creatorName = params.creatorName; - const comments = ref(params.comments ? params.comments.map((c) => useComment(c)) : []); - const selection = useSelection(params.selection); - const suppressHighlight = ref(params.suppressHighlight); - const suppressClick = ref(params.suppressClick || params.selection?.source === 'super-editor'); - const thread = ref(params.thread == null ? null : params.thread); - const isTrackedChange = ref(params.isTrackedChange || false); - const trackedChange = reactive(params.trackedChange || { insertion: null, deletion: null }); - - /* Mark done (resolve) conversations */ - const markedDone = ref(params.markedDone || null); - const markedDoneByEmail = ref(params.markedDoneByEmail || null); - const markedDoneByName = ref(params.markedDoneByName || null); - const group = ref(null); - const isInternal = ref(params.isInternal || true); - - const conversationElement = ref(null); - - const isFocused = ref(params.isFocused || false); - - /** - * Mark this conversation as done with UTC date - * - */ - const markDone = (email, name) => { - markedDone.value = new Date().toISOString(); - markedDoneByEmail.value = email; - markedDoneByName.value = name; - group.value = null; - }; - - /** - * Get the raw values of this comment - * - * @returns {Object} - The raw values of this comment - */ - const getValues = () => { - const values = { - // Raw - conversationId, - documentId, - creatorEmail, - creatorName, - - comments: comments.value.map((c) => c.getValues()), - selection: selection.getValues(), - markedDone: markedDone.value, - markedDoneByEmail: markedDoneByEmail.value, - markedDoneByName: markedDoneByName.value, - isFocused: isFocused.value, - }; - return values; - }; - - const exposedData = { - conversationId, - thread, - documentId, - creatorEmail, - creatorName, - comments, - selection, - markedDone, - markedDoneByEmail, - markedDoneByName, - isFocused, - group, - conversationElement, - suppressHighlight, - suppressClick, - isInternal, - isTrackedChange, - trackedChange, - }; - - return { - ...exposedData, - - // Actions - getValues, - markDone, - }; -} diff --git a/packages/superdoc/src/components/CommentsLayer/use-conversation.ts b/packages/superdoc/src/components/CommentsLayer/use-conversation.ts new file mode 100644 index 000000000..5ed4a7cf8 --- /dev/null +++ b/packages/superdoc/src/components/CommentsLayer/use-conversation.ts @@ -0,0 +1,242 @@ +import { ref, reactive, type Ref, type UnwrapNestedRefs } from 'vue'; +import { v4 as uuidv4 } from 'uuid'; +import useSelection, { type UseSelectionReturn, type UseSelectionParams } from '../../helpers/use-selection'; +import useComment, { type UseCommentParams, type UseCommentReturn } from './use-comment'; + +/** + * Tracked change information for a conversation + */ +interface TrackedChangeInfo { + /** Insertion tracked change data */ + insertion: unknown | null; + /** Deletion tracked change data */ + deletion: unknown | null; +} + +/** + * Parameters for initializing a conversation + */ +export interface UseConversationParams { + /** Unique identifier for the conversation */ + conversationId?: string; + /** ID of the document the conversation belongs to */ + documentId?: string; + /** Email of the conversation creator */ + creatorEmail?: string; + /** Name of the conversation creator */ + creatorName?: string; + /** Array of comment parameters to initialize comments */ + comments?: UseCommentParams[]; + /** Selection information for the conversation */ + selection?: UseSelectionParams; + /** Whether to suppress highlighting */ + suppressHighlight?: boolean; + /** Whether to suppress click interactions */ + suppressClick?: boolean; + /** Thread identifier */ + thread?: string | null; + /** Whether this is a tracked change */ + isTrackedChange?: boolean; + /** Tracked change information */ + trackedChange?: TrackedChangeInfo; + /** When the conversation was marked as done */ + markedDone?: string | null; + /** Email of user who marked conversation as done */ + markedDoneByEmail?: string | null; + /** Name of user who marked conversation as done */ + markedDoneByName?: string | null; + /** Whether the conversation is focused */ + isFocused?: boolean; + /** Whether the comment is internal */ + isInternal?: boolean; +} + +/** + * Conversation values object returned by getValues() + */ +export interface ConversationValues { + /** Unique identifier for the conversation */ + conversationId: string; + /** ID of the document the conversation belongs to */ + documentId?: string; + /** Email of the conversation creator */ + creatorEmail?: string; + /** Name of the conversation creator */ + creatorName?: string; + /** Array of comment values */ + comments: ReturnType[]; + /** Selection information */ + selection: ReturnType; + /** When the conversation was marked as done */ + markedDone: string | null; + /** Email of user who marked conversation as done */ + markedDoneByEmail: string | null; + /** Name of user who marked conversation as done */ + markedDoneByName: string | null; + /** Whether the conversation is focused */ + isFocused: boolean; +} + +/** + * Return type of the useConversation composable + */ +export interface UseConversationReturn { + /** Unique identifier for the conversation */ + conversationId: string; + /** Thread identifier */ + thread: Ref; + /** ID of the document the conversation belongs to */ + documentId?: string; + /** Email of the conversation creator */ + creatorEmail?: string; + /** Name of the conversation creator */ + creatorName?: string; + /** Array of comment instances */ + comments: Ref[]>; + /** Selection information */ + selection: UseSelectionReturn; + /** When the conversation was marked as done */ + markedDone: Ref; + /** Email of user who marked conversation as done */ + markedDoneByEmail: Ref; + /** Name of user who marked conversation as done */ + markedDoneByName: Ref; + /** Whether the conversation is focused */ + isFocused: Ref; + /** Group identifier */ + group: Ref; + /** Reference to the conversation DOM element */ + conversationElement: Ref; + /** Whether to suppress highlighting */ + suppressHighlight: Ref; + /** Whether to suppress click interactions */ + suppressClick: Ref; + /** Whether the comment is internal */ + isInternal: Ref; + /** Whether this is a tracked change */ + isTrackedChange: Ref; + /** Tracked change information */ + trackedChange: TrackedChangeInfo; + /** Get the raw values of the conversation */ + getValues: () => ConversationValues; + /** Mark this conversation as done */ + markDone: (email: string, name: string) => void; +} + +/** + * Vue composable for managing conversation state + * + * This composable provides state management for comment conversations, + * including multiple comments in a thread, selection tracking, and + * conversation resolution (marking as done). + * + * @param params - Conversation initialization parameters + * @returns Conversation state and action methods + * + * @example + * const conversation = useConversation({ + * conversationId: 'conv-123', + * documentId: 'doc-456', + * creatorEmail: 'user@example.com', + * creatorName: 'John Doe', + * comments: [ + * { commentText: 'First comment', creatorEmail: 'user@example.com' } + * ], + * selection: { + * documentId: 'doc-456', + * page: 1, + * selectionBounds: { top: 100, left: 50 } + * } + * }); + * + * conversation.markDone('user@example.com', 'John Doe'); + */ +export default function useConversation(params: UseConversationParams): UseConversationReturn { + const conversationId = params.conversationId || uuidv4(); + const documentId = params.documentId; + const creatorEmail = params.creatorEmail; + const creatorName = params.creatorName; + const comments = ref[]>( + params.comments ? params.comments.map((c) => useComment(c)) : [], + ); + const selection = useSelection(params.selection || { documentId: documentId || '', page: 1, selectionBounds: {} }); + const suppressHighlight = ref(params.suppressHighlight); + const suppressClick = ref(params.suppressClick || params.selection?.source === 'super-editor'); + const thread = ref(params.thread == null ? null : params.thread); + const isTrackedChange = ref(params.isTrackedChange || false); + const trackedChange = reactive(params.trackedChange || { insertion: null, deletion: null }); + + /* Mark done (resolve) conversations */ + const markedDone = ref(params.markedDone || null); + const markedDoneByEmail = ref(params.markedDoneByEmail || null); + const markedDoneByName = ref(params.markedDoneByName || null); + const group = ref(null); + const isInternal = ref(params.isInternal || true); + + const conversationElement = ref(null); + + const isFocused = ref(params.isFocused || false); + + /** + * Mark this conversation as done with UTC date + * + * @param email - Email of the user marking the conversation as done + * @param name - Name of the user marking the conversation as done + */ + const markDone = (email: string, name: string): void => { + markedDone.value = new Date().toISOString(); + markedDoneByEmail.value = email; + markedDoneByName.value = name; + group.value = null; + }; + + /** + * Get the raw values of this conversation + * + * @returns The raw values of this conversation as a plain object + */ + const getValues = (): ConversationValues => { + const values: ConversationValues = { + // Raw + conversationId, + documentId, + creatorEmail, + creatorName, + + comments: comments.value.map((c) => c.getValues()), + selection: selection.getValues(), + markedDone: markedDone.value, + markedDoneByEmail: markedDoneByEmail.value, + markedDoneByName: markedDoneByName.value, + isFocused: isFocused.value, + }; + return values; + }; + + const exposedData: UseConversationReturn = { + conversationId, + thread, + documentId, + creatorEmail, + creatorName, + comments, + selection, + markedDone, + markedDoneByEmail, + markedDoneByName, + isFocused, + group, + conversationElement, + suppressHighlight, + suppressClick, + isInternal, + isTrackedChange, + trackedChange, + + // Actions + getValues, + markDone, + }; + + return exposedData; +} diff --git a/packages/superdoc/src/components/CommentsLayer/use-floating-comment.js b/packages/superdoc/src/components/CommentsLayer/use-floating-comment.js deleted file mode 100644 index 070894e40..000000000 --- a/packages/superdoc/src/components/CommentsLayer/use-floating-comment.js +++ /dev/null @@ -1,21 +0,0 @@ -import { ref, reactive } from 'vue'; - -export function useFloatingComment(params) { - const id = params.commentId; - const comment = ref(params); - - const position = reactive({ - top: 0, - left: 0, - right: 0, - bottom: 0, - }); - const offset = ref(0); - - return { - id, - comment, - position, - offset, - }; -} diff --git a/packages/superdoc/src/components/CommentsLayer/use-floating-comment.ts b/packages/superdoc/src/components/CommentsLayer/use-floating-comment.ts new file mode 100644 index 000000000..3fb609cbc --- /dev/null +++ b/packages/superdoc/src/components/CommentsLayer/use-floating-comment.ts @@ -0,0 +1,78 @@ +import { ref, reactive, type Ref } from 'vue'; +import type { Comment } from './types'; + +/** + * Position coordinates for floating comment + */ +export interface FloatingPosition { + /** Top position in pixels */ + top: number; + /** Left position in pixels */ + left: number; + /** Right position in pixels */ + right: number; + /** Bottom position in pixels */ + bottom: number; +} + +/** + * Parameters for initializing a floating comment + */ +export interface UseFloatingCommentParams extends Comment { + /** The comment ID is required for floating comments */ + commentId: string; +} + +/** + * Return type of the useFloatingComment composable + */ +export interface UseFloatingCommentReturn { + /** Unique identifier for the floating comment */ + id: string; + /** Reference to the comment data */ + comment: Ref; + /** Reactive position coordinates */ + position: FloatingPosition; + /** Offset value for positioning */ + offset: Ref; +} + +/** + * Vue composable for managing floating comment state + * + * This composable provides reactive state management for floating comments, + * including position tracking and offset calculations for proper positioning + * in the document viewer. + * + * @param params - Floating comment initialization parameters + * @returns Floating comment state and properties + * + * @example + * const floatingComment = useFloatingComment({ + * commentId: 'comment-123', + * commentText: 'This is a comment', + * // ... other comment properties + * }); + * + * floatingComment.position.top = 100; + * floatingComment.offset.value = 20; + */ +export function useFloatingComment(params: UseFloatingCommentParams): UseFloatingCommentReturn { + const id = params.commentId; + const comment = ref(params); + + const position = reactive({ + top: 0, + left: 0, + right: 0, + bottom: 0, + }); + const offset = ref(0); + + return { + id, + comment, + position, + offset, + }; +} diff --git a/packages/superdoc/src/components/PdfViewer/PdfViewer.vue b/packages/superdoc/src/components/PdfViewer/PdfViewer.vue index 4e364294c..f16991caa 100644 --- a/packages/superdoc/src/components/PdfViewer/PdfViewer.vue +++ b/packages/superdoc/src/components/PdfViewer/PdfViewer.vue @@ -3,7 +3,7 @@ import { NSpin } from 'naive-ui'; import { storeToRefs } from 'pinia'; import { onMounted, onUnmounted, ref } from 'vue'; import { useSuperdocStore } from '@superdoc/stores/superdoc-store'; -import { PDFAdapterFactory, createPDFConfig } from './pdf/pdf-adapter.js'; +import { PDFAdapterFactory, createPDFConfig } from './pdf/pdf-adapter'; import { readFileAsArrayBuffer } from './helpers/read-file.js'; import useSelection from '@superdoc/helpers/use-selection'; import './pdf/pdf-viewer.css'; diff --git a/packages/superdoc/src/components/PdfViewer/helpers/range.js b/packages/superdoc/src/components/PdfViewer/helpers/range.js deleted file mode 100644 index 5769f0ded..000000000 --- a/packages/superdoc/src/components/PdfViewer/helpers/range.js +++ /dev/null @@ -1,4 +0,0 @@ -export const range = (start, end) => { - const length = end - start; - return Array.from({ length }, (_, i) => start + i); -}; diff --git a/packages/superdoc/src/components/PdfViewer/helpers/range.ts b/packages/superdoc/src/components/PdfViewer/helpers/range.ts new file mode 100644 index 000000000..93a369510 --- /dev/null +++ b/packages/superdoc/src/components/PdfViewer/helpers/range.ts @@ -0,0 +1,20 @@ +/** + * Generate an array of numbers in a specified range + * + * Creates an array containing consecutive integers from start (inclusive) + * to end (exclusive), similar to Python's range() or lodash's _.range(). + * + * @param start - The starting number (inclusive) + * @param end - The ending number (exclusive) + * @returns Array of numbers from start to end-1 + * + * @example + * range(1, 5) // Returns [1, 2, 3, 4] + * range(0, 3) // Returns [0, 1, 2] + * range(5, 5) // Returns [] + * range(10, 13) // Returns [10, 11, 12] + */ +export const range = (start: number, end: number): number[] => { + const length = end - start; + return Array.from({ length }, (_, i) => start + i); +}; diff --git a/packages/superdoc/src/components/PdfViewer/helpers/read-file.js b/packages/superdoc/src/components/PdfViewer/helpers/read-file.js deleted file mode 100644 index 5e9a4bc47..000000000 --- a/packages/superdoc/src/components/PdfViewer/helpers/read-file.js +++ /dev/null @@ -1,8 +0,0 @@ -export const readFileAsArrayBuffer = (blob) => { - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = (event) => resolve(event.target.result); - reader.onerror = reject; - reader.readAsDataURL(blob); - }); -}; diff --git a/packages/superdoc/src/components/PdfViewer/helpers/read-file.ts b/packages/superdoc/src/components/PdfViewer/helpers/read-file.ts new file mode 100644 index 000000000..4aea40385 --- /dev/null +++ b/packages/superdoc/src/components/PdfViewer/helpers/read-file.ts @@ -0,0 +1,27 @@ +/** + * Read a Blob or File as a Data URL + * + * Converts a Blob or File into a base64-encoded Data URL string that can be + * used directly in img src attributes or for other browser display purposes. + * + * Note: Despite the function name containing "ArrayBuffer", this function + * actually reads as a Data URL for compatibility with existing code. The return + * type is string | ArrayBuffer to maintain backward compatibility, but in practice + * this will always return a string (Data URL) or null. + * + * @param blob - The Blob or File to read + * @returns Promise that resolves to a Data URL string or null on error + * @throws Rejects the promise if the FileReader encounters an error + * + * @example + * const dataUrl = await readFileAsArrayBuffer(myBlob); + * imageElement.src = dataUrl; + */ +export const readFileAsArrayBuffer = (blob: Blob): Promise => { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (event) => resolve(event.target?.result ?? null); + reader.onerror = reject; + reader.readAsDataURL(blob); + }); +}; diff --git a/packages/superdoc/src/components/PdfViewer/pdf/pdf-adapter.js b/packages/superdoc/src/components/PdfViewer/pdf/pdf-adapter.js deleted file mode 100644 index 1db925e0a..000000000 --- a/packages/superdoc/src/components/PdfViewer/pdf/pdf-adapter.js +++ /dev/null @@ -1,222 +0,0 @@ -// @ts-check -import { range } from '../helpers/range.js'; - -/** - * @typedef {import('pdfjs-dist').PDFDocumentProxy} PDFDocumentProxy - * @typedef {import('pdfjs-dist').PDFPageProxy} PDFPageProxy - */ - -/** - * @typedef {'pdfjs'} AdapterType - */ - -/** - * @typedef {Object} PDFConfig - * @property {AdapterType} adapter - * @property {any} [pdfLib] - * @property {any} [pdfViewer] - * @property {string} [workerSrc] - * @property {boolean} [setWorker] - * @property {0 | 1} [textLayerMode] - */ - -/** - * @typedef {Object} PDFJSConfig - * @property {any} pdfLib - * @property {any} pdfViewer - * @property {string} [workerSrc] - * @property {0 | 1} [textLayerMode] - * @property {boolean} [setWorker] - */ - -/** - * @typedef {Object} RenderPagesOptions - * @property {string} documentId - * @property {PDFDocumentProxy} pdfDocument - * @property {HTMLElement} viewerContainer - * @property {function(string, ...any): void} [emit] - */ - -/** - * @typedef {Object} PageSize - * @property {number} width - * @property {number} height - */ - -/** - * @abstract - */ -class PDFAdapter { - /** - * @throws {Error} - */ - constructor() { - const proto = Object.getPrototypeOf(this); - if (proto.constructor === PDFAdapter) { - throw new Error('Abstract class should not be instanciated'); - } - } -} - -export class PDFJSAdapter extends PDFAdapter { - /** - * @param {PDFJSConfig} config - */ - constructor(config) { - super(); - this.pdfLib = config.pdfLib; - this.pdfViewer = config.pdfViewer; - this.workerSrc = config.workerSrc; - this.textLayerMode = config.textLayerMode ?? 0; - if (config.setWorker) { - if (this.workerSrc) { - this.pdfLib.GlobalWorkerOptions.workerSrc = config.workerSrc; - } else { - // Fallback to CDN version. - this.pdfLib.GlobalWorkerOptions.workerSrc = getWorkerSrcFromCDN(this.pdfLib.version); - } - } - /** @type {any[]} */ - this.pdfPageViews = []; - } - - /** - * @param {string | ArrayBuffer | Uint8Array} file - * @returns {Promise} - */ - async getDocument(file) { - const loadingTask = this.pdfLib.getDocument(file); - const document = await loadingTask.promise; - return document; - } - - /** - * @param {RenderPagesOptions} options - * @returns {Promise} - */ - async renderPages({ documentId, pdfDocument, viewerContainer, emit = () => {} }) { - try { - this.pdfPageViews = []; - - const numPages = pdfDocument.numPages; - const firstPage = 1; - - const pdfjsPages = await getPdfjsPages(pdfDocument, firstPage, numPages); - const pageContainers = []; - - for (const [index, page] of pdfjsPages.entries()) { - const container = document.createElement('div'); - container.classList.add('pdf-page'); - container.dataset.pageNumber = (index + 1).toString(); - container.id = `${documentId}-page-${index + 1}`; - - pageContainers.push(container); - - const { width, height } = this.getOriginalPageSize(page); - const scale = 1; - - const eventBus = new this.pdfViewer.EventBus(); - const pdfPageView = new this.pdfViewer.PDFPageView({ - container, - id: index + 1, - scale, - defaultViewport: page.getViewport({ scale }), - eventBus, - textLayerMode: this.textLayerMode, - }); - this.pdfPageViews.push(pdfPageView); - - const containerBounds = container.getBoundingClientRect(); - // @ts-expect-error - Adding custom properties to DOMRect for internal use - containerBounds.originalWidth = width; - // @ts-expect-error - Adding custom properties to DOMRect for internal use - containerBounds.originalHeight = height; - - pdfPageView.setPdfPage(page); - await pdfPageView.draw(); - - emit('page-loaded', documentId, index, containerBounds); - } - - viewerContainer.append(...pageContainers); - - emit('ready', documentId, viewerContainer); - } catch (err) { - console.error('Error loading PDF:', err); - } - } - - /** - * @param {PDFPageProxy} page - * @returns {PageSize} - */ - getOriginalPageSize(page) { - const viewport = page.getViewport({ scale: 1 }); - const width = viewport.width; - const height = viewport.height; - return { width, height }; - } - - /** - * @return {void} - */ - destroy() { - this.pdfPageViews.forEach((view) => view.destroy()); - this.pdfPageViews = []; - } -} - -export class PDFAdapterFactory { - /** - * @param {PDFJSConfig & {adapter: AdapterType}} config - * @returns {PDFAdapter} - * @throws {Error} - */ - static create(config) { - const adapters = { - pdfjs: () => { - return new PDFJSAdapter(config); - }, - default: () => { - throw new Error('Unsupported adapter'); - }, - }; - const adapter = adapters[config.adapter] ?? adapters.default; - return adapter(); - } -} - -/** - * @param {Partial} [config] - * @returns {PDFConfig} - */ -export const createPDFConfig = (config) => { - /** @type {PDFConfig} */ - const defaultConfig = { - adapter: 'pdfjs', - }; - - return { - ...defaultConfig, - ...config, - }; -}; - -/** - * @param {PDFDocumentProxy} pdf - * @param {number} firstPage - * @param {number} lastPage - * @returns {Promise} - */ -export async function getPdfjsPages(pdf, firstPage, lastPage) { - const pagesPromises = range(firstPage, lastPage + 1).map((num) => pdf.getPage(num)); - return await Promise.all(pagesPromises); -} - -/** - * @param {number} version - * @returns {string} - */ -export function getWorkerSrcFromCDN(version) { - return `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${version}/pdf.worker.min.mjs`; -} diff --git a/packages/superdoc/src/components/PdfViewer/pdf/pdf-adapter.ts b/packages/superdoc/src/components/PdfViewer/pdf/pdf-adapter.ts new file mode 100644 index 000000000..676756ad5 --- /dev/null +++ b/packages/superdoc/src/components/PdfViewer/pdf/pdf-adapter.ts @@ -0,0 +1,586 @@ +import { range } from '../helpers/range.js'; +import type { PDFDocumentProxy, PDFPageProxy } from 'pdfjs-dist'; + +/** + * Supported PDF adapter types + */ +export type AdapterType = 'pdfjs'; + +/** + * Event bus for coordinating PDF viewer events + */ +export interface EventBus { + /** + * Subscribe to an event + */ + on(eventName: string, listener: (...args: unknown[]) => unknown, options?: object): void; + + /** + * Unsubscribe from an event + */ + off(eventName: string, listener: (...args: unknown[]) => unknown, options?: object): void; + + /** + * Dispatch an event + */ + dispatch(eventName: string, data: object): void; +} + +/** + * PDF page view for rendering individual pages + */ +export interface PDFPageView { + /** + * Set the PDF page to render + */ + setPdfPage(page: PDFPageProxy): void; + + /** + * Draw the page + */ + draw(): Promise; + + /** + * Destroy the page view and clean up resources + */ + destroy(): void; +} + +/** + * PDF.js library interface containing the core API + */ +export interface PDFJSLibrary { + /** + * Load a PDF document from various sources + */ + getDocument: (src: string | ArrayBuffer | Uint8Array) => PDFDocumentLoadingTask; + + /** + * Global worker configuration options + */ + GlobalWorkerOptions: { + workerSrc: string | undefined; + }; + + /** + * PDF.js library version + */ + version: string; +} + +/** + * PDF document loading task returned by getDocument + */ +export interface PDFDocumentLoadingTask { + /** + * Promise that resolves to the PDF document proxy + */ + promise: Promise; +} + +/** + * PDF.js viewer library interface containing UI components + */ +export interface PDFJSViewer { + /** + * Event bus constructor for coordinating viewer events + */ + EventBus: new () => EventBus; + + /** + * PDF page view constructor for rendering individual pages + */ + PDFPageView: new (options: PDFPageViewOptions) => PDFPageView; +} + +/** + * Options for creating a PDFPageView instance + */ +export interface PDFPageViewOptions { + /** + * Container element for the page view + */ + container: HTMLElement; + + /** + * Page number (1-based index) + */ + id: number; + + /** + * Scale factor for rendering the page + */ + scale: number; + + /** + * Default viewport for the page + */ + defaultViewport: PageViewport; + + /** + * Event bus for coordinating viewer events + */ + eventBus: EventBus; + + /** + * Text layer rendering mode (0 = disabled, 1 = enabled) + */ + textLayerMode?: 0 | 1; +} + +/** + * Page viewport containing dimensions and transformation matrix + */ +export interface PageViewport { + /** + * Page width in CSS pixels + */ + width: number; + + /** + * Page height in CSS pixels + */ + height: number; +} + +/** + * General PDF configuration options + */ +export interface PDFConfig { + /** + * Adapter type to use for PDF rendering + */ + adapter: AdapterType; + + /** + * PDF.js library instance (optional) + */ + pdfLib?: PDFJSLibrary; + + /** + * PDF.js viewer library instance (optional) + */ + pdfViewer?: PDFJSViewer; + + /** + * Custom worker source URL (optional) + */ + workerSrc?: string; + + /** + * Whether to set the worker source automatically (optional) + */ + setWorker?: boolean; + + /** + * Text layer rendering mode (0 = disabled, 1 = enabled) + */ + textLayerMode?: 0 | 1; +} + +/** + * Configuration options specific to PDF.js adapter + */ +export interface PDFJSConfig { + /** + * PDF.js library instance + */ + pdfLib: PDFJSLibrary; + + /** + * PDF.js viewer library instance + */ + pdfViewer: PDFJSViewer; + + /** + * Custom worker source URL (optional) + */ + workerSrc?: string; + + /** + * Text layer rendering mode (0 = disabled, 1 = enabled) + */ + textLayerMode?: 0 | 1; + + /** + * Whether to set the worker source automatically (optional) + */ + setWorker?: boolean; +} + +/** + * Options for rendering PDF pages + */ +export interface RenderPagesOptions { + /** + * Unique identifier for the document + */ + documentId: string; + + /** + * PDF document proxy from PDF.js + */ + pdfDocument: PDFDocumentProxy; + + /** + * Container element for rendered pages + */ + viewerContainer: HTMLElement; + + /** + * Event emission callback function (optional) + */ + emit?: (event: string, ...args: unknown[]) => void; +} + +/** + * Page dimensions in CSS pixels + */ +export interface PageSize { + /** + * Page width in CSS pixels + */ + width: number; + + /** + * Page height in CSS pixels + */ + height: number; +} + +/** + * Extended DOMRect with original page dimensions + */ +export interface ExtendedDOMRect extends DOMRect { + /** + * Original page width before scaling + */ + originalWidth: number; + + /** + * Original page height before scaling + */ + originalHeight: number; +} + +/** + * Abstract base class for PDF adapters + * + * Provides a common interface for different PDF rendering libraries. + * Concrete implementations must extend this class and implement + * the required functionality. + * + * @abstract + */ +abstract class PDFAdapter { + /** + * Creates an instance of PDFAdapter + * + * @throws {Error} If attempting to instantiate the abstract class directly + */ + constructor() { + const proto = Object.getPrototypeOf(this); + if (proto.constructor === PDFAdapter) { + throw new Error('Abstract class should not be instanciated'); + } + } +} + +/** + * PDF.js implementation of the PDF adapter + * + * Provides PDF rendering capabilities using Mozilla's PDF.js library. + * Handles document loading, page rendering, and cleanup operations. + * + * @extends PDFAdapter + */ +export class PDFJSAdapter extends PDFAdapter { + /** + * PDF.js library instance + */ + private pdfLib: PDFJSLibrary; + + /** + * PDF.js viewer library instance + */ + private pdfViewer: PDFJSViewer; + + /** + * Worker source URL for PDF.js + */ + private workerSrc: string | undefined; + + /** + * Text layer rendering mode (0 = disabled, 1 = enabled) + */ + private textLayerMode: 0 | 1; + + /** + * Array of PDF page views for rendered pages + */ + private pdfPageViews: PDFPageView[]; + + /** + * Creates a new PDF.js adapter instance + * + * Initializes the adapter with the provided PDF.js libraries and configuration. + * If setWorker is true, automatically configures the worker source either from + * the provided workerSrc or falls back to CDN version. + * + * @param config - Configuration options for the adapter + */ + constructor(config: PDFJSConfig) { + super(); + this.pdfLib = config.pdfLib; + this.pdfViewer = config.pdfViewer; + this.workerSrc = config.workerSrc; + this.textLayerMode = config.textLayerMode ?? 0; + if (config.setWorker) { + if (this.workerSrc) { + this.pdfLib.GlobalWorkerOptions.workerSrc = config.workerSrc; + } else { + // Fallback to CDN version. + this.pdfLib.GlobalWorkerOptions.workerSrc = getWorkerSrcFromCDN(this.pdfLib.version); + } + } + this.pdfPageViews = []; + } + + /** + * Load a PDF document from various sources + * + * Accepts a file as a string URL, ArrayBuffer, or Uint8Array and returns + * a promise that resolves to a PDFDocumentProxy for rendering. + * + * @param file - PDF source (URL string, ArrayBuffer, or Uint8Array) + * @returns Promise resolving to the loaded PDF document + * + * @example + * const doc = await adapter.getDocument('/path/to/file.pdf'); + * const doc = await adapter.getDocument(arrayBuffer); + */ + async getDocument(file: string | ArrayBuffer | Uint8Array): Promise { + const loadingTask = this.pdfLib.getDocument(file); + const document = await loadingTask.promise; + return document; + } + + /** + * Render all pages of a PDF document into a container + * + * Creates page view elements for each page in the document, renders them + * using PDF.js, and appends them to the provided container. Emits events + * for page loading progress and completion. + * + * @param options - Rendering configuration options + * @returns Promise that resolves when all pages are rendered + * + * @throws Will log errors to console if rendering fails + * + * @example + * await adapter.renderPages({ + * documentId: 'doc-123', + * pdfDocument: pdfDoc, + * viewerContainer: containerElement, + * emit: (event, ...args) => console.log(event, args) + * }); + */ + async renderPages({ + documentId, + pdfDocument, + viewerContainer, + emit = () => { + /* noop */ + }, + }: RenderPagesOptions): Promise { + try { + this.pdfPageViews = []; + + const numPages = pdfDocument.numPages; + const firstPage = 1; + + const pdfjsPages = await getPdfjsPages(pdfDocument, firstPage, numPages); + const pageContainers: HTMLDivElement[] = []; + + for (const [index, page] of pdfjsPages.entries()) { + const container = document.createElement('div'); + container.classList.add('pdf-page'); + container.dataset.pageNumber = (index + 1).toString(); + container.id = `${documentId}-page-${index + 1}`; + + pageContainers.push(container); + + const { width, height } = this.getOriginalPageSize(page); + const scale = 1; + + const eventBus = new this.pdfViewer.EventBus(); + const pdfPageView = new this.pdfViewer.PDFPageView({ + container, + id: index + 1, + scale, + defaultViewport: page.getViewport({ scale }), + eventBus, + textLayerMode: this.textLayerMode, + }); + this.pdfPageViews.push(pdfPageView); + + const containerBounds = container.getBoundingClientRect() as ExtendedDOMRect; + // Adding custom properties to DOMRect for internal use + containerBounds.originalWidth = width; + containerBounds.originalHeight = height; + + pdfPageView.setPdfPage(page); + await pdfPageView.draw(); + + emit('page-loaded', documentId, index, containerBounds); + } + + viewerContainer.append(...pageContainers); + + emit('ready', documentId, viewerContainer); + } catch (err) { + console.error('Error loading PDF:', err); + } + } + + /** + * Get the original dimensions of a PDF page + * + * Calculates the page size at scale 1.0 (100%) to determine the + * original, unscaled dimensions of the page. + * + * @param page - PDF page proxy from PDF.js + * @returns Page dimensions in CSS pixels + * + * @example + * const size = adapter.getOriginalPageSize(pdfPage); + * console.log(`Page is ${size.width}x${size.height} pixels`); + */ + getOriginalPageSize(page: PDFPageProxy): PageSize { + const viewport = page.getViewport({ scale: 1 }); + const width = viewport.width; + const height = viewport.height; + return { width, height }; + } + + /** + * Clean up and destroy all rendered page views + * + * Releases resources associated with each page view and clears + * the internal array. Should be called when the viewer is no + * longer needed to prevent memory leaks. + * + * @example + * adapter.destroy(); // Clean up before removing viewer + */ + destroy(): void { + this.pdfPageViews.forEach((view) => view.destroy()); + this.pdfPageViews = []; + } +} + +/** + * Factory class for creating PDF adapter instances + * + * Provides a centralized way to instantiate different PDF adapter + * implementations based on configuration. + */ +export class PDFAdapterFactory { + /** + * Create a PDF adapter instance based on configuration + * + * Currently supports only the 'pdfjs' adapter type. Additional + * adapter types can be added in the future. + * + * @param config - Configuration including adapter type and library instances + * @returns Configured PDF adapter instance + * @throws {Error} If an unsupported adapter type is specified + * + * @example + * const adapter = PDFAdapterFactory.create({ + * adapter: 'pdfjs', + * pdfLib: pdfjsLib, + * pdfViewer: pdfjsViewer, + * setWorker: true + * }); + */ + static create(config: PDFJSConfig & { adapter: AdapterType }): PDFAdapter { + const adapters: Record PDFAdapter> = { + pdfjs: () => { + return new PDFJSAdapter(config); + }, + default: () => { + throw new Error('Unsupported adapter'); + }, + }; + const adapter = adapters[config.adapter] ?? adapters.default; + return adapter(); + } +} + +/** + * Create a PDF configuration object with defaults + * + * Merges provided configuration with default values to ensure + * all required properties are present. + * + * @param config - Partial configuration options (optional) + * @returns Complete PDF configuration with defaults applied + * + * @example + * const config = createPDFConfig({ + * pdfLib: pdfjsLib, + * pdfViewer: pdfjsViewer + * }); + */ +export const createPDFConfig = (config?: Partial): PDFConfig => { + const defaultConfig: PDFConfig = { + adapter: 'pdfjs', + }; + + return { + ...defaultConfig, + ...config, + }; +}; + +/** + * Retrieve multiple pages from a PDF document + * + * Fetches a range of pages from a PDF document in parallel using + * Promise.all for optimal performance. + * + * @param pdf - PDF document proxy from PDF.js + * @param firstPage - First page number to retrieve (1-based index) + * @param lastPage - Last page number to retrieve (inclusive, 1-based index) + * @returns Promise resolving to array of PDF page proxies + * + * @example + * const pages = await getPdfjsPages(pdfDoc, 1, 5); + * // Returns pages 1, 2, 3, 4, 5 + */ +export async function getPdfjsPages( + pdf: PDFDocumentProxy, + firstPage: number, + lastPage: number, +): Promise { + const pagesPromises = range(firstPage, lastPage + 1).map((num) => pdf.getPage(num)); + return await Promise.all(pagesPromises); +} + +/** + * Generate CDN URL for PDF.js worker script + * + * Constructs a CDN URL for the PDF.js worker based on the library + * version. Used as a fallback when no custom worker source is provided. + * + * @param version - PDF.js version number (e.g., "2.16.105") + * @returns CDN URL string for the worker script + * + * @example + * const workerUrl = getWorkerSrcFromCDN('2.16.105'); + * // Returns: 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.worker.min.mjs' + */ +export function getWorkerSrcFromCDN(version: string): string { + return `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${version}/pdf.worker.min.mjs`; +} diff --git a/packages/superdoc/src/composables/use-ai.js b/packages/superdoc/src/composables/use-ai.js deleted file mode 100644 index f231a061a..000000000 --- a/packages/superdoc/src/composables/use-ai.js +++ /dev/null @@ -1,122 +0,0 @@ -import { ref, reactive } from 'vue'; - -/** - * Composable to manage AI layer and AI writer functionality - * - * @param {Object} options - Configuration options - * @param {Object} options.activeEditorRef - Ref to the active editor - * @returns {Object} - AI state and methods - */ -export function useAi({ activeEditorRef }) { - // Shared state - const showAiLayer = ref(false); - const showAiWriter = ref(false); - const aiWriterPosition = reactive({ top: 0, left: 0 }); - const aiLayer = ref(null); - - /** - * Show the AI writer at the current cursor position - */ - const showAiWriterAtCursor = () => { - const editor = activeEditorRef.value; - if (!editor || editor.isDestroyed) { - console.error('[useAi] Editor not available'); - return; - } - - try { - // Get the current cursor position - const { view } = editor; - const { selection } = view.state; - - // If we have selected text, add AI highlighting - if (!selection.empty) { - // Add the ai mark to the document - editor.commands.insertAiMark(); - } - - let coords; - try { - // Try to get coordinates from the selection head - coords = view.coordsAtPos(selection.$head.pos); - } catch { - // Fallback to using the DOM selection if ProseMirror position is invalid - const domSelection = window.getSelection(); - if (domSelection.rangeCount > 0) { - const range = domSelection.getRangeAt(0); - const rect = range.getBoundingClientRect(); - coords = { top: rect.top, left: rect.left }; - } else { - // If no selection, use editor position - const editorRect = view.dom.getBoundingClientRect(); - coords = { top: editorRect.top + 50, left: editorRect.left + 50 }; - } - } - - // Position the AIWriter at the cursor position - // Move down 30px to render under the cursor - aiWriterPosition.top = coords.top + 30 + 'px'; - aiWriterPosition.left = coords.left + 'px'; - - // Show the AIWriter - showAiWriter.value = true; - } catch (error) { - console.error('[useAi] Error displaying AIWriter:', error); - // Fallback position in center of editor - try { - const editorDom = activeEditorRef.value.view.dom; - const rect = editorDom.getBoundingClientRect(); - aiWriterPosition.top = rect.top + 100 + 'px'; - aiWriterPosition.left = rect.left + 100 + 'px'; - showAiWriter.value = true; - } catch (e) { - console.error('[useAi] Failed to get fallback position:', e); - } - } - }; - - /** - * Handle closing the AI writer - */ - const handleAiWriterClose = () => { - showAiWriter.value = false; - }; - - /** - * Initialize the AI layer - * - * @param {Boolean} value - Whether to show the AI layer - */ - const initAiLayer = (value = true) => { - showAiLayer.value = value; - }; - - /** - * Handle tool click for AI functionality - */ - const handleAiToolClick = () => { - // Add the ai mark to the document - const editor = activeEditorRef.value; - if (!editor || editor.isDestroyed) { - console.error('[useAi] Editor not available'); - return; - } - editor.commands.insertAiMark(); - // Show the AI writer at the cursor position - showAiWriterAtCursor(); - }; - - return { - // State - showAiLayer, - showAiWriter, - aiWriterPosition, - aiLayer, - - // Methods - initAiLayer, - showAiWriterAtCursor, - handleAiWriterClose, - handleAiToolClick, - }; -} diff --git a/packages/superdoc/src/composables/use-ai.ts b/packages/superdoc/src/composables/use-ai.ts new file mode 100644 index 000000000..2c18c683c --- /dev/null +++ b/packages/superdoc/src/composables/use-ai.ts @@ -0,0 +1,204 @@ +import { ref, reactive, type Ref, type UnwrapNestedRefs } from 'vue'; +import type { Editor } from '../core/types'; +import type { EditorView } from 'prosemirror-view'; + +/** + * Position coordinates for the AI writer interface + */ +export interface AiWriterPosition { + /** Top position in CSS units */ + top: number | string; + /** Left position in CSS units */ + left: number | string; +} + +/** + * Configuration options for the useAi composable + */ +export interface UseAiOptions { + /** Ref to the active editor instance */ + activeEditorRef: Ref; +} + +/** + * Return type of the useAi composable + */ +export interface UseAiReturn { + /** Whether the AI layer is visible */ + showAiLayer: Ref; + /** Whether the AI writer is visible */ + showAiWriter: Ref; + /** Position of the AI writer interface */ + aiWriterPosition: UnwrapNestedRefs; + /** Reference to the AI layer element */ + aiLayer: Ref; + /** Initialize the AI layer */ + initAiLayer: (value?: boolean) => void; + /** Show the AI writer at the current cursor position */ + showAiWriterAtCursor: () => void; + /** Handle closing the AI writer */ + handleAiWriterClose: () => void; + /** Handle AI tool click */ + handleAiToolClick: () => void; +} + +/** + * Vue composable for managing AI layer and AI writer functionality + * + * This composable provides comprehensive AI interface management including: + * - AI layer visibility control + * - AI writer positioning at cursor location + * - Editor integration for AI marks and commands + * - Error handling for editor state + * + * @param options - Configuration options including active editor ref + * @returns AI state and methods + * + * @example + * const editorRef = ref(null); + * const ai = useAi({ activeEditorRef: editorRef }); + * + * ai.initAiLayer(true); + * ai.showAiWriterAtCursor(); + */ +export function useAi({ activeEditorRef }: UseAiOptions): UseAiReturn { + // Shared state + const showAiLayer = ref(false); + const showAiWriter = ref(false); + const aiWriterPosition = reactive({ top: 0, left: 0 }); + const aiLayer = ref(null); + + /** + * Show the AI writer at the current cursor position + * + * This method positions the AI writer interface near the current cursor + * or selection in the editor. It handles various edge cases including: + * - Invalid editor states + * - Empty or invalid selections + * - DOM selection fallbacks + * - Error recovery with fallback positioning + */ + const showAiWriterAtCursor = (): void => { + const editor = activeEditorRef.value; + if (!editor || !('isDestroyed' in editor) || editor.isDestroyed) { + console.error('[useAi] Editor not available'); + return; + } + + try { + // Get the current cursor position + if (!('view' in editor)) { + console.error('[useAi] Editor view not available'); + return; + } + const view = editor.view as EditorView; + const { selection } = view.state; + + // If we have selected text, add AI highlighting + if (!selection.empty) { + // Add the ai mark to the document + if ('commands' in editor && editor.commands && 'insertAiMark' in editor.commands) { + editor.commands.insertAiMark(); + } + } + + let coords: { top: number; left: number }; + try { + // Try to get coordinates from the selection head + coords = view.coordsAtPos(selection.$head.pos); + } catch { + // Fallback to using the DOM selection if ProseMirror position is invalid + const domSelection = window.getSelection(); + if (domSelection && domSelection.rangeCount > 0) { + const range = domSelection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + coords = { top: rect.top, left: rect.left }; + } else { + // If no selection, use editor position + const editorRect = view.dom.getBoundingClientRect(); + coords = { top: editorRect.top + 50, left: editorRect.left + 50 }; + } + } + + // Position the AIWriter at the cursor position + // Move down 30px to render under the cursor + aiWriterPosition.top = coords.top + 30 + 'px'; + aiWriterPosition.left = coords.left + 'px'; + + // Show the AIWriter + showAiWriter.value = true; + } catch (error) { + console.error('[useAi] Error displaying AIWriter:', error); + // Fallback position in center of editor + try { + const currentEditor = activeEditorRef.value; + if (currentEditor && 'view' in currentEditor) { + const editorView = currentEditor.view as EditorView; + const editorDom = editorView.dom; + const rect = editorDom.getBoundingClientRect(); + aiWriterPosition.top = rect.top + 100 + 'px'; + aiWriterPosition.left = rect.left + 100 + 'px'; + showAiWriter.value = true; + } + } catch (e) { + console.error('[useAi] Failed to get fallback position:', e); + } + } + }; + + /** + * Handle closing the AI writer + * + * This method hides the AI writer interface when the user closes it + * or completes their AI interaction. + */ + const handleAiWriterClose = (): void => { + showAiWriter.value = false; + }; + + /** + * Initialize the AI layer + * + * This method controls the visibility of the AI layer overlay. + * + * @param value - Whether to show the AI layer (default: true) + */ + const initAiLayer = (value = true): void => { + showAiLayer.value = value; + }; + + /** + * Handle tool click for AI functionality + * + * This method is called when the AI tool is clicked in the toolbar. + * It adds an AI mark to the document and displays the AI writer + * at the current cursor position. + */ + const handleAiToolClick = (): void => { + // Add the ai mark to the document + const editor = activeEditorRef.value; + if (!editor || !('isDestroyed' in editor) || editor.isDestroyed) { + console.error('[useAi] Editor not available'); + return; + } + if ('commands' in editor && editor.commands && 'insertAiMark' in editor.commands) { + editor.commands.insertAiMark(); + } + // Show the AI writer at the cursor position + showAiWriterAtCursor(); + }; + + return { + // State + showAiLayer, + showAiWriter, + aiWriterPosition, + aiLayer, + + // Methods + initAiLayer, + showAiWriterAtCursor, + handleAiWriterClose, + handleAiToolClick, + }; +} diff --git a/packages/superdoc/src/composables/use-document.js b/packages/superdoc/src/composables/use-document.js deleted file mode 100644 index 4a21a479b..000000000 --- a/packages/superdoc/src/composables/use-document.js +++ /dev/null @@ -1,120 +0,0 @@ -import { ref, shallowRef } from 'vue'; -import { useField } from './use-field'; -import { documentTypes } from '@superdoc/common'; -import useComment from '@superdoc/components/CommentsLayer/use-comment'; - -export default function useDocument(params, superdocConfig) { - const id = params.id; - const type = initDocumentType(params); - - const data = params.data; - const config = superdocConfig; - const state = params.state; - const role = params.role; - const html = params.html; - const markdown = params.markdown; - - // Placement - const container = ref(null); - const pageContainers = ref([]); - const isReady = ref(false); - const rulers = ref(superdocConfig.rulers); - - // Collaboration - const ydoc = shallowRef(params.ydoc); - const provider = shallowRef(params.provider); - const socket = shallowRef(params.socket); - const isNewFile = ref(params.isNewFile); - - // For docx - const editorRef = shallowRef(null); - const setEditor = (ref) => (editorRef.value = ref); - const getEditor = () => editorRef.value; - - const presentationEditorRef = shallowRef(null); - const setPresentationEditor = (ref) => (presentationEditorRef.value = ref); - const getPresentationEditor = () => presentationEditorRef.value; - - /** - * Initialize the mime type of the document - * @param {Object} param0 The config object - * @param {String} param0.type The type of document - * @param {Object} param0.data The data object - * @returns {String} The document type - * @throws {Error} If the document type is not specified - */ - function initDocumentType({ type, data }) { - if (data?.type) return data.type; - if (type) return type in documentTypes ? documentTypes[type] : null; - - throw new Error('Document type not specified for doc:', params); - } - - // Comments - const removeComments = () => { - conversationsBackup.value = conversations.value; - conversations.value = []; - }; - - const restoreComments = () => { - conversations.value = conversationsBackup.value; - console.debug('[superdoc] Restored comments:', conversations.value); - }; - - // Modules - const rawFields = ref(params.fields || []); - const fields = ref(params.fields?.map((f) => useField(f)) || []); - const annotations = ref(params.annotations || []); - const conversations = ref(initConversations()); - const conversationsBackup = ref(conversations.value); - - function initConversations() { - if (!config.modules.comments) return []; - return params.conversations?.map((c) => useComment(c)) || []; - } - - const core = ref(null); - - const removeConversation = (conversationId) => { - const index = conversations.value.findIndex((c) => c.conversationId === conversationId); - if (index > -1) conversations.value.splice(index, 1); - }; - - return { - id, - data, - html, - markdown, - type, - config, - state, - role, - - core, - ydoc, - provider, - socket, - isNewFile, - - // Placement - container, - pageContainers, - isReady, - rulers, - - // Modules - rawFields, - fields, - annotations, - conversations, - - // Actions - setEditor, - getEditor, - setPresentationEditor, - getPresentationEditor, - removeComments, - restoreComments, - removeConversation, - }; -} diff --git a/packages/superdoc/src/composables/use-document.ts b/packages/superdoc/src/composables/use-document.ts new file mode 100644 index 000000000..875bf87d5 --- /dev/null +++ b/packages/superdoc/src/composables/use-document.ts @@ -0,0 +1,280 @@ +import { ref, shallowRef, type Ref, type ShallowRef, type UnwrapNestedRefs } from 'vue'; +import { useField, type UseFieldReturn, type RawField } from './use-field'; +import { documentTypes } from '@superdoc/common'; +import useComment, { type UseCommentReturn, type UseCommentParams } from '../components/CommentsLayer/use-comment'; +import type { Editor, Config } from '../core/types'; +import type { Doc } from 'yjs'; + +/** + * Annotation object structure + */ +export interface Annotation { + [key: string]: unknown; +} + +/** + * Document initialization parameters + */ +export interface DocumentParams { + /** Unique identifier for the document */ + id?: string; + /** Type of the document */ + type?: string; + /** Document data (File, Blob, or object) */ + data?: File | Blob | { type?: string } | null; + /** Document state */ + state?: string; + /** User's role for this document */ + role?: string; + /** HTML content */ + html?: string; + /** Markdown content */ + markdown?: string; + /** Yjs document for collaboration */ + ydoc?: Doc; + /** Hocuspocus or WebSocket provider for collaboration (HocuspocusProvider | WebsocketProvider) */ + provider?: unknown; + /** WebSocket for collaboration */ + socket?: unknown; + /** Whether this is a new file */ + isNewFile?: boolean; + /** Field definitions */ + fields?: RawField[]; + /** Annotations */ + annotations?: Annotation[]; + /** Conversation/comment data */ + conversations?: UseCommentParams[]; +} + +/** + * Return type of the useDocument composable + */ +export interface UseDocumentReturn { + /** Document ID */ + id: string | undefined; + /** Document data */ + data: File | Blob | { type?: string } | null | undefined; + /** HTML content */ + html: string | undefined; + /** Markdown content */ + markdown: string | undefined; + /** Document type/MIME type */ + type: string | null; + /** SuperDoc configuration */ + config: Config; + /** Document state */ + state: string | undefined; + /** User role */ + role: string | undefined; + /** Core document instance */ + core: Ref; + /** Yjs document */ + ydoc: ShallowRef; + /** Hocuspocus or WebSocket provider (HocuspocusProvider | WebsocketProvider) */ + provider: ShallowRef; + /** WebSocket connection */ + socket: ShallowRef; + /** Whether this is a new file */ + isNewFile: Ref; + /** Container element reference */ + container: Ref; + /** Page container elements */ + pageContainers: Ref; + /** Whether the document is ready */ + isReady: Ref; + /** Whether rulers are shown */ + rulers: Ref; + /** Raw field data */ + rawFields: Ref; + /** Processed field objects */ + fields: Ref; + /** Document annotations */ + annotations: Ref; + /** Comments/conversations */ + conversations: Ref[]>; + /** Set the editor instance */ + setEditor: (editor: Editor) => void; + /** Get the editor instance */ + getEditor: () => Editor | null; + /** Set the presentation editor instance */ + setPresentationEditor: (editor: Editor) => void; + /** Get the presentation editor instance */ + getPresentationEditor: () => Editor | null; + /** Remove all comments temporarily */ + removeComments: () => void; + /** Restore previously removed comments */ + restoreComments: () => void; + /** Remove a specific conversation by ID */ + removeConversation: (conversationId: string) => void; +} + +/** + * Vue composable for managing document state and lifecycle + * + * This composable provides comprehensive document management including: + * - Document metadata and type handling + * - Editor instance management (docx and presentation editors) + * - Collaboration state (Yjs, providers, sockets) + * - Fields, annotations, and comments management + * - Container and placement references + * + * @param params - Document initialization parameters + * @param superdocConfig - SuperDoc configuration object + * @returns Document state and action methods + * + * @example + * const document = useDocument({ + * id: 'doc-123', + * type: 'docx', + * data: fileBlob, + * fields: rawFields, + * conversations: comments + * }, config); + * + * document.setEditor(editorInstance); + * const editor = document.getEditor(); + */ +export default function useDocument(params: DocumentParams, superdocConfig: Config): UseDocumentReturn { + const id = params.id; + const type = initDocumentType(params); + + const data = params.data; + const config = superdocConfig; + const state = params.state; + const role = params.role; + const html = params.html; + const markdown = params.markdown; + + // Placement + const container = ref(null); + const pageContainers = ref([]); + const isReady = ref(false); + const rulers = ref(superdocConfig.rulers); + + // Collaboration + const ydoc = shallowRef(params.ydoc); + const provider = shallowRef(params.provider); + const socket = shallowRef(params.socket); + const isNewFile = ref(params.isNewFile); + + // For docx + const editorRef = shallowRef(null); + const setEditor = (ref: Editor): void => { + editorRef.value = ref; + }; + const getEditor = (): Editor | null => editorRef.value; + + const presentationEditorRef = shallowRef(null); + const setPresentationEditor = (ref: Editor): void => { + presentationEditorRef.value = ref; + }; + const getPresentationEditor = (): Editor | null => presentationEditorRef.value; + + /** + * Initialize the mime type of the document + * + * @param param0 - The config object + * @param param0.type - The type of document + * @param param0.data - The data object + * @returns The document type + * @throws Error if the document type is not specified + */ + function initDocumentType({ + type, + data, + }: { + type?: string; + data?: File | Blob | { type?: string } | null; + }): string | null { + if (data && typeof data === 'object' && 'type' in data && data.type) { + return data.type; + } + if (type) { + return type in documentTypes ? documentTypes[type as keyof typeof documentTypes] : null; + } + + throw new Error('Document type not specified for doc: ' + JSON.stringify(params)); + } + + // Comments + const removeComments = (): void => { + conversationsBackup.value = conversations.value; + conversations.value = []; + }; + + const restoreComments = (): void => { + conversations.value = conversationsBackup.value; + console.debug('[superdoc] Restored comments:', conversations.value); + }; + + // Modules + const rawFields = ref(params.fields || []); + const fields = ref(params.fields?.map((f: RawField) => useField(f)) || []) as unknown as Ref; + const annotations = ref(params.annotations || []); + const conversations = ref[]>(initConversations()); + const conversationsBackup = ref[]>(conversations.value); + + /** + * Initialize conversations/comments if the module is enabled + * + * @returns Array of conversation objects + */ + function initConversations(): UnwrapNestedRefs[] { + if (!config.modules?.comments) return []; + return params.conversations?.map((c: UseCommentParams) => useComment(c)) || []; + } + + const core = ref(null); + + /** + * Remove a conversation by its ID + * + * @param conversationId - The ID of the conversation to remove + */ + const removeConversation = (conversationId: string): void => { + const index = conversations.value.findIndex( + (c: UnwrapNestedRefs) => c.commentId === conversationId, + ); + if (index > -1) conversations.value.splice(index, 1); + }; + + const returnValue: UseDocumentReturn = { + id, + data, + html, + markdown, + type, + config, + state, + role, + + core, + ydoc, + provider, + socket, + isNewFile, + + // Placement + container, + pageContainers, + isReady, + rulers, + + // Modules + rawFields, + fields, + annotations, + conversations, + + // Actions + setEditor, + getEditor, + setPresentationEditor, + getPresentationEditor, + removeComments, + restoreComments, + removeConversation, + }; + + return returnValue; +} diff --git a/packages/superdoc/src/composables/use-field.js b/packages/superdoc/src/composables/use-field.js deleted file mode 100644 index e1d5fa9a7..000000000 --- a/packages/superdoc/src/composables/use-field.js +++ /dev/null @@ -1,131 +0,0 @@ -import { ref, reactive, watch } from 'vue'; - -export function useFieldValueWatcher(field, originalValue) { - const fieldId = field.itemid; - const rawField = field; - - const valueIsObject = originalValue !== null && typeof originalValue === 'object'; - const value = valueIsObject ? reactive({ ...originalValue }) : ref(originalValue); - const change = ref(null); - - const handleChange = (newValue, oldValue) => { - // If the value hasn't changed, don't do anything - // If new change, add the change to the list - const newChange = { - fieldId: fieldId.value, - changeTime: Date.now(), - oldValue: oldValue, - newValue: newValue, - originalField: rawField, - }; - change.value = newChange; - }; - - watch(value, handleChange); - return { - value, - }; -} - -export function useField(field) { - const id = ref(field.itemid); - - const icon = ref(field.itemicon); - const iconPack = ref(field.itemiconpack); - - const label = ref(field.itemdisplaylabel); - const originalValue = field.itemlinkvalue; - const placeholder = field.itemplaceholdertext; - - const changeHistory = ref([]); - const { value } = useFieldValueWatcher(field, originalValue, changeHistory); - - const fieldType = ref(field.itemtype); - const fieldSubType = ref(field.itemfieldtype); - const originalJSON = field; - const fieldStyle = reactive({ - fontFamily: field.fontfamily || 'Arial', - fontSize: field.font_size || '12pt', - originalFontSize: field.original_font_size || '12pt', - }); - - const logicRules = ref(field.logicrules); - const hidden = ref(false); - - const additionalOptions = reactive({}); - const fieldHandlers = { - SELECT: useSelectField, - IMAGEINPUT: useImageField, - CHECKBOXINPUT: useCheckboxField, - }; - if (fieldType.value in fieldHandlers) { - Object.assign(additionalOptions, fieldHandlers[fieldType.value](field)); - } - - const format = ref(field.itemformat); - - /** - * Callback for fields which value is not a String value - * and have to be calculated using additional data - * Example: multiple image upload input - * - * @param {Object} data which is passed from SD - * @returns {String} string value that can be used in annotation - */ - const valueGetter = field.valueGetter; - - return { - id, - icon, - iconPack, - label, - placeholder, - fieldType, - fieldSubType, - value, - format, - logicRules, - hidden, - originalJSON, - fieldStyle, - valueGetter, - ...additionalOptions, - }; -} - -export function useImageField(field) { - const fontfamily = ref(field.fontfamily); - const iteminputtype = ref(field.iteminputtype); - - const self = { - fontfamily, - iteminputtype, - }; - return self; -} - -export function useSelectField(field) { - const options = ref(field.itemoptions); - return { - options, - }; -} - -export function useCheckboxField(field) { - const options = ref(field.itemoptions); - - if (options.value) { - options.value = options.value.map((option) => { - return { - label: option.itemdisplaylabel, - value: option.itemlinkvalue, - checked: option.ischecked, - id: option.itemid, - annotationId: option.annotationId, - }; - }); - } - return { - options, - }; -} diff --git a/packages/superdoc/src/composables/use-field.ts b/packages/superdoc/src/composables/use-field.ts new file mode 100644 index 000000000..433727216 --- /dev/null +++ b/packages/superdoc/src/composables/use-field.ts @@ -0,0 +1,350 @@ +import { ref, reactive, watch, type Ref, type UnwrapNestedRefs } from 'vue'; + +/** + * Font style configuration for a field + */ +export interface FieldStyle { + /** Font family for the field */ + fontFamily: string; + /** Font size for the field */ + fontSize: string; + /** Original font size for the field */ + originalFontSize: string; +} + +/** + * Option item for select and checkbox fields + */ +export interface FieldOption { + /** Display label for the option */ + itemdisplaylabel?: string; + /** Value of the option */ + itemlinkvalue?: string | boolean; + /** Whether the option is checked (for checkboxes) */ + ischecked?: boolean; + /** ID of the option */ + itemid?: string; + /** ID of the associated annotation */ + annotationId?: string; +} + +/** + * Normalized checkbox option + */ +export interface CheckboxOption { + /** Display label for the option */ + label: string; + /** Value of the option */ + value: string | boolean; + /** Whether the option is checked */ + checked: boolean; + /** ID of the option */ + id: string; + /** ID of the associated annotation */ + annotationId?: string; +} + +/** + * Logic rules for conditional field visibility + */ +export interface LogicRules { + [key: string]: unknown; +} + +/** + * Raw field data structure from the API + */ +export interface RawField { + /** Unique identifier for the field */ + itemid: string; + /** Icon identifier for the field */ + itemicon?: string; + /** Icon pack identifier */ + itemiconpack?: string; + /** Display label for the field */ + itemdisplaylabel?: string; + /** Initial value of the field */ + itemlinkvalue?: unknown; + /** Placeholder text for the field */ + itemplaceholdertext?: string; + /** Type of the field (SELECT, IMAGEINPUT, CHECKBOXINPUT, etc.) */ + itemtype?: string; + /** Subtype of the field */ + itemfieldtype?: string; + /** Font family for the field */ + fontfamily?: string; + /** Font size for the field */ + font_size?: string; + /** Original font size for the field */ + original_font_size?: string; + /** Logic rules for conditional visibility */ + logicrules?: LogicRules; + /** Format string for the field value */ + itemformat?: string; + /** Options for select/checkbox fields */ + itemoptions?: FieldOption[]; + /** Input type for image fields */ + iteminputtype?: string; + /** Custom value getter function */ + valueGetter?: (data: unknown) => string; +} + +/** + * Change record for tracking field value changes + */ +export interface FieldChange { + /** ID of the field that changed */ + fieldId: string; + /** Timestamp of the change */ + changeTime: number; + /** Previous value before the change */ + oldValue: unknown; + /** New value after the change */ + newValue: unknown; + /** Reference to the original field object */ + originalField: RawField; +} + +/** + * Return type of the useFieldValueWatcher composable + */ +export interface UseFieldValueWatcherReturn { + /** Reactive reference to the field value */ + value: T extends object ? UnwrapNestedRefs : Ref; +} + +/** + * Additional options returned for select fields + */ +export interface SelectFieldOptions { + /** Available options for the select field */ + options: Ref; +} + +/** + * Additional options returned for checkbox fields + */ +export interface CheckboxFieldOptions { + /** Available options for the checkbox field */ + options: Ref; +} + +/** + * Additional options returned for image fields + */ +export interface ImageFieldOptions { + /** Font family for the image field */ + fontfamily: Ref; + /** Input type for the image field */ + iteminputtype: Ref; +} + +/** + * Return type of the useField composable + */ +export type UseFieldReturn = { + /** Unique identifier for the field */ + id: Ref; + /** Icon identifier */ + icon: Ref; + /** Icon pack identifier */ + iconPack: Ref; + /** Display label */ + label: Ref; + /** Placeholder text */ + placeholder: string | undefined; + /** Field type */ + fieldType: Ref; + /** Field subtype */ + fieldSubType: Ref; + /** Current field value */ + value: Ref | UnwrapNestedRefs>; + /** Format string */ + format: Ref; + /** Logic rules */ + logicRules: Ref; + /** Whether the field is hidden */ + hidden: Ref; + /** Original JSON data */ + originalJSON: RawField; + /** Field styling */ + fieldStyle: UnwrapNestedRefs; + /** Custom value getter function */ + valueGetter?: (data: unknown) => string; +} & Record; + +/** + * Vue composable for watching field value changes + * + * This composable creates a reactive value wrapper around a field's initial value + * and watches for changes, tracking them with timestamps and old/new values. + * + * @param field - The raw field object + * @param originalValue - The initial value of the field + * @returns An object containing the reactive value + */ +export function useFieldValueWatcher(field: RawField, originalValue: T): UseFieldValueWatcherReturn { + const fieldId = field.itemid; + const rawField = field; + + const valueIsObject = originalValue !== null && typeof originalValue === 'object'; + const value = valueIsObject ? reactive({ ...(originalValue as object) }) : ref(originalValue); + const change = ref(null); + + const handleChange = (newValue: T, oldValue: T): void => { + // If the value hasn't changed, don't do anything + // If new change, add the change to the list + const newChange: FieldChange = { + fieldId: fieldId, + changeTime: Date.now(), + oldValue: oldValue, + newValue: newValue, + originalField: rawField, + }; + change.value = newChange; + }; + + watch(value as Ref, handleChange); + return { + value: value as T extends object ? UnwrapNestedRefs : Ref, + }; +} + +/** + * Vue composable for managing field state and behavior + * + * This composable provides comprehensive field management including: + * - Reactive state for all field properties + * - Value change tracking + * - Type-specific field handlers (select, checkbox, image) + * - Field styling and formatting + * - Logic rules for conditional visibility + * + * @param field - The raw field object from the API + * @returns Field state and properties + * + * @example + * const field = useField(rawFieldData); + * console.log(field.label.value); + * field.value.value = 'new value'; + */ +export function useField(field: RawField): UseFieldReturn { + const id = ref(field.itemid); + + const icon = ref(field.itemicon); + const iconPack = ref(field.itemiconpack); + + const label = ref(field.itemdisplaylabel); + const originalValue = field.itemlinkvalue; + const placeholder = field.itemplaceholdertext; + + const { value } = useFieldValueWatcher(field, originalValue); + + const fieldType = ref(field.itemtype); + const fieldSubType = ref(field.itemfieldtype); + const originalJSON = field; + const fieldStyle = reactive({ + fontFamily: field.fontfamily || 'Arial', + fontSize: field.font_size || '12pt', + originalFontSize: field.original_font_size || '12pt', + }); + + const logicRules = ref(field.logicrules); + const hidden = ref(false); + + const additionalOptions: Record = reactive({}); + const fieldHandlers: Record SelectFieldOptions | ImageFieldOptions | CheckboxFieldOptions> = + { + SELECT: useSelectField, + IMAGEINPUT: useImageField, + CHECKBOXINPUT: useCheckboxField, + }; + if (fieldType.value && fieldType.value in fieldHandlers) { + Object.assign(additionalOptions, fieldHandlers[fieldType.value](field)); + } + + const format = ref(field.itemformat); + + /** + * Callback for fields which value is not a String value + * and have to be calculated using additional data + * Example: multiple image upload input + * + * @param data - Data which is passed from SD + * @returns String value that can be used in annotation + */ + const valueGetter = field.valueGetter; + + return { + id, + icon, + iconPack, + label, + placeholder, + fieldType, + fieldSubType, + value, + format, + logicRules, + hidden, + originalJSON, + fieldStyle, + valueGetter, + ...additionalOptions, + }; +} + +/** + * Handler for image input fields + * + * @param field - The raw field object + * @returns Image field specific options + */ +export function useImageField(field: RawField): ImageFieldOptions { + const fontfamily = ref(field.fontfamily); + const iteminputtype = ref(field.iteminputtype); + + const self: ImageFieldOptions = { + fontfamily, + iteminputtype, + }; + return self; +} + +/** + * Handler for select fields + * + * @param field - The raw field object + * @returns Select field specific options + */ +export function useSelectField(field: RawField): SelectFieldOptions { + const options = ref(field.itemoptions); + return { + options, + }; +} + +/** + * Handler for checkbox fields + * + * @param field - The raw field object + * @returns Checkbox field specific options + */ +export function useCheckboxField(field: RawField): CheckboxFieldOptions { + const options = ref(undefined); + + if (field.itemoptions) { + options.value = field.itemoptions.map((option: FieldOption): CheckboxOption => { + return { + label: option.itemdisplaylabel || '', + value: option.itemlinkvalue ?? false, + checked: option.ischecked || false, + id: option.itemid || '', + annotationId: option.annotationId, + }; + }); + } + return { + options, + }; +} diff --git a/packages/superdoc/src/composables/use-high-contrast-mode.js b/packages/superdoc/src/composables/use-high-contrast-mode.js deleted file mode 100644 index ba71c1f53..000000000 --- a/packages/superdoc/src/composables/use-high-contrast-mode.js +++ /dev/null @@ -1,14 +0,0 @@ -import { ref } from 'vue'; - -const isHighContrastMode = ref(false); - -export function useHighContrastMode() { - const setHighContrastMode = (value) => { - isHighContrastMode.value = value; - }; - - return { - isHighContrastMode, - setHighContrastMode, - }; -} diff --git a/packages/superdoc/src/composables/use-high-contrast-mode.ts b/packages/superdoc/src/composables/use-high-contrast-mode.ts new file mode 100644 index 000000000..f72ac6382 --- /dev/null +++ b/packages/superdoc/src/composables/use-high-contrast-mode.ts @@ -0,0 +1,36 @@ +import { ref, type Ref } from 'vue'; + +const isHighContrastMode = ref(false); + +/** + * Return type of the useHighContrastMode composable + */ +export interface UseHighContrastModeReturn { + /** Whether high contrast mode is enabled */ + isHighContrastMode: Ref; + /** Set the high contrast mode value */ + setHighContrastMode: (value: boolean) => void; +} + +/** + * Vue composable for managing high contrast mode accessibility state + * + * This composable provides a global state for high contrast mode that can be + * used across the application to adjust visual accessibility. + * + * @returns An object containing the high contrast mode state and setter + * + * @example + * const { isHighContrastMode, setHighContrastMode } = useHighContrastMode(); + * setHighContrastMode(true); + */ +export function useHighContrastMode(): UseHighContrastModeReturn { + const setHighContrastMode = (value: boolean): void => { + isHighContrastMode.value = value; + }; + + return { + isHighContrastMode, + setHighContrastMode, + }; +} diff --git a/packages/superdoc/src/composables/use-selected-text.js b/packages/superdoc/src/composables/use-selected-text.js deleted file mode 100644 index 152f568a9..000000000 --- a/packages/superdoc/src/composables/use-selected-text.js +++ /dev/null @@ -1,21 +0,0 @@ -import { computed } from 'vue'; - -/** - * Composable to get the currently selected text from an editor - * - * @param {Object} editorRef - Ref to the editor instance - * @returns {Object} - Object containing the selected text as a computed property - */ -export function useSelectedText(editorRef) { - // Create a computed property that will update when the editor selection changes - const selectedText = computed(() => { - const editor = editorRef.value; - if (!editor || !editor.state) return ''; - - return editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to, ' '); - }); - - return { - selectedText, - }; -} diff --git a/packages/superdoc/src/composables/use-selected-text.ts b/packages/superdoc/src/composables/use-selected-text.ts new file mode 100644 index 000000000..4560c519d --- /dev/null +++ b/packages/superdoc/src/composables/use-selected-text.ts @@ -0,0 +1,39 @@ +import { computed, type ComputedRef, type Ref } from 'vue'; +import type { Editor } from '../core/types'; + +/** + * Return type of the useSelectedText composable + */ +export interface UseSelectedTextReturn { + /** The currently selected text from the editor */ + selectedText: ComputedRef; +} + +/** + * Vue composable to get the currently selected text from an editor + * + * This composable provides a reactive computed property that returns the + * text currently selected in the editor. It automatically updates when + * the editor selection changes. + * + * @param editorRef - Ref to the editor instance + * @returns An object containing the selected text as a computed property + * + * @example + * const editorRef = ref(null); + * const { selectedText } = useSelectedText(editorRef); + * console.log(selectedText.value); // Selected text or empty string + */ +export function useSelectedText(editorRef: Ref): UseSelectedTextReturn { + // Create a computed property that will update when the editor selection changes + const selectedText = computed(() => { + const editor = editorRef.value; + if (!editor || !editor.state) return ''; + + return editor.state.doc.textBetween(editor.state.selection.from, editor.state.selection.to, ' '); + }); + + return { + selectedText, + }; +} diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js deleted file mode 100644 index 4c4e45b5b..000000000 --- a/packages/superdoc/src/core/SuperDoc.js +++ /dev/null @@ -1,1032 +0,0 @@ -import '../style.css'; - -import { EventEmitter } from 'eventemitter3'; -import { v4 as uuidv4 } from 'uuid'; -import { HocuspocusProviderWebsocket } from '@hocuspocus/provider'; - -import { DOCX, PDF, HTML } from '@superdoc/common'; -import { SuperToolbar, createZip } from '@harbour-enterprises/super-editor'; -import { SuperComments } from '../components/CommentsLayer/commentsList/super-comments-list.js'; -import { createSuperdocVueApp } from './create-app.js'; -import { shuffleArray } from '@superdoc/common/collaboration/awareness'; -import { createDownload, cleanName } from './helpers/export.js'; -import { initSuperdocYdoc, initCollaborationComments, makeDocumentsCollaborative } from './collaboration/helpers.js'; -import { normalizeDocumentEntry } from './helpers/file.js'; -import { isAllowed } from './collaboration/permissions.js'; - -const DEFAULT_USER = Object.freeze({ - name: 'Default SuperDoc user', - email: null, -}); - -/** @typedef {import('./types').User} User */ -/** @typedef {import('./types').TelemetryConfig} TelemetryConfig */ -/** @typedef {import('./types').Document} Document */ -/** @typedef {import('./types').Modules} Modules */ -/** @typedef {import('./types').Editor} Editor */ -/** @typedef {import('./types').DocumentMode} DocumentMode */ -/** @typedef {import('./types').Config} Config */ -/** @typedef {import('./types').ExportParams} ExportParams */ - -/** - * SuperDoc class - * Expects a config object - * - * @class - * @extends EventEmitter - */ -export class SuperDoc extends EventEmitter { - /** @type {Array} */ - static allowedTypes = [DOCX, PDF, HTML]; - - /** @type {string} */ - version; - - /** @type {User[]} */ - users; - - /** @type {import('yjs').Doc | undefined} */ - ydoc; - - /** @type {import('@hocuspocus/provider').HocuspocusProvider | undefined} */ - provider; - - /** @type {Config} */ - config = { - superdocId: null, - selector: '#superdoc', - documentMode: 'editing', - role: 'editor', - document: {}, - documents: [], - format: null, - editorExtensions: [], - - colors: [], - user: { name: null, email: null }, - users: [], - - modules: {}, // Optional: Modules to load. Use modules.ai.{your_key} to pass in your key - permissionResolver: null, // Optional: Override for permission checks - - title: 'SuperDoc', - conversations: [], - isInternal: false, - - // toolbar config - toolbar: null, // Optional DOM element to render the toolbar in - toolbarGroups: ['left', 'center', 'right'], - toolbarIcons: {}, - toolbarTexts: {}, - - isDev: false, - - // telemetry config - telemetry: null, - - // Events - onEditorBeforeCreate: () => null, - onEditorCreate: () => null, - onEditorDestroy: () => null, - onContentError: () => null, - onReady: () => null, - onCommentsUpdate: () => null, - onAwarenessUpdate: () => null, - onLocked: () => null, - onPdfDocumentReady: () => null, - onSidebarToggle: () => null, - onCollaborationReady: () => null, - onEditorUpdate: () => null, - onCommentsListChange: () => null, - onException: () => null, - onListDefinitionsChange: () => null, - onTransaction: () => null, - onFontsResolved: null, - // Image upload handler - // async (file) => url; - handleImageUpload: null, - - // Disable context menus (slash and right-click) globally - disableContextMenu: false, - - // Internal: toggle layout-engine-powered PresentationEditor in dev shells - useLayoutEngine: true, - }; - - /** - * @param {Config} config - */ - constructor(config) { - super(); - this.#init(config); - } - - async #init(config) { - this.config = { - ...this.config, - ...config, - }; - - const incomingUser = this.config.user; - if (!incomingUser || typeof incomingUser !== 'object') { - this.config.user = { ...DEFAULT_USER }; - } else { - this.config.user = { - ...DEFAULT_USER, - ...incomingUser, - }; - if (!this.config.user.name) { - this.config.user.name = DEFAULT_USER.name; - } - } - - // Initialize tracked changes defaults based on document mode - if (!this.config.layoutEngineOptions) { - this.config.layoutEngineOptions = {}; - } - // Only set defaults if user didn't explicitly configure tracked changes - if (!this.config.layoutEngineOptions.trackedChanges) { - // Default: ON for editing/suggesting modes, OFF for viewing mode - const isViewingMode = this.config.documentMode === 'viewing'; - this.config.layoutEngineOptions.trackedChanges = { - mode: isViewingMode ? 'final' : 'review', - enabled: !isViewingMode, - }; - } - - this.config.modules = this.config.modules || {}; - if (!Object.prototype.hasOwnProperty.call(this.config.modules, 'comments')) { - this.config.modules.comments = {}; - } - - this.config.colors = shuffleArray(this.config.colors); - this.userColorMap = new Map(); - this.colorIndex = 0; - - // @ts-expect-error - __APP_VERSION__ is injected at build time - this.version = __APP_VERSION__; - this.#log('🦋 [superdoc] Using SuperDoc version:', this.version); - - this.superdocId = config.superdocId || uuidv4(); - this.colors = this.config.colors; - - // Preprocess document - this.#initDocuments(); - - // Initialize collaboration if configured - await this.#initCollaboration(this.config.modules); - - // Apply csp nonce if provided - if (this.config.cspNonce) this.#patchNaiveUIStyles(); - - this.#initVueApp(); - this.#initListeners(); - - this.user = this.config.user; // The current user - this.users = this.config.users || []; // All users who have access to this superdoc - this.socket = null; - - this.isDev = this.config.isDev || false; - - this.activeEditor = null; - this.comments = []; - - if (!this.config.selector) { - throw new Error('SuperDoc: selector is required'); - } - - this.app.mount(this.config.selector); - - // Required editors - this.readyEditors = 0; - - this.isLocked = this.config.isLocked || false; - this.lockedBy = this.config.lockedBy || null; - - // If a toolbar element is provided, render a toolbar - this.#addToolbar(); - } - - /** - * Get the number of editors that are required for this superdoc - * @returns {number} The number of required editors - */ - get requiredNumberOfEditors() { - return this.superdocStore.documents.filter((d) => d.type === DOCX).length; - } - - get state() { - return { - documents: this.superdocStore.documents, - users: this.users, - }; - } - - /** - * Get the SuperDoc container element - * @returns {HTMLElement | null} - */ - get element() { - if (typeof this.config.selector === 'string') { - return document.querySelector(this.config.selector); - } - return this.config.selector; - } - - #patchNaiveUIStyles() { - const cspNonce = this.config.cspNonce; - - const originalCreateElement = document.createElement; - document.createElement = function (tagName) { - const element = originalCreateElement.call(this, tagName); - if (tagName.toLowerCase() === 'style') { - element.setAttribute('nonce', cspNonce); - } - return element; - }; - } - - #initDocuments() { - const doc = this.config.document; - const hasDocumentConfig = !!doc && typeof doc === 'object' && Object.keys(this.config.document)?.length; - const hasDocumentUrl = !!doc && typeof doc === 'string' && doc.length > 0; - const hasDocumentFile = !!doc && typeof File === 'function' && doc instanceof File; - const hasDocumentBlob = !!doc && doc instanceof Blob && !(doc instanceof File); - const hasListOfDocuments = this.config.documents && this.config.documents?.length; - if (hasDocumentConfig && hasListOfDocuments) { - console.warn('🦋 [superdoc] You can only provide one of document or documents'); - } - - if (hasDocumentConfig) { - // If an uploader-specific wrapper was passed, normalize it. - const normalized = normalizeDocumentEntry(this.config.document); - this.config.documents = [ - { - id: uuidv4(), - ...normalized, - }, - ]; - } else if (hasDocumentUrl) { - this.config.documents = [ - { - id: uuidv4(), - type: DOCX, - url: this.config.document, - name: 'document.docx', - isNewFile: true, - }, - ]; - } else if (hasDocumentFile) { - const normalized = normalizeDocumentEntry(this.config.document); - this.config.documents = [ - { - id: uuidv4(), - ...normalized, - }, - ]; - } else if (hasDocumentBlob) { - const normalized = normalizeDocumentEntry(this.config.document); - this.config.documents = [ - { - id: uuidv4(), - ...normalized, - }, - ]; - } - - // Also normalize any provided documents array entries (e.g., when consumer passes uploader wrappers directly) - if (Array.isArray(this.config.documents) && this.config.documents.length > 0) { - this.config.documents = this.config.documents.map((d) => { - const normalized = normalizeDocumentEntry(d); - - if (!normalized || typeof normalized !== 'object') { - return normalized; - } - - const existingId = - (typeof normalized === 'object' && 'id' in normalized && normalized.id) || - (d && typeof d === 'object' && 'id' in d && d.id); - - return { - ...normalized, - id: existingId || uuidv4(), - }; - }); - } - } - - #initVueApp() { - const { app, pinia, superdocStore, commentsStore, highContrastModeStore } = createSuperdocVueApp(); - this.app = app; - this.pinia = pinia; - this.app.config.globalProperties.$config = this.config; - this.app.config.globalProperties.$documentMode = this.config.documentMode; - - this.app.config.globalProperties.$superdoc = this; - this.superdocStore = superdocStore; - this.commentsStore = commentsStore; - this.highContrastModeStore = highContrastModeStore; - if (typeof this.superdocStore.setExceptionHandler === 'function') { - this.superdocStore.setExceptionHandler((payload) => this.emit('exception', payload)); - } - this.superdocStore.init(this.config); - const commentsModuleConfig = this.config.modules.comments; - this.commentsStore.init(commentsModuleConfig && commentsModuleConfig !== false ? commentsModuleConfig : {}); - } - - #initListeners() { - this.on('editorBeforeCreate', this.config.onEditorBeforeCreate); - this.on('editorCreate', this.config.onEditorCreate); - this.on('editorDestroy', this.config.onEditorDestroy); - this.on('ready', this.config.onReady); - this.on('comments-update', this.config.onCommentsUpdate); - this.on('awareness-update', this.config.onAwarenessUpdate); - this.on('locked', this.config.onLocked); - this.on('pdf-document-ready', this.config.onPdfDocumentReady); - this.on('sidebar-toggle', this.config.onSidebarToggle); - this.on('collaboration-ready', this.config.onCollaborationReady); - this.on('editor-update', this.config.onEditorUpdate); - this.on('content-error', this.onContentError); - this.on('exception', this.config.onException); - this.on('list-definitions-change', this.config.onListDefinitionsChange); - - if (this.config.onFontsResolved) { - this.on('fonts-resolved', this.config.onFontsResolved); - } - } - - /** - * Initialize collaboration if configured - * @param {Object} config - * @returns {Promise} The processed documents with collaboration enabled - */ - async #initCollaboration({ collaboration: collaborationModuleConfig, comments: commentsConfig = {} } = {}) { - if (!collaborationModuleConfig) return this.config.documents; - - // Flag this superdoc as collaborative - this.isCollaborative = true; - - // Start a socket for all documents and general metaMap for this SuperDoc - if (collaborationModuleConfig.providerType === 'hocuspocus') { - this.config.socket = new HocuspocusProviderWebsocket({ - url: collaborationModuleConfig.url, - }); - } - - // Initialize collaboration for documents - const processedDocuments = makeDocumentsCollaborative(this); - - // Optionally, initialize separate superdoc sync - for comments, view, etc. - if (commentsConfig.useInternalExternalComments && !commentsConfig.suppressInternalExternalComments) { - const { ydoc: sdYdoc, provider: sdProvider } = initSuperdocYdoc(this); - this.ydoc = sdYdoc; - this.provider = sdProvider; - } else { - this.ydoc = processedDocuments[0].ydoc; - this.provider = processedDocuments[0].provider; - } - - // Initialize comments sync, if enabled - initCollaborationComments(this); - - return processedDocuments; - } - - /** - * Add a user to the shared users list - * @param {Object} user The user to add - * @returns {void} - */ - addSharedUser(user) { - if (this.users.some((u) => u.email === user.email)) return; - this.users.push(user); - } - - /** - * Remove a user from the shared users list - * @param {String} email The email of the user to remove - * @returns {void} - */ - removeSharedUser(email) { - this.users = this.users.filter((u) => u.email !== email); - } - - /** - * Triggered when there is an error in the content - * @param {Object} param0 - * @param {Error} param0.error The error that occurred - * @param {Editor} param0.editor The editor that caused the error - */ - onContentError({ error, editor }) { - const { documentId } = editor.options; - const doc = this.superdocStore.documents.find((d) => d.id === documentId); - this.config.onContentError({ error, editor, documentId: doc.id, file: doc.data }); - } - - /** - * Triggered when the PDF document is ready - * @returns {void} - */ - broadcastPdfDocumentReady() { - this.emit('pdf-document-ready'); - } - - /** - * Triggered when the superdoc is ready - * @returns {void} - */ - broadcastReady() { - if (this.readyEditors === this.requiredNumberOfEditors) { - this.emit('ready', { superdoc: this }); - } - } - - /** - * Triggered before an editor is created - * @param {Editor} editor The editor that is about to be created - * @returns {void} - */ - broadcastEditorBeforeCreate(editor) { - this.emit('editorBeforeCreate', { editor }); - } - - /** - * Triggered when an editor is created - * @param {Editor} editor The editor that was created - * @returns {void} - */ - broadcastEditorCreate(editor) { - this.readyEditors++; - this.broadcastReady(); - this.emit('editorCreate', { editor }); - } - - /** - * Triggered when an editor is destroyed - * @returns {void} - */ - broadcastEditorDestroy() { - this.emit('editorDestroy'); - } - - /** - * Triggered when the comments sidebar is toggled - * @param {boolean} isOpened - */ - broadcastSidebarToggle(isOpened) { - this.emit('sidebar-toggle', isOpened); - } - - #log(...args) { - (console.debug ? console.debug : console.log)('🦋 🦸‍♀️ [superdoc]', ...args); - } - - /** - * Set the active editor - * @param {Editor} editor The editor to set as active - * @returns {void} - */ - setActiveEditor(editor) { - this.activeEditor = editor; - if (this.toolbar) { - this.activeEditor.toolbar = this.toolbar; - this.toolbar.setActiveEditor(editor); - } - } - - /** - * Toggle the ruler visibility for SuperEditors - * - * @returns {void} - */ - toggleRuler() { - this.config.rulers = !this.config.rulers; - this.superdocStore.documents.forEach((doc) => { - doc.rulers = this.config.rulers; - }); - } - - /** - * Determine whether the current configuration allows a given permission. - * Used by downstream consumers (toolbar, context menu, commands) to keep - * tracked-change affordances consistent with customer overrides. - * - * @param {Object} params - * @param {string} params.permission Permission key to evaluate - * @param {string} [params.role=this.config.role] Role to evaluate against - * @param {boolean} [params.isInternal=this.config.isInternal] Internal/external flag - * @param {Object|null} [params.comment] Comment object (if already resolved) - * @param {Object|null} [params.trackedChange] Tracked change metadata (id, attrs, etc.) - * @returns {boolean} - */ - canPerformPermission({ - permission, - role = this.config.role, - isInternal = this.config.isInternal, - comment = null, - trackedChange = null, - } = {}) { - if (!permission) return false; - - let resolvedComment = comment ?? trackedChange?.comment ?? null; - - const commentId = trackedChange?.commentId || trackedChange?.id; - if (!resolvedComment && commentId && this.commentsStore?.getComment) { - const storeComment = this.commentsStore.getComment(commentId); - resolvedComment = storeComment?.getValues ? storeComment.getValues() : storeComment; - } - - const context = { - superdoc: this, - currentUser: this.config.user, - comment: resolvedComment ?? null, - trackedChange: trackedChange ?? null, - }; - - return isAllowed(permission, role, isInternal, context); - } - - #addToolbar() { - const moduleConfig = this.config.modules?.toolbar || {}; - this.toolbarElement = this.config.modules?.toolbar?.selector || this.config.toolbar; - this.toolbar = null; - - const config = { - selector: this.toolbarElement || null, - isDev: this.isDev || false, - toolbarGroups: this.config.modules?.toolbar?.groups || this.config.toolbarGroups, - role: this.config.role, - icons: this.config.modules?.toolbar?.icons || this.config.toolbarIcons, - texts: this.config.modules?.toolbar?.texts || this.config.toolbarTexts, - fonts: this.config.modules?.toolbar?.fonts || null, - hideButtons: this.config.modules?.toolbar?.hideButtons ?? true, - responsiveToContainer: this.config.modules?.toolbar?.responsiveToContainer ?? false, - documentMode: this.config.documentMode, - superdoc: this, - aiApiKey: this.config.modules?.ai?.apiKey, - aiEndpoint: this.config.modules?.ai?.endpoint, - ...moduleConfig, - }; - - this.toolbar = new SuperToolbar(config); - - this.toolbar.on('superdoc-command', this.onToolbarCommand.bind(this)); - this.toolbar.on('exception', this.config.onException); - this.once('editorCreate', () => this.toolbar.updateToolbarState()); - } - - /** - * Add a comments list to the superdoc - * Requires the comments module to be enabled - * @param {Element} element The DOM element to render the comments list in - * @returns {void} - */ - addCommentsList(element) { - if (!this.config?.modules?.comments || this.config.role === 'viewer') return; - this.#log('🦋 [superdoc] Adding comments list to:', element); - if (element) this.config.modules.comments.element = element; - this.commentsList = new SuperComments(this.config.modules?.comments, this); - if (this.config.onCommentsListChange) this.config.onCommentsListChange({ isRendered: true }); - } - - /** - * Remove the comments list from the superdoc - * @returns {void} - */ - removeCommentsList() { - if (this.commentsList) { - this.commentsList.close(); - this.commentsList = null; - if (this.config.onCommentsListChange) this.config.onCommentsListChange({ isRendered: false }); - } - } - - /** - * Toggle the custom context menu globally. - * Updates both flow editors and PresentationEditor instances so downstream listeners can short-circuit early. - * @param {boolean} disabled - */ - setDisableContextMenu(disabled = true) { - const nextValue = Boolean(disabled); - if (this.config.disableContextMenu === nextValue) return; - this.config.disableContextMenu = nextValue; - - this.superdocStore?.documents?.forEach((doc) => { - const presentationEditor = doc.getPresentationEditor?.(); - if (presentationEditor?.setContextMenuDisabled) { - presentationEditor.setContextMenuDisabled(nextValue); - } - const editor = doc.getEditor?.(); - if (editor?.setOptions) { - editor.setOptions({ disableContextMenu: nextValue }); - } - }); - } - - /** - * Triggered when a toolbar command is executed - * @param {Object} param0 - * @param {Object} param0.item The toolbar item that was clicked - * @param {string} param0.argument The argument passed to the command - */ - onToolbarCommand({ item, argument }) { - if (item.command === 'setDocumentMode') { - this.setDocumentMode(argument); - } else if (item.command === 'setZoom') { - this.superdocStore.activeZoom = argument; - } - } - - /** - * Set the document mode. - * @param {DocumentMode} type - * @returns {void} - */ - setDocumentMode(type) { - if (!type) return; - - type = type.toLowerCase(); - this.config.documentMode = type; - - const types = { - viewing: () => this.#setModeViewing(), - editing: () => this.#setModeEditing(), - suggesting: () => this.#setModeSuggesting(), - }; - - if (types[type]) types[type](); - } - - /** - * Set the document mode on a document's editor (PresentationEditor or Editor). - * Tries PresentationEditor first, falls back to Editor for backward compatibility. - * @param {Object} doc - The document object - * @param {string} mode - The document mode ('editing', 'viewing', 'suggesting') - */ - #applyDocumentMode(doc, mode) { - const presentationEditor = typeof doc.getPresentationEditor === 'function' ? doc.getPresentationEditor() : null; - if (presentationEditor) { - presentationEditor.setDocumentMode(mode); - return; - } - const editor = typeof doc.getEditor === 'function' ? doc.getEditor() : null; - if (editor) { - editor.setDocumentMode(mode); - } - } - - /** - * Force PresentationEditor instances to render a specific tracked-changes mode - * or disable tracked-change metadata entirely. - * - * @param {{ mode?: 'review' | 'original' | 'final' | 'off', enabled?: boolean }} [preferences] - */ - setTrackedChangesPreferences(preferences) { - const normalized = preferences && Object.keys(preferences).length ? { ...preferences } : undefined; - if (!this.config.layoutEngineOptions) { - this.config.layoutEngineOptions = {}; - } - this.config.layoutEngineOptions.trackedChanges = normalized; - this.superdocStore?.documents?.forEach((doc) => { - const presentationEditor = typeof doc.getPresentationEditor === 'function' ? doc.getPresentationEditor() : null; - if (presentationEditor?.setTrackedChangesOverrides) { - presentationEditor.setTrackedChangesOverrides(normalized); - } - }); - } - - #setModeEditing() { - if (this.config.role !== 'editor') return this.#setModeSuggesting(); - if (this.superdocStore.documents.length > 0) { - const firstEditor = this.superdocStore.documents[0]?.getEditor(); - if (firstEditor) this.setActiveEditor(firstEditor); - } - - // Enable tracked changes for editing mode - this.setTrackedChangesPreferences({ mode: 'review', enabled: true }); - - this.superdocStore.documents.forEach((doc) => { - doc.restoreComments(); - this.#applyDocumentMode(doc, 'editing'); - }); - - if (this.toolbar) { - this.toolbar.documentMode = 'editing'; - this.toolbar.updateToolbarState(); - } - } - - #setModeSuggesting() { - if (!['editor', 'suggester'].includes(this.config.role)) return this.#setModeViewing(); - if (this.superdocStore.documents.length > 0) { - const firstEditor = this.superdocStore.documents[0]?.getEditor(); - if (firstEditor) this.setActiveEditor(firstEditor); - } - - // Enable tracked changes for suggesting mode - this.setTrackedChangesPreferences({ mode: 'review', enabled: true }); - - this.superdocStore.documents.forEach((doc) => { - doc.restoreComments(); - this.#applyDocumentMode(doc, 'suggesting'); - }); - - if (this.toolbar) { - this.toolbar.documentMode = 'suggesting'; - this.toolbar.updateToolbarState(); - } - } - - #setModeViewing() { - this.toolbar.activeEditor = null; - - // Disable tracked changes for viewing mode (show final document) - this.setTrackedChangesPreferences({ mode: 'final', enabled: false }); - - this.superdocStore.documents.forEach((doc) => { - doc.removeComments(); - this.#applyDocumentMode(doc, 'viewing'); - }); - - if (this.toolbar) { - this.toolbar.documentMode = 'viewing'; - this.toolbar.updateToolbarState(); - } - } - - /** - * Search for text or regex in the active editor - * @param {string | RegExp} text The text or regex to search for - * @returns {Object[]} The search results - */ - search(text) { - return this.activeEditor?.commands.search(text); - } - - /** - * Go to the next search result - * @param {Object} match The match object - * @returns {void} - */ - goToSearchResult(match) { - return this.activeEditor?.commands.goToSearchResult(match); - } - - /** - * Set the document to locked or unlocked - * @param {boolean} lock - */ - setLocked(lock = true) { - this.config.documents.forEach((doc) => { - const metaMap = doc.ydoc.getMap('meta'); - doc.ydoc.transact(() => { - metaMap.set('locked', lock); - metaMap.set('lockedBy', this.user); - }); - }); - } - - /** - * Get the HTML content of all editors - * @returns {Array} The HTML content of all editors - */ - getHTML(options = {}) { - const editors = []; - this.superdocStore.documents.forEach((doc) => { - const editor = doc.getEditor(); - if (editor) { - editors.push(editor); - } - }); - - return editors.map((editor) => editor.getHTML(options)); - } - - /** - * Lock the current superdoc - * @param {Boolean} isLocked - * @param {User} lockedBy The user who locked the superdoc - */ - lockSuperdoc(isLocked = false, lockedBy) { - this.isLocked = isLocked; - this.lockedBy = lockedBy; - this.#log('🦋 [superdoc] Locking superdoc:', isLocked, lockedBy, '\n\n\n'); - this.emit('locked', { isLocked, lockedBy }); - } - - /** - * Export the superdoc to a file - * @param {ExportParams} params - Export configuration - * @returns {Promise} - */ - async export({ - exportType = ['docx'], - commentsType = 'external', - exportedName, - additionalFiles = [], - additionalFileNames = [], - isFinalDoc = false, - triggerDownload = true, - fieldsHighlightColor = null, - } = {}) { - // Get the docx files first - const baseFileName = exportedName ? cleanName(exportedName) : cleanName(this.config.title); - const docxFiles = await this.exportEditorsToDOCX({ commentsType, isFinalDoc, fieldsHighlightColor }); - const blobsToZip = [...additionalFiles]; - const filenames = [...additionalFileNames]; - - // If we are exporting docx files, add them to the zip - if (exportType.includes('docx')) { - docxFiles.forEach((blob) => { - blobsToZip.push(blob); - filenames.push(`${baseFileName}.docx`); - }); - } - - // If we only have one blob, just download it. Otherwise, zip them up. - if (blobsToZip.length === 1) { - if (triggerDownload) { - return createDownload(blobsToZip[0], baseFileName, exportType[0]); - } - - return blobsToZip[0]; - } - - const zip = await createZip(blobsToZip, filenames); - - if (triggerDownload) { - return createDownload(zip, baseFileName, 'zip'); - } - - return zip; - } - - /** - * Export editors to DOCX format. - * @param {{ commentsType?: string, isFinalDoc?: boolean }} [options] - * @returns {Promise>} - */ - async exportEditorsToDOCX({ commentsType, isFinalDoc, fieldsHighlightColor } = {}) { - const comments = []; - if (commentsType !== 'clean') { - if (this.commentsStore && typeof this.commentsStore.translateCommentsForExport === 'function') { - comments.push(...this.commentsStore.translateCommentsForExport()); - } - } - - const docxPromises = this.superdocStore.documents.map(async (doc) => { - if (!doc || doc.type !== DOCX) return null; - - const editor = typeof doc.getEditor === 'function' ? doc.getEditor() : null; - const fallbackDocx = () => { - if (!doc.data) return null; - if (doc.data.type && doc.data.type !== DOCX) return null; - return doc.data; - }; - - if (!editor) return fallbackDocx(); - - try { - const exported = await editor.exportDocx({ isFinalDoc, comments, commentsType, fieldsHighlightColor }); - if (exported) return exported; - } catch (error) { - this.emit('exception', { error, document: doc }); - } - - return fallbackDocx(); - }); - - const docxFiles = await Promise.all(docxPromises); - return docxFiles.filter(Boolean); - } - - /** - * Request an immediate save from all collaboration documents - * @returns {Promise} Resolves when all documents have saved - */ - async #triggerCollaborationSaves() { - this.#log('🦋 [superdoc] Triggering collaboration saves'); - return new Promise((resolve) => { - this.superdocStore.documents.forEach((doc, index) => { - this.#log(`Before reset - Doc ${index}: pending = ${this.pendingCollaborationSaves}`); - this.pendingCollaborationSaves = 0; - if (doc.ydoc) { - this.pendingCollaborationSaves++; - this.#log(`After increment - Doc ${index}: pending = ${this.pendingCollaborationSaves}`); - const metaMap = doc.ydoc.getMap('meta'); - metaMap.observe((event) => { - if (event.changes.keys.has('immediate-save-finished')) { - this.pendingCollaborationSaves--; - if (this.pendingCollaborationSaves <= 0) { - resolve(); - } - } - }); - metaMap.set('immediate-save', true); - } - }); - this.#log( - `FINAL pending = ${this.pendingCollaborationSaves}, but we have ${this.superdocStore.documents.filter((d) => d.ydoc).length} docs!`, - ); - }); - } - - /** - * Save the superdoc if in collaboration mode - * @returns {Promise} Resolves when all documents have saved - */ - async save() { - const savePromises = [ - this.#triggerCollaborationSaves(), - // this.exportEditorsToDOCX(), - ]; - - this.#log('🦋 [superdoc] Saving superdoc'); - const result = await Promise.all(savePromises); - this.#log('🦋 [superdoc] Save complete:', result); - return result; - } - - /** - * Destroy the superdoc instance - * @returns {void} - */ - destroy() { - if (!this.app) { - return; - } - - this.#log('[superdoc] Unmounting app'); - - this.config.socket?.cancelWebsocketRetry(); - this.config.socket?.disconnect(); - this.config.socket?.destroy(); - - this.ydoc?.destroy(); - this.provider?.disconnect(); - this.provider?.destroy(); - - this.config.documents.forEach((doc) => { - if (doc.provider) { - doc.provider.disconnect(); - doc.provider.destroy(); - } - - // Destroy the ydoc - doc.ydoc?.destroy(); - }); - - this.superdocStore.reset(); - - this.app.unmount(); - this.removeAllListeners(); - delete this.app.config.globalProperties.$config; - delete this.app.config.globalProperties.$superdoc; - } - - /** - * Focus the active editor or the first editor in the superdoc - * @returns {void} - */ - focus() { - if (this.activeEditor) { - this.activeEditor.focus(); - } else { - this.superdocStore.documents.find((doc) => { - const editor = doc.getEditor(); - if (editor) { - editor.focus(); - } - }); - } - } - - /** - * Set the high contrast mode - * @param {boolean} isHighContrast - * @returns {void} - */ - setHighContrastMode(isHighContrast) { - if (!this.activeEditor) return; - this.activeEditor.setHighContrastMode(isHighContrast); - this.highContrastModeStore.setHighContrastMode(isHighContrast); - } - - /** - * Capture layout pipeline events from PresentationEditor - * Forwards metrics and errors to host callbacks - * @param {Object} payload - Event payload from PresentationEditor.onTelemetry - * @param {string} payload.type - Event type: 'layout' or 'error' - * @param {Object} payload.data - Event data (metrics for layout, error details for error) - * @returns {void} - */ - captureLayoutPipelineEvent(payload) { - // Emit as an event so hosts can listen - this.emit('layout-pipeline', payload); - - // Call the host callback if provided in config - if (typeof this.config.onLayoutPipelineEvent === 'function') { - this.config.onLayoutPipelineEvent(payload); - } - } -} diff --git a/packages/superdoc/src/core/SuperDoc.test.js b/packages/superdoc/src/core/SuperDoc.test.ts similarity index 68% rename from packages/superdoc/src/core/SuperDoc.test.js rename to packages/superdoc/src/core/SuperDoc.test.ts index d49d4943f..22133c201 100644 --- a/packages/superdoc/src/core/SuperDoc.test.js +++ b/packages/superdoc/src/core/SuperDoc.test.ts @@ -3,7 +3,7 @@ import { DOCX, PDF } from '@superdoc/common'; // Mock must be defined before imports that use it vi.mock('@superdoc/common/collaboration/awareness', () => ({ - shuffleArray: vi.fn((arr) => [...arr].reverse()), + shuffleArray: vi.fn((arr: unknown[]) => [...arr].reverse()), })); // Import the mocked module to access the mock @@ -18,28 +18,33 @@ const toolbarUpdateSpy = vi.fn(); const toolbarSetActiveSpy = vi.fn(); class MockToolbar { - constructor(config) { + config: Record; + listeners: Record void>; + activeEditor: unknown; + updateToolbarState: ReturnType; + + constructor(config: Record) { this.config = config; this.listeners = {}; this.activeEditor = null; this.updateToolbarState = toolbarUpdateSpy; } - on(event, handler) { + on(event: string, handler: (...args: unknown[]) => void): void { this.listeners[event] = handler; } - once(event, handler) { + once(event: string, handler: (...args: unknown[]) => void): void { this.listeners[event] = handler; } - setActiveEditor(editor) { + setActiveEditor(editor: unknown): void { this.activeEditor = editor; toolbarSetActiveSpy(editor); } } -const createZipMock = vi.fn(async (blobs, names) => ({ zip: true, blobs, names })); +const createZipMock = vi.fn(async (blobs: unknown[], names: string[]) => ({ zip: true, blobs, names })); vi.mock('@harbour-enterprises/super-editor', () => ({ SuperToolbar: MockToolbar, @@ -52,7 +57,7 @@ vi.mock('../components/CommentsLayer/commentsList/super-comments-list.js', () => })); const createDownloadMock = vi.fn(() => 'downloaded'); -const cleanNameMock = vi.fn((value) => value.replace(/\s+/g, '-')); +const cleanNameMock = vi.fn((value: string) => value.replace(/\s+/g, '-')); vi.mock('./helpers/export.js', () => ({ createDownload: createDownloadMock, @@ -64,7 +69,7 @@ const initSuperdocYdocMock = vi.fn(() => ({ provider: { disconnect: vi.fn(), destroy: vi.fn(), on: vi.fn(), off: vi.fn() }, })); const initCollaborationCommentsMock = vi.fn(); -const makeDocumentsCollaborativeMock = vi.fn((superdoc) => { +const makeDocumentsCollaborativeMock = vi.fn((superdoc: { config: { documents: unknown[]; socket: unknown } }) => { return superdoc.config.documents.map((doc, index) => { const provider = { disconnect: vi.fn(), destroy: vi.fn() }; const ydoc = { @@ -74,11 +79,11 @@ const makeDocumentsCollaborativeMock = vi.fn((superdoc) => { set: vi.fn(), observe: vi.fn(), })), - transact: (fn) => fn(), + transact: (fn: () => void) => fn(), }; - Object.assign(doc, { - id: doc.id || `doc-${index}`, + Object.assign(doc as Record, { + id: (doc as { id?: string }).id || `doc-${index}`, provider, ydoc, socket: superdoc.config.socket, @@ -111,12 +116,16 @@ vi.mock('./create-app.js', () => ({ createSuperdocVueApp: createVueAppMock, })); -const flushMicrotasks = async () => { +const flushMicrotasks = async (): Promise => { await Promise.resolve(); await Promise.resolve(); }; -const createAppHarness = () => { +const createAppHarness = (): { + app: Record; + superdocStore: Record; + commentsStore: Record; +} => { const superdocStore = { documents: [], init: vi.fn(), @@ -151,11 +160,11 @@ const createAppHarness = () => { }; const originalCreateElement = document.createElement; -let consoleDebugSpy; -let consoleLogSpy; +let consoleDebugSpy: ReturnType | undefined; +let consoleLogSpy: ReturnType | undefined; describe('SuperDoc core', () => { - let SuperDoc; + let SuperDoc: { new (config: Record): Record }; beforeEach(async () => { consoleDebugSpy = vi.spyOn(console, 'debug').mockImplementation(() => {}); @@ -202,10 +211,13 @@ describe('SuperDoc core', () => { expect(createVueAppMock).toHaveBeenCalled(); expect(app.mount).toHaveBeenCalledWith('#host'); - expect(superdocStore.init).toHaveBeenCalledWith(instance.config); - expect(instance.config.documents).toHaveLength(1); - expect(instance.config.documents[0]).toMatchObject({ type: DOCX, url: 'https://example.com/doc.docx' }); - expect(instance.colors).toEqual(['blue', 'red']); + expect(superdocStore.init).toHaveBeenCalledWith((instance as { config: unknown }).config); + expect((instance.config as { documents: unknown[] }).documents).toHaveLength(1); + expect((instance.config as { documents: { type: string; url: string }[] }).documents[0]).toMatchObject({ + type: DOCX, + url: 'https://example.com/doc.docx', + }); + expect((instance as { colors: string[] }).colors).toEqual(['blue', 'red']); expect(shuffleArrayMock).toHaveBeenCalledWith(['red', 'blue']); }); @@ -222,8 +234,13 @@ describe('SuperDoc core', () => { }); await flushMicrotasks(); - expect(Object.prototype.hasOwnProperty.call(instance.config.modules, 'comments')).toBe(true); - expect(instance.config.modules.comments).toMatchObject({}); + expect( + Object.prototype.hasOwnProperty.call( + (instance.config as { modules: Record }).modules, + 'comments', + ), + ).toBe(true); + expect((instance.config as { modules: { comments: Record } }).modules.comments).toMatchObject({}); expect(commentsStore.init).toHaveBeenCalledWith({}); }); @@ -239,8 +256,12 @@ describe('SuperDoc core', () => { await flushMicrotasks(); - expect(instance.config.user).toEqual(expect.objectContaining({ name: 'Default SuperDoc user', email: null })); - expect(instance.user).toEqual(expect.objectContaining({ name: 'Default SuperDoc user', email: null })); + expect((instance.config as { user: { name: string; email: unknown } }).user).toEqual( + expect.objectContaining({ name: 'Default SuperDoc user', email: null }), + ); + expect((instance as { user: { name: string; email: unknown } }).user).toEqual( + expect.objectContaining({ name: 'Default SuperDoc user', email: null }), + ); }); it('warns when both document object and documents list provided', async () => { @@ -262,14 +283,18 @@ describe('SuperDoc core', () => { await flushMicrotasks(); expect(warnSpy).toHaveBeenCalledWith('🦋 [superdoc] You can only provide one of document or documents'); - expect(instance.config.documents).toHaveLength(1); - expect(instance.config.documents[0].name).toBe('doc1.docx'); + expect((instance.config as { documents: { name: string }[] }).documents).toHaveLength(1); + expect((instance.config as { documents: { name: string }[] }).documents[0].name).toBe('doc1.docx'); warnSpy.mockRestore(); }); it('initializes collaboration for hocuspocus provider', async () => { const { superdocStore } = createAppHarness(); - superdocStore.documents = [ + (superdocStore.documents as { + id: string; + type: string; + getEditor: () => { commands: { togglePagination: ReturnType } }; + }[]) = [ { id: 'doc-1', type: DOCX, @@ -301,16 +326,20 @@ describe('SuperDoc core', () => { expect(hocuspocusConstructor).toHaveBeenCalledWith({ url: 'wss://example.com' }); expect(makeDocumentsCollaborativeMock).toHaveBeenCalledWith(instance); expect(initCollaborationCommentsMock).toHaveBeenCalledWith(instance); - expect(instance.isCollaborative).toBe(true); - expect(instance.provider).toBeDefined(); - expect(instance.ydoc).toBeDefined(); + expect((instance as { isCollaborative: boolean }).isCollaborative).toBe(true); + expect((instance as { provider: unknown }).provider).toBeDefined(); + expect((instance as { ydoc: unknown }).ydoc).toBeDefined(); }); // pagination legacy removed; togglePagination test removed it('broadcasts ready only when all editors resolved', async () => { const { superdocStore } = createAppHarness(); - superdocStore.documents = [ + (superdocStore.documents as { + type: string; + getEditor: () => Record; + setEditor: ReturnType; + }[]) = [ { type: DOCX, getEditor: vi.fn(() => ({})), setEditor: vi.fn() }, { type: DOCX, getEditor: vi.fn(() => ({})), setEditor: vi.fn() }, ]; @@ -327,13 +356,13 @@ describe('SuperDoc core', () => { await flushMicrotasks(); const readySpy = vi.fn(); - instance.on('ready', readySpy); + (instance as { on: (event: string, handler: () => void) => void }).on('ready', readySpy); const editor = {}; - instance.broadcastEditorCreate(editor); + (instance as { broadcastEditorCreate: (editor: unknown) => void }).broadcastEditorCreate(editor); expect(readySpy).not.toHaveBeenCalled(); - instance.broadcastEditorCreate(editor); + (instance as { broadcastEditorCreate: (editor: unknown) => void }).broadcastEditorCreate(editor); expect(readySpy).toHaveBeenCalledTimes(1); }); @@ -354,23 +383,27 @@ describe('SuperDoc core', () => { }); await flushMicrotasks(); - instance.config.documents = [ + ( + instance.config as { documents: { ydoc: { getMap: () => typeof metaMap; transact: (fn: () => void) => void } }[] } + ).documents = [ { ydoc: { getMap: vi.fn(() => metaMap), - transact: (fn) => fn(), + transact: (fn: () => void) => fn(), }, }, ]; const lockedSpy = vi.fn(); - instance.on('locked', lockedSpy); + (instance as { on: (event: string, handler: (data: unknown) => void) => void }).on('locked', lockedSpy); - instance.setLocked(true); + (instance as { setLocked: (locked: boolean) => void }).setLocked(true); expect(metaSet).toHaveBeenNthCalledWith(1, 'locked', true); - expect(metaSet).toHaveBeenNthCalledWith(2, 'lockedBy', instance.user); + expect(metaSet).toHaveBeenNthCalledWith(2, 'lockedBy', (instance as { user: unknown }).user); expect(lockedSpy).not.toHaveBeenCalled(); - instance.lockSuperdoc(true, { name: 'Admin' }); + (instance as { lockSuperdoc: (locked: boolean, lockedBy: { name: string }) => void }).lockSuperdoc(true, { + name: 'Admin', + }); expect(lockedSpy).toHaveBeenCalledWith({ isLocked: true, lockedBy: { name: 'Admin' } }); }); @@ -389,10 +422,12 @@ describe('SuperDoc core', () => { }); await flushMicrotasks(); - vi.spyOn(instance, 'exportEditorsToDOCX').mockResolvedValue(['docx-blob']); + vi.spyOn(instance as { exportEditorsToDOCX: () => Promise }, 'exportEditorsToDOCX').mockResolvedValue([ + 'docx-blob', + ]); const extraBlob = new Blob(['extra']); - await instance.export({ + await (instance as { export: (params: Record) => Promise }).export({ exportType: ['docx', 'txt'], additionalFiles: [extraBlob], additionalFileNames: ['extra.txt'], @@ -405,7 +440,7 @@ describe('SuperDoc core', () => { }); it('falls back to original document data when an editor export yields no blob', async () => { - const { superdocStore } = createAppHarness(); + createAppHarness(); const instance = new SuperDoc({ selector: '#host', @@ -421,7 +456,18 @@ describe('SuperDoc core', () => { const originalBlob = { name: 'fallback.docx' }; const exportDocxMock = vi.fn().mockResolvedValue(undefined); - instance.superdocStore.documents = [ + ( + instance as { + superdocStore: { + documents: { + id: string; + type: string; + data: unknown; + getEditor: () => { exportDocx: ReturnType }; + }[]; + }; + } + ).superdocStore.documents = [ { id: 'doc-1', type: DOCX, @@ -430,7 +476,7 @@ describe('SuperDoc core', () => { }, ]; - const results = await instance.exportEditorsToDOCX(); + const results = await (instance as { exportEditorsToDOCX: () => Promise }).exportEditorsToDOCX(); expect(exportDocxMock).toHaveBeenCalledTimes(1); expect(results).toEqual([originalBlob]); @@ -453,7 +499,9 @@ describe('SuperDoc core', () => { const docxBlob = { name: 'doc-1.docx', type: DOCX }; const pdfBlob = { name: 'doc-2.pdf', type: PDF }; - instance.superdocStore.documents = [ + ( + instance as { superdocStore: { documents: { id: string; type: string; data: unknown; getEditor: () => null }[] } } + ).superdocStore.documents = [ { id: 'doc-1', type: DOCX, @@ -468,7 +516,7 @@ describe('SuperDoc core', () => { }, ]; - const results = await instance.exportEditorsToDOCX(); + const results = await (instance as { exportEditorsToDOCX: () => Promise }).exportEditorsToDOCX(); expect(results).toEqual([docxBlob]); }); @@ -491,10 +539,19 @@ describe('SuperDoc core', () => { }); await flushMicrotasks(); - const provider = instance.provider; - const processedDocs = instance.config.documents; + const provider = ( + instance as { provider: { disconnect: ReturnType; destroy: ReturnType } } + ).provider; + const processedDocs = ( + instance.config as { + documents: { + provider: { disconnect: ReturnType; destroy: ReturnType }; + ydoc: { destroy: ReturnType }; + }[]; + } + ).documents; - instance.destroy(); + (instance as { destroy: () => void }).destroy(); expect(provider.disconnect).toHaveBeenCalled(); expect(provider.destroy).toHaveBeenCalled(); @@ -504,8 +561,11 @@ describe('SuperDoc core', () => { expect(doc.ydoc.destroy).toHaveBeenCalled(); }); expect(app.unmount).toHaveBeenCalled(); - expect(instance.app.config.globalProperties.$config).toBeUndefined(); - expect(instance.listenerCount('ready')).toBe(0); + expect( + (instance as { app: { config: { globalProperties: { $config?: unknown } } } }).app.config.globalProperties + .$config, + ).toBeUndefined(); + expect((instance as { listenerCount: (event: string) => number }).listenerCount('ready')).toBe(0); }); it('removes comments in viewing mode and restores them when returning to editing', async () => { @@ -519,7 +579,7 @@ describe('SuperDoc core', () => { getEditor: vi.fn(() => ({ setDocumentMode })), getPresentationEditor: vi.fn(() => null), }; - superdocStore.documents = [docStub]; + (superdocStore.documents as unknown[]) = [docStub]; const instance = new SuperDoc({ selector: '#host', @@ -533,11 +593,11 @@ describe('SuperDoc core', () => { }); await flushMicrotasks(); - instance.setDocumentMode('viewing'); + (instance as { setDocumentMode: (mode: string) => void }).setDocumentMode('viewing'); expect(removeComments).toHaveBeenCalledTimes(1); expect(setDocumentMode).toHaveBeenLastCalledWith('viewing'); - instance.setDocumentMode('editing'); + (instance as { setDocumentMode: (mode: string) => void }).setDocumentMode('editing'); expect(restoreComments).toHaveBeenCalledTimes(1); expect(setDocumentMode).toHaveBeenLastCalledWith('editing'); }); @@ -558,17 +618,19 @@ describe('SuperDoc core', () => { await flushMicrotasks(); const container = document.createElement('div'); - instance.addCommentsList(container); + (instance as { addCommentsList: (container: HTMLElement) => void }).addCommentsList(container); expect(superCommentsConstructor).not.toHaveBeenCalled(); - expect(instance.config.modules.comments.element).toBeUndefined(); - expect(instance.commentsList).toBeUndefined(); + expect( + (instance.config as { modules: { comments: { element?: unknown } } }).modules.comments.element, + ).toBeUndefined(); + expect((instance as { commentsList?: unknown }).commentsList).toBeUndefined(); }); it('applies CSP nonce to style tags when configured', async () => { createAppHarness(); - const instance = new SuperDoc({ + new SuperDoc({ selector: '#host', document: 'https://example.com/doc.docx', documents: [], @@ -600,14 +662,17 @@ describe('SuperDoc core', () => { }); await flushMicrotasks(); - expect(instance.config.documents).toHaveLength(1); - expect(instance.config.documents[0]).toMatchObject({ + expect((instance.config as { documents: unknown[] }).documents).toHaveLength(1); + expect( + (instance.config as { documents: { id: string; type: string; name: string; isNewFile: boolean }[] }) + .documents[0], + ).toMatchObject({ id: expect.any(String), type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', name: 'contract.docx', isNewFile: true, }); - expect(instance.config.documents[0].data).toBe(file); + expect((instance.config as { documents: { data: File }[] }).documents[0].data).toBe(file); }); it('handles Blob from fetch response', async () => { @@ -624,15 +689,18 @@ describe('SuperDoc core', () => { }); await flushMicrotasks(); - expect(instance.config.documents).toHaveLength(1); - expect(instance.config.documents[0]).toMatchObject({ + expect((instance.config as { documents: unknown[] }).documents).toHaveLength(1); + expect( + (instance.config as { documents: { id: string; type: string; name: string; isNewFile: boolean }[] }) + .documents[0], + ).toMatchObject({ id: expect.any(String), type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', name: 'document', // Default name for Blobs isNewFile: true, }); // Blob should be wrapped as File - expect(instance.config.documents[0].data).toBeInstanceOf(File); + expect((instance.config as { documents: { data: File }[] }).documents[0].data).toBeInstanceOf(File); }); it('handles File with empty type (browser edge case)', async () => { @@ -647,9 +715,9 @@ describe('SuperDoc core', () => { }); await flushMicrotasks(); - expect(instance.config.documents).toHaveLength(1); + expect((instance.config as { documents: unknown[] }).documents).toHaveLength(1); // Should infer type from filename - expect(instance.config.documents[0].type).toBe(DOCX); + expect((instance.config as { documents: { type: string }[] }).documents[0].type).toBe(DOCX); }); it('handles Blob without type', async () => { @@ -664,10 +732,10 @@ describe('SuperDoc core', () => { }); await flushMicrotasks(); - expect(instance.config.documents).toHaveLength(1); + expect((instance.config as { documents: unknown[] }).documents).toHaveLength(1); // Should default to DOCX - expect(instance.config.documents[0].type).toBe(DOCX); - expect(instance.config.documents[0].name).toBe('document'); + expect((instance.config as { documents: { type: string; name: string }[] }).documents[0].type).toBe(DOCX); + expect((instance.config as { documents: { type: string; name: string }[] }).documents[0].name).toBe('document'); }); }); @@ -675,7 +743,7 @@ describe('SuperDoc core', () => { it('generates IDs for all document types', async () => { createAppHarness(); - const testCases = [ + const testCases: { document: string | File | Blob | { data: Blob; name: string; type: string } }[] = [ // URL string { document: 'https://example.com/doc.docx' }, // File @@ -693,8 +761,8 @@ describe('SuperDoc core', () => { }); await flushMicrotasks(); - expect(instance.config.documents[0].id).toBeDefined(); - expect(instance.config.documents[0].id).toMatch(/^(uuid-1234|doc-)/); + expect((instance.config as { documents: { id: string }[] }).documents[0].id).toBeDefined(); + expect((instance.config as { documents: { id: string }[] }).documents[0].id).toMatch(/^(uuid-1234|doc-)/); } }); @@ -707,8 +775,12 @@ describe('SuperDoc core', () => { }); await flushMicrotasks(); - expect(instance.config.documents[0]).toBeNull(); - expect(instance.config.documents[1]).toMatchObject({ + expect( + (instance.config as { documents: (null | { id: string; type: string; name: string })[] }).documents[0], + ).toBeNull(); + expect( + (instance.config as { documents: (null | { id: string; type: string; name: string })[] }).documents[1], + ).toMatchObject({ id: 'uuid-1234', type: DOCX, name: 'doc.docx', @@ -727,9 +799,9 @@ describe('SuperDoc core', () => { }); await flushMicrotasks(); - expect(instance.config.documents[0].id).toBe('custom-id-1'); - expect(instance.config.documents[1].id).toBeDefined(); - expect(instance.config.documents[1].id).not.toBe('custom-id-1'); + expect((instance.config as { documents: { id: string }[] }).documents[0].id).toBe('custom-id-1'); + expect((instance.config as { documents: { id: string }[] }).documents[1].id).toBeDefined(); + expect((instance.config as { documents: { id: string }[] }).documents[1].id).not.toBe('custom-id-1'); }); }); @@ -748,8 +820,10 @@ describe('SuperDoc core', () => { }); await flushMicrotasks(); - expect(instance.config.documents).toHaveLength(1); - expect(instance.config.documents[0]).toMatchObject({ + expect((instance.config as { documents: unknown[] }).documents).toHaveLength(1); + expect( + (instance.config as { documents: { id: string; type: string; name: string }[] }).documents[0], + ).toMatchObject({ id: expect.any(String), type: DOCX, name: 'custom.docx', @@ -773,7 +847,10 @@ describe('SuperDoc core', () => { }); await flushMicrotasks(); - expect(instance.config.documents[0]).toMatchObject({ + expect( + (instance.config as { documents: { id: string; type: string; name: string; isNewFile: boolean }[] }) + .documents[0], + ).toMatchObject({ id: expect.any(String), type: DOCX, name: 'custom.docx', diff --git a/packages/superdoc/src/core/SuperDoc.ts b/packages/superdoc/src/core/SuperDoc.ts new file mode 100644 index 000000000..7d0f3491c --- /dev/null +++ b/packages/superdoc/src/core/SuperDoc.ts @@ -0,0 +1,1465 @@ +import '../style.css'; + +import EventEmitter from 'eventemitter3'; +import { v4 as uuidv4 } from 'uuid'; +import { HocuspocusProviderWebsocket } from '@hocuspocus/provider'; +import type { HocuspocusProvider } from '@hocuspocus/provider'; +import type { Doc as YDoc } from 'yjs'; +import type { App as VueApp } from 'vue'; +import type { Pinia } from 'pinia'; + +import { DOCX, PDF, HTML } from '@superdoc/common'; +import { SuperToolbar, createZip } from '@harbour-enterprises/super-editor'; +import { SuperComments } from '../components/CommentsLayer/commentsList/super-comments-list'; +import { createSuperdocVueApp } from './create-app'; +import { shuffleArray } from '@superdoc/common/collaboration/awareness'; +import { createDownload, cleanName } from './helpers/export'; +import { initSuperdocYdoc, initCollaborationComments, makeDocumentsCollaborative } from './collaboration/helpers'; +import { normalizeDocumentEntry } from './helpers/file'; +import { isAllowed } from './collaboration/permissions'; +import type { + User, + Config, + Document, + Editor, + DocumentMode, + ExportParams, + ExportType, + CommentsType, + Modules, + PermissionResolverParams, +} from './types'; +import type { SuperDocEvents } from './SuperDoc.types'; + +const DEFAULT_USER: Readonly = Object.freeze({ + name: 'Default SuperDoc user', + email: null, +}); + +/** + * SuperDoc class + * + * The main SuperDoc class that manages document editing, collaboration, + * and all related functionality. It extends EventEmitter to provide + * a robust event system for document lifecycle management. + * + * @class + * @extends EventEmitter + * + * @example + * const superdoc = new SuperDoc({ + * selector: '#editor', + * documentMode: 'editing', + * role: 'editor', + * document: { url: '/path/to/document.docx', type: 'docx' }, + * user: { name: 'John Doe', email: 'john@example.com' } + * }); + */ +export class SuperDoc extends EventEmitter { + /** Allowed document types */ + static allowedTypes: string[] = [DOCX, PDF, HTML]; + + /** Version of SuperDoc */ + version: string; + + /** All users who have access to this superdoc */ + users: User[]; + + /** Yjs document for collaboration */ + ydoc?: YDoc; + + /** HocusPocus provider for collaboration */ + provider?: HocuspocusProvider; + + /** Configuration object */ + config: Config; + + /** Vue application instance */ + app!: VueApp; + + /** Pinia store instance */ + pinia!: Pinia; + + /** SuperDoc store */ + superdocStore!: ReturnType; + + /** Comments store */ + commentsStore!: ReturnType; + + /** High contrast mode store */ + highContrastModeStore!: ReturnType; + + /** Unique ID for this SuperDoc instance */ + superdocId: string; + + /** Available colors for user awareness */ + colors: string[]; + + /** Map of users to their assigned colors */ + userColorMap: Map; + + /** Current index for color assignment */ + colorIndex: number; + + /** Current user */ + user: User; + + /** Socket connection (deprecated) */ + socket: null; + + /** Whether running in development mode */ + isDev: boolean; + + /** Currently active editor instance */ + activeEditor: Editor | null; + + /** All comments in the document */ + comments: unknown[]; + + /** Number of editors that have been initialized */ + readyEditors: number; + + /** Whether the document is locked */ + isLocked: boolean; + + /** User who locked the document */ + lockedBy: User | null; + + /** Toolbar instance */ + toolbar: InstanceType | null; + + /** Toolbar DOM element */ + toolbarElement?: string | HTMLElement; + + /** Comments list instance */ + commentsList?: SuperComments; + + /** Whether this SuperDoc uses collaboration */ + isCollaborative?: boolean; + + /** Pending collaboration saves counter */ + pendingCollaborationSaves: number; + + /** + * Create a new SuperDoc instance + * + * @param config - Configuration options for SuperDoc + */ + constructor(config: Config) { + super(); + this.version = ''; + this.users = []; + this.config = { + selector: '#superdoc', + documentMode: 'editing', + role: 'editor', + document: {}, + documents: [], + editorExtensions: [], + colors: [], + user: { name: '', email: null }, + users: [], + modules: {}, + title: 'SuperDoc', + conversations: [], + isInternal: false, + toolbarGroups: ['left', 'center', 'right'], + toolbarIcons: {}, + toolbarTexts: {}, + isDev: false, + onEditorBeforeCreate: () => { + /* Lifecycle hook - override in config */ + }, + onEditorCreate: () => { + /* Lifecycle hook - override in config */ + }, + onEditorDestroy: () => { + /* Lifecycle hook - override in config */ + }, + onContentError: () => { + /* Lifecycle hook - override in config */ + }, + onReady: () => { + /* Lifecycle hook - override in config */ + }, + onCommentsUpdate: () => { + /* Lifecycle hook - override in config */ + }, + onAwarenessUpdate: () => { + /* Lifecycle hook - override in config */ + }, + onLocked: () => { + /* Lifecycle hook - override in config */ + }, + onPdfDocumentReady: () => { + /* Lifecycle hook - override in config */ + }, + onSidebarToggle: () => { + /* Lifecycle hook - override in config */ + }, + onCollaborationReady: () => { + /* Lifecycle hook - override in config */ + }, + onEditorUpdate: () => { + /* Lifecycle hook - override in config */ + }, + onCommentsListChange: () => { + /* Lifecycle hook - override in config */ + }, + onException: () => { + /* Lifecycle hook - override in config */ + }, + onListDefinitionsChange: () => { + /* Lifecycle hook - override in config */ + }, + onTransaction: () => { + /* Lifecycle hook - override in config */ + }, + disableContextMenu: false, + useLayoutEngine: true, + }; + this.superdocId = ''; + this.colors = []; + this.userColorMap = new Map(); + this.colorIndex = 0; + this.user = { ...DEFAULT_USER }; + this.socket = null; + this.isDev = false; + this.activeEditor = null; + this.comments = []; + this.readyEditors = 0; + this.isLocked = false; + this.lockedBy = null; + this.toolbar = null; + this.pendingCollaborationSaves = 0; + + // Initialize asynchronously with proper error handling + // Errors are caught and emitted as exception events rather than + // being swallowed silently by the void operator + this.#init(config).catch((error) => { + console.error('[superdoc] Initialization failed:', error); + this.emit('exception', { error: error as Error }); + }); + } + + /** + * Initialize the SuperDoc instance + * + * @param config - Configuration options + */ + async #init(config: Config): Promise { + this.config = { + ...this.config, + ...config, + }; + + const incomingUser = this.config.user; + if (!incomingUser || typeof incomingUser !== 'object') { + this.config.user = { ...DEFAULT_USER }; + } else { + this.config.user = { + ...DEFAULT_USER, + ...incomingUser, + }; + if (!this.config.user.name) { + this.config.user.name = DEFAULT_USER.name; + } + } + + // Initialize tracked changes defaults based on document mode + if (!this.config.layoutEngineOptions) { + this.config.layoutEngineOptions = {}; + } + // Only set defaults if user didn't explicitly configure tracked changes + if (!this.config.layoutEngineOptions.trackedChanges) { + // Default: ON for editing/suggesting modes, OFF for viewing mode + const isViewingMode = this.config.documentMode === 'viewing'; + this.config.layoutEngineOptions.trackedChanges = { + mode: isViewingMode ? 'final' : 'review', + enabled: !isViewingMode, + }; + } + + this.config.modules = this.config.modules || {}; + if (!Object.prototype.hasOwnProperty.call(this.config.modules, 'comments')) { + this.config.modules.comments = {}; + } + + this.config.colors = shuffleArray((this.config.colors || []) as `#${string}`[]) as string[]; + this.userColorMap = new Map(); + this.colorIndex = 0; + + this.version = __APP_VERSION__; + this.#log('🦋 [superdoc] Using SuperDoc version:', this.version); + + this.superdocId = config.superdocId || uuidv4(); + this.colors = this.config.colors || []; + + // Preprocess document + this.#initDocuments(); + + // Initialize collaboration if configured + await this.#initCollaboration(this.config.modules); + + // Apply csp nonce if provided + if (this.config.cspNonce) this.#patchNaiveUIStyles(); + + this.#initVueApp(); + this.#initListeners(); + + this.user = this.config.user; // The current user + this.users = this.config.users || []; // All users who have access to this superdoc + this.socket = null; + + this.isDev = this.config.isDev || false; + + this.activeEditor = null; + this.comments = []; + + if (!this.config.selector) { + throw new Error('SuperDoc: selector is required'); + } + + this.app.mount(this.config.selector); + + // Required editors + this.readyEditors = 0; + + this.isLocked = this.config.isLocked || false; + this.lockedBy = this.config.lockedBy || null; + + // If a toolbar element is provided, render a toolbar + this.#addToolbar(); + } + + /** + * Get the number of editors that are required for this superdoc + * + * @returns The number of required editors + */ + get requiredNumberOfEditors(): number { + return this.superdocStore.documents.filter((d) => d.type === DOCX).length; + } + + /** + * Get the current state of the SuperDoc + * + * @returns The current state containing documents and users + */ + get state(): { documents: unknown[]; users: User[] } { + return { + documents: this.superdocStore.documents, + users: this.users, + }; + } + + /** + * Get the SuperDoc container element + * + * @returns The DOM element or null if not found + */ + get element(): HTMLElement | null { + if (typeof this.config.selector === 'string') { + return document.querySelector(this.config.selector); + } + return this.config.selector; + } + + /** + * Patch Naive UI to add CSP nonce to dynamically created style elements + */ + #patchNaiveUIStyles(): void { + const cspNonce = this.config.cspNonce; + if (!cspNonce) return; + + const originalCreateElement = document.createElement; + document.createElement = function (tagName: string): HTMLElement { + const element = originalCreateElement.call(this, tagName); + if (tagName.toLowerCase() === 'style') { + element.setAttribute('nonce', cspNonce); + } + return element; + }; + } + + /** + * Initialize documents from configuration + * + * Normalizes and processes document configuration into a consistent format + */ + #initDocuments(): void { + const doc = this.config.document; + const hasDocumentConfig = !!doc && typeof doc === 'object' && Object.keys(this.config.document as object)?.length; + const hasDocumentUrl = !!doc && typeof doc === 'string' && doc.length > 0; + const hasDocumentFile = !!doc && typeof File === 'function' && doc instanceof File; + const hasDocumentBlob = !!doc && doc instanceof Blob && !(doc instanceof File); + const hasListOfDocuments = this.config.documents && this.config.documents?.length; + + if (hasDocumentConfig && hasListOfDocuments) { + console.warn('🦋 [superdoc] You can only provide one of document or documents'); + } + + if (hasDocumentConfig) { + // If an uploader-specific wrapper was passed, normalize it. + const normalized = normalizeDocumentEntry(this.config.document); + const normalizedObj = normalized && typeof normalized === 'object' ? normalized : {}; + this.config.documents = [ + { + id: uuidv4(), + type: DOCX, + ...normalizedObj, + } as Document, + ]; + } else if (hasDocumentUrl) { + this.config.documents = [ + { + id: uuidv4(), + type: DOCX, + url: this.config.document as string, + name: 'document.docx', + isNewFile: true, + }, + ]; + } else if (hasDocumentFile) { + const normalized = normalizeDocumentEntry(this.config.document); + const normalizedObj = normalized && typeof normalized === 'object' ? normalized : {}; + this.config.documents = [ + { + id: uuidv4(), + type: DOCX, + ...normalizedObj, + } as Document, + ]; + } else if (hasDocumentBlob) { + const normalized = normalizeDocumentEntry(this.config.document); + const normalizedObj = normalized && typeof normalized === 'object' ? normalized : {}; + this.config.documents = [ + { + id: uuidv4(), + type: DOCX, + ...normalizedObj, + } as Document, + ]; + } + + // Also normalize any provided documents array entries (e.g., when consumer passes uploader wrappers directly) + if (Array.isArray(this.config.documents) && this.config.documents.length > 0) { + this.config.documents = this.config.documents.map((d) => { + const normalized = normalizeDocumentEntry(d); + + if (!normalized || typeof normalized !== 'object') { + return normalized as Document; + } + + const existingId = + (typeof normalized === 'object' && 'id' in normalized && normalized.id) || + (d && typeof d === 'object' && 'id' in d && d.id); + + return { + type: DOCX, + ...normalized, + id: existingId || uuidv4(), + } as Document; + }); + } + } + + /** + * Initialize the Vue app and stores + */ + #initVueApp(): void { + const { app, pinia, superdocStore, commentsStore, highContrastModeStore } = createSuperdocVueApp(); + this.app = app; + this.pinia = pinia; + this.app.config.globalProperties.$config = this.config; + this.app.config.globalProperties.$documentMode = this.config.documentMode; + + this.app.config.globalProperties.$superdoc = this; + this.superdocStore = superdocStore; + this.commentsStore = commentsStore; + this.highContrastModeStore = highContrastModeStore; + + if (typeof this.superdocStore.setExceptionHandler === 'function') { + this.superdocStore.setExceptionHandler((payload) => this.emit('exception', payload)); + } + + this.superdocStore.init(this.config); + + const commentsModuleConfig = this.config.modules?.comments; + this.commentsStore.init(commentsModuleConfig ?? {}); + } + + /** + * Initialize event listeners + */ + #initListeners(): void { + if (this.config.onEditorBeforeCreate) + this.on('editorBeforeCreate', (data) => this.config.onEditorBeforeCreate?.(data.editor)); + if (this.config.onEditorCreate) this.on('editorCreate', (data) => this.config.onEditorCreate?.(data.editor)); + if (this.config.onEditorDestroy) this.on('editorDestroy', () => this.config.onEditorDestroy?.()); + if (this.config.onReady) this.on('ready', (data) => this.config.onReady?.(data)); + if (this.config.onCommentsUpdate) this.on('comments-update', (data) => this.config.onCommentsUpdate?.(data)); + + if (this.config.onAwarenessUpdate) this.on('awareness-update', (data) => this.config.onAwarenessUpdate?.(data)); + if (this.config.onLocked) this.on('locked', this.config.onLocked); + if (this.config.onPdfDocumentReady) this.on('pdf-document-ready', this.config.onPdfDocumentReady); + if (this.config.onSidebarToggle) this.on('sidebar-toggle', this.config.onSidebarToggle); + if (this.config.onCollaborationReady) this.on('collaboration-ready', this.config.onCollaborationReady); + if (this.config.onEditorUpdate) this.on('editor-update', this.config.onEditorUpdate); + this.on('content-error', this.onContentError.bind(this)); + if (this.config.onException) this.on('exception', this.config.onException); + if (this.config.onListDefinitionsChange) this.on('list-definitions-change', this.config.onListDefinitionsChange); + + if (this.config.onFontsResolved) { + this.on('fonts-resolved', this.config.onFontsResolved); + } + } + + /** + * Initialize collaboration if configured + * + * @param modules - Module configuration + * @returns The processed documents with collaboration enabled + */ + async #initCollaboration(modules: Modules = {}): Promise { + const { collaboration: collaborationModuleConfig, comments: commentsConfig = {} } = modules; + + if (!collaborationModuleConfig) return this.config.documents || []; + + // Flag this superdoc as collaborative + this.isCollaborative = true; + + // Start a socket for all documents and general metaMap for this SuperDoc + if (collaborationModuleConfig.providerType === 'hocuspocus' && collaborationModuleConfig.url) { + const socket = new HocuspocusProviderWebsocket({ + url: collaborationModuleConfig.url, + }); + this.config.socket = { + cancelWebsocketRetry: () => socket.cancelWebsocketRetry?.(), + disconnect: () => socket.disconnect(), + destroy: () => socket.destroy(), + }; + } + + // Initialize collaboration for documents + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const processedDocuments = makeDocumentsCollaborative(this as any); + + // Optionally, initialize separate superdoc sync - for comments, view, etc. + if (commentsConfig.useInternalExternalComments && !commentsConfig.suppressInternalExternalComments) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = initSuperdocYdoc(this as any); + if (result) { + this.ydoc = result.ydoc; + this.provider = result.provider as HocuspocusProvider; + } + } else { + this.ydoc = processedDocuments[0]?.ydoc; + this.provider = processedDocuments[0]?.provider as HocuspocusProvider | undefined; + } + + // Initialize comments sync, if enabled + // eslint-disable-next-line @typescript-eslint/no-explicit-any + initCollaborationComments(this as any); + + return processedDocuments as unknown as Document[]; + } + + /** + * Add a user to the shared users list + * + * @param user - The user to add + */ + addSharedUser(user: User): void { + if (this.users.some((u) => u.email === user.email)) return; + this.users.push(user); + } + + /** + * Remove a user from the shared users list + * + * @param email - The email of the user to remove + */ + removeSharedUser(email: string): void { + this.users = this.users.filter((u) => u.email !== email); + } + + /** + * Triggered when there is an error in the content + * + * @param params - Error parameters + * @param params.error - The error that occurred + * @param params.editor - The editor that caused the error + */ + onContentError({ error, editor }: { error: Error; editor: Editor }): void { + const { documentId } = editor.options; + const doc = this.superdocStore.documents.find((d) => d.id === documentId); + if (!doc) return; + + this.config.onContentError?.({ + error: error as object, + editor, + documentId: doc.id || '', + file: (doc.data as File | Blob) || null, + }); + } + + /** + * Triggered when the PDF document is ready + */ + broadcastPdfDocumentReady(): void { + this.emit('pdf-document-ready'); + } + + /** + * Triggered when the superdoc is ready + */ + broadcastReady(): void { + if (this.readyEditors === this.requiredNumberOfEditors) { + this.emit('ready', { superdoc: this }); + } + } + + /** + * Triggered before an editor is created + * + * @param editor - The editor that is about to be created + */ + broadcastEditorBeforeCreate(editor: Editor): void { + this.emit('editorBeforeCreate', { editor }); + } + + /** + * Triggered when an editor is created + * + * @param editor - The editor that was created + */ + broadcastEditorCreate(editor: Editor): void { + this.readyEditors++; + this.broadcastReady(); + this.emit('editorCreate', { editor }); + } + + /** + * Triggered when an editor is destroyed + */ + broadcastEditorDestroy(): void { + this.emit('editorDestroy'); + } + + /** + * Triggered when the comments sidebar is toggled + * + * @param isOpened - Whether the sidebar is opened + */ + broadcastSidebarToggle(isOpened: boolean): void { + this.emit('sidebar-toggle', isOpened); + } + + /** + * Log debug messages + * + * @param args - Arguments to log + */ + #log(...args: unknown[]): void { + (console.debug ? console.debug : console.log)('🦋 🦸‍♀️ [superdoc]', ...args); + } + + /** + * Set the active editor + * + * @param editor - The editor to set as active + */ + setActiveEditor(editor: Editor): void { + this.activeEditor = editor; + if (this.toolbar) { + this.activeEditor.toolbar = this.toolbar; + this.toolbar.setActiveEditor(editor); + } + } + + /** + * Toggle the ruler visibility for SuperEditors + */ + toggleRuler(): void { + this.config.rulers = !this.config.rulers; + this.superdocStore.documents.forEach((doc) => { + doc.rulers.value = this.config.rulers; + }); + } + + /** + * Determine whether the current configuration allows a given permission. + * + * Used by downstream consumers (toolbar, context menu, commands) to keep + * tracked-change affordances consistent with customer overrides. This method + * checks permissions against the configured role, internal/external context, + * and any custom permission resolver provided in the configuration. + * + * Common permission keys include: + * - 'RESOLVE_OWN' - Can resolve own comments + * - 'RESOLVE_ANY' - Can resolve any comment + * - 'DELETE_OWN' - Can delete own comments + * - 'DELETE_ANY' - Can delete any comment + * - 'ACCEPT_CHANGES' - Can accept tracked changes + * - 'REJECT_CHANGES' - Can reject tracked changes + * + * @param params - Permission parameters + * @param params.permission - Permission key to evaluate (e.g., 'RESOLVE_OWN', 'DELETE_ANY') + * @param params.role - Role to evaluate against (defaults to config.role). Values: 'editor', 'suggester', 'viewer' + * @param params.isInternal - Whether this is an internal context (defaults to config.isInternal) + * @param params.comment - Comment object if already resolved (optional) + * @param params.trackedChange - Tracked change metadata with id, attrs, etc. (optional) + * @returns True if the permission is allowed, false otherwise + * + * @example + * // Check if current user can resolve their own comments + * const canResolve = superdoc.canPerformPermission({ + * permission: 'RESOLVE_OWN', + * comment: myComment + * }); + * + * @example + * // Check permission for a tracked change + * const canAccept = superdoc.canPerformPermission({ + * permission: 'ACCEPT_CHANGES', + * trackedChange: { id: 'change-123' } + * }); + */ + canPerformPermission( + { + permission, + role = this.config.role, + isInternal = this.config.isInternal, + comment = null, + trackedChange = null, + }: { + permission: string; + role?: string; + isInternal?: boolean; + comment?: object | null; + trackedChange?: { commentId?: string; id?: string; comment?: object } | null; + } = { permission: '' }, + ): boolean { + if (!permission) return false; + + let resolvedComment = comment ?? trackedChange?.comment ?? null; + + const commentId = trackedChange?.commentId || trackedChange?.id; + if (!resolvedComment && commentId && this.commentsStore?.getComment) { + const storeComment = this.commentsStore.getComment(commentId); + resolvedComment = storeComment?.getValues ? storeComment.getValues() : storeComment; + } + + const context: PermissionResolverParams = { + permission, + role, + isInternal, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + superdoc: this as any, + currentUser: this.config.user, + comment: resolvedComment ?? null, + trackedChange: trackedChange ?? null, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return isAllowed(permission as any, (role || 'viewer') as any, isInternal || false, context as any); + } + + /** + * Add a toolbar to the SuperDoc + */ + #addToolbar(): void { + const moduleConfig = this.config.modules?.toolbar || {}; + this.toolbarElement = this.config.modules?.toolbar?.selector || this.config.toolbar; + this.toolbar = null; + + const config = { + selector: this.toolbarElement || null, + isDev: this.isDev || false, + toolbarGroups: this.config.modules?.toolbar?.groups || this.config.toolbarGroups, + role: this.config.role, + icons: this.config.modules?.toolbar?.icons || this.config.toolbarIcons, + texts: this.config.modules?.toolbar?.texts || this.config.toolbarTexts, + fonts: this.config.modules?.toolbar?.fonts || null, + hideButtons: this.config.modules?.toolbar?.hideButtons ?? true, + responsiveToContainer: this.config.modules?.toolbar?.responsiveToContainer ?? false, + documentMode: this.config.documentMode, + superdoc: this, + aiApiKey: this.config.modules?.ai?.apiKey, + aiEndpoint: this.config.modules?.ai?.endpoint, + ...moduleConfig, + }; + + this.toolbar = new SuperToolbar(config); + + this.toolbar.on('superdoc-command', this.onToolbarCommand.bind(this)); + this.toolbar.on('exception', this.config.onException); + this.once('editorCreate', () => this.toolbar?.updateToolbarState()); + } + + /** + * Add a comments list to the superdoc + * Requires the comments module to be enabled + * + * @param element - The DOM element to render the comments list in + */ + addCommentsList(element?: HTMLElement): void { + if (!this.config?.modules?.comments || this.config.role === 'viewer') return; + this.#log('🦋 [superdoc] Adding comments list to:', element); + + if (element && this.config.modules.comments) { + this.config.modules.comments.element = element; + } + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.commentsList = new SuperComments(this.config.modules?.comments || {}, this as any); + + if (this.config.onCommentsListChange) { + this.config.onCommentsListChange({ isRendered: true }); + } + } + + /** + * Remove the comments list from the superdoc + */ + removeCommentsList(): void { + if (this.commentsList) { + this.commentsList.close(); + this.commentsList = undefined; + if (this.config.onCommentsListChange) { + this.config.onCommentsListChange({ isRendered: false }); + } + } + } + + /** + * Toggle the custom context menu globally. + * Updates both flow editors and PresentationEditor instances so downstream listeners can short-circuit early. + * + * @param disabled - Whether to disable the context menu + */ + setDisableContextMenu(disabled = true): void { + const nextValue = Boolean(disabled); + if (this.config.disableContextMenu === nextValue) return; + this.config.disableContextMenu = nextValue; + + this.superdocStore?.documents?.forEach((doc) => { + const presentationEditor = doc.getPresentationEditor?.(); + if (presentationEditor && 'setContextMenuDisabled' in presentationEditor) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (presentationEditor as any).setContextMenuDisabled(nextValue); + } + const editor = doc.getEditor?.(); + if (editor?.setOptions) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + editor.setOptions({ disableContextMenu: nextValue } as any); + } + }); + } + + /** + * Triggered when a toolbar command is executed + * + * @param params - Command parameters + * @param params.item - The toolbar item that was clicked + * @param params.argument - The argument passed to the command + */ + onToolbarCommand({ item, argument }: { item: { command: string }; argument: string }): void { + if (item.command === 'setDocumentMode') { + this.setDocumentMode(argument as DocumentMode); + } else if (item.command === 'setZoom') { + this.superdocStore.activeZoom = parseFloat(argument); + } + } + + /** + * Set the document mode + * + * @param type - The document mode to set + */ + setDocumentMode(type: DocumentMode): void { + if (!type) return; + + const normalizedType = type.toLowerCase() as DocumentMode; + this.config.documentMode = normalizedType; + + const types: Record void> = { + viewing: () => this.#setModeViewing(), + editing: () => this.#setModeEditing(), + suggesting: () => this.#setModeSuggesting(), + }; + + if (types[normalizedType]) types[normalizedType](); + } + + /** + * Set the document mode on a document's editor (PresentationEditor or Editor). + * Tries PresentationEditor first, falls back to Editor for backward compatibility. + * + * @param doc - The document object + * @param mode - The document mode ('editing', 'viewing', 'suggesting') + */ + #applyDocumentMode( + doc: ReturnType['documents'][0], + mode: DocumentMode, + ): void { + const presentationEditor = typeof doc.getPresentationEditor === 'function' ? doc.getPresentationEditor() : null; + if (presentationEditor && 'setDocumentMode' in presentationEditor) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (presentationEditor as any).setDocumentMode(mode); + return; + } + const editor = typeof doc.getEditor === 'function' ? doc.getEditor() : null; + if (editor && 'setDocumentMode' in editor) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (editor as any).setDocumentMode(mode); + } + } + + /** + * Configure how tracked changes (suggestions) are displayed in the document. + * + * This method allows you to control the visibility and rendering mode of + * tracked changes across all document editors. It affects both the visual + * display and the underlying data handling. + * + * @param preferences - Tracked changes display preferences + * @param preferences.mode - How to render tracked changes: + * - 'review': Show all changes with markup (insertions highlighted, deletions shown) + * - 'final': Show document as if all changes were accepted + * - 'original': Show document as if all changes were rejected + * - 'off': Disable tracked changes display entirely + * @param preferences.enabled - Whether tracked changes functionality is active + * + * @example + * // Show document with all tracked changes visible (review mode) + * superdoc.setTrackedChangesPreferences({ mode: 'review', enabled: true }); + * + * @example + * // Show final document (all changes accepted) + * superdoc.setTrackedChangesPreferences({ mode: 'final', enabled: false }); + * + * @example + * // Reset to default behavior + * superdoc.setTrackedChangesPreferences(); + */ + setTrackedChangesPreferences(preferences?: { + mode?: 'review' | 'original' | 'final' | 'off'; + enabled?: boolean; + }): void { + const normalized = preferences && Object.keys(preferences).length ? { ...preferences } : undefined; + if (!this.config.layoutEngineOptions) { + this.config.layoutEngineOptions = {}; + } + this.config.layoutEngineOptions.trackedChanges = normalized; + this.superdocStore?.documents?.forEach((doc) => { + const presentationEditor = typeof doc.getPresentationEditor === 'function' ? doc.getPresentationEditor() : null; + if (presentationEditor && 'setTrackedChangesOverrides' in presentationEditor) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (presentationEditor as any).setTrackedChangesOverrides(normalized); + } + }); + } + + /** + * Set document mode to editing + */ + #setModeEditing(): void { + if (this.config.role !== 'editor') return this.#setModeSuggesting(); + if (this.superdocStore.documents.length > 0) { + const firstEditor = this.superdocStore.documents[0]?.getEditor(); + if (firstEditor) this.setActiveEditor(firstEditor); + } + + // Enable tracked changes for editing mode + this.setTrackedChangesPreferences({ mode: 'review', enabled: true }); + + this.superdocStore.documents.forEach((doc) => { + doc.restoreComments(); + this.#applyDocumentMode(doc, 'editing'); + }); + + if (this.toolbar) { + this.toolbar.documentMode = 'editing'; + this.toolbar.updateToolbarState(); + } + } + + /** + * Set document mode to suggesting + */ + #setModeSuggesting(): void { + if (!['editor', 'suggester'].includes(this.config.role || '')) return this.#setModeViewing(); + if (this.superdocStore.documents.length > 0) { + const firstEditor = this.superdocStore.documents[0]?.getEditor(); + if (firstEditor) this.setActiveEditor(firstEditor); + } + + // Enable tracked changes for suggesting mode + this.setTrackedChangesPreferences({ mode: 'review', enabled: true }); + + this.superdocStore.documents.forEach((doc) => { + doc.restoreComments(); + this.#applyDocumentMode(doc, 'suggesting'); + }); + + if (this.toolbar) { + this.toolbar.documentMode = 'suggesting'; + this.toolbar.updateToolbarState(); + } + } + + /** + * Set document mode to viewing + */ + #setModeViewing(): void { + if (this.toolbar) { + this.toolbar.activeEditor = null; + } + + // Disable tracked changes for viewing mode (show final document) + this.setTrackedChangesPreferences({ mode: 'final', enabled: false }); + + this.superdocStore.documents.forEach((doc) => { + doc.removeComments(); + this.#applyDocumentMode(doc, 'viewing'); + }); + + if (this.toolbar) { + this.toolbar.documentMode = 'viewing'; + this.toolbar.updateToolbarState(); + } + } + + /** + * Search for text or regex in the active editor + * + * @param text - The text or regex to search for + * @returns The search results + */ + search(text: string | RegExp): unknown[] { + return this.activeEditor?.commands.search(text) || []; + } + + /** + * Go to the next search result + * + * @param match - The match object + */ + goToSearchResult(match: unknown): void { + this.activeEditor?.commands.goToSearchResult(match); + } + + /** + * Set the document to locked or unlocked + * + * @param lock - Whether to lock the document + */ + setLocked(lock = true): void { + this.config.documents?.forEach((doc) => { + if (!doc.ydoc) return; + const metaMap = doc.ydoc.getMap('meta'); + doc.ydoc.transact(() => { + metaMap.set('locked', lock); + metaMap.set('lockedBy', this.user); + }); + }); + } + + /** + * Get the HTML content of all editors + * + * @param options - Export options + * @returns The HTML content of all editors + */ + getHTML(options: object = {}): string[] { + const editors: Editor[] = []; + this.superdocStore.documents.forEach((doc) => { + const editor = doc.getEditor(); + if (editor) { + editors.push(editor); + } + }); + + return editors.map((editor) => editor.getHTML(options)); + } + + /** + * Lock the current superdoc + * + * @param isLocked - Whether the superdoc is locked + * @param lockedBy - The user who locked the superdoc + */ + lockSuperdoc(isLocked = false, lockedBy?: User): void { + this.isLocked = isLocked; + this.lockedBy = lockedBy || null; + this.#log('🦋 [superdoc] Locking superdoc:', isLocked, lockedBy, '\n\n\n'); + this.emit('locked', { isLocked, lockedBy: this.lockedBy }); + } + + /** + * Export the superdoc to a file + * + * Exports the document to the specified format(s). Supports DOCX export + * with optional comment handling. Multiple files are automatically zipped. + * + * @param params - Export configuration + * @param params.exportType - Array of export formats (currently only 'docx' supported) + * @param params.commentsType - How to handle comments: 'external' (include) or 'clean' (exclude) + * @param params.exportedName - Custom filename (without extension) + * @param params.additionalFiles - Additional blobs to include in the export + * @param params.additionalFileNames - Names for the additional files + * @param params.isFinalDoc - Whether to export as final document (accepting all changes) + * @param params.triggerDownload - Whether to trigger browser download (default: true) + * @param params.fieldsHighlightColor - Color for field highlights in exported document + * @returns A promise that resolves with the exported Blob, or void if triggerDownload is true + * @throws {Error} If exportType contains invalid values + * + * @example + * // Export and download as DOCX + * await superdoc.export({ exportType: ['docx'] }); + * + * @example + * // Get blob without downloading + * const blob = await superdoc.export({ triggerDownload: false }); + */ + async export({ + exportType = ['docx'], + commentsType = 'external', + exportedName, + additionalFiles = [], + additionalFileNames = [], + isFinalDoc = false, + triggerDownload = true, + fieldsHighlightColor, + }: ExportParams & { + additionalFiles?: Blob[]; + additionalFileNames?: string[]; + isFinalDoc?: boolean; + } = {}): Promise { + // Input validation for export types + const validExportTypes: ExportType[] = ['docx', 'pdf', 'html']; + const hasValidExportType = exportType.some((t) => validExportTypes.includes(t as ExportType)); + const hasAdditionalAssets = additionalFiles.length > 0 || additionalFileNames.length > 0; + const invalidTypes = exportType.filter((t) => !validExportTypes.includes(t as ExportType)); + + // Only throw when there are no supported export types AND no additional assets to include. + // This keeps backward compatibility for callers that bundle extra assets (e.g., txt) alongside docx. + if (!hasValidExportType && !hasAdditionalAssets && invalidTypes.length > 0) { + throw new Error( + `Invalid export types: ${invalidTypes.join(', ')}. Valid types are: ${validExportTypes.join(', ')}`, + ); + } + + // Input validation for comments type + const validCommentsTypes: CommentsType[] = ['external', 'clean', 'all']; + if (commentsType && !validCommentsTypes.includes(commentsType)) { + this.#log('🦋 [superdoc] Unrecognized commentsType, defaulting to external:', commentsType); + commentsType = 'external'; + } + + // Get the docx files first + const baseFileName = exportedName ? cleanName(exportedName) : cleanName(this.config.title || 'SuperDoc'); + const docxFiles = await this.exportEditorsToDOCX({ commentsType, isFinalDoc, fieldsHighlightColor }); + const blobsToZip = [...additionalFiles]; + const filenames = [...additionalFileNames]; + + // If we are exporting docx files, add them to the zip + if (exportType.includes('docx')) { + docxFiles.forEach((blob) => { + blobsToZip.push(blob); + filenames.push(`${baseFileName}.docx`); + }); + } + + // If we only have one blob, just download it. Otherwise, zip them up. + if (blobsToZip.length === 1) { + if (triggerDownload) { + createDownload(blobsToZip[0], baseFileName, exportType[0]); + return; + } + + return blobsToZip[0]; + } + + const zip = await createZip(blobsToZip, filenames); + + if (triggerDownload) { + createDownload(zip, baseFileName, 'zip'); + return; + } + + return zip; + } + + /** + * Export editors to DOCX format + * + * @param options - Export options + * @returns A promise that resolves with an array of DOCX blobs + */ + async exportEditorsToDOCX({ + commentsType, + isFinalDoc, + fieldsHighlightColor, + }: { + commentsType?: CommentsType; + isFinalDoc?: boolean; + fieldsHighlightColor?: string | null; + } = {}): Promise { + const comments: unknown[] = []; + if (commentsType !== 'clean') { + if (this.commentsStore && typeof this.commentsStore.translateCommentsForExport === 'function') { + comments.push(...this.commentsStore.translateCommentsForExport()); + } + } + + const docxPromises = this.superdocStore.documents.map(async (doc) => { + if (!doc || doc.type !== DOCX) return null; + + const editor = typeof doc.getEditor === 'function' ? doc.getEditor() : null; + const fallbackDocx = (): Blob | null => { + if (!doc.data) return null; + if ('type' in doc.data && doc.data.type !== DOCX) return null; + return doc.data as Blob; + }; + + if (!editor) return fallbackDocx(); + + try { + const exported = await editor.exportDocx({ + isFinalDoc, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + comments: comments as any, + commentsType, + fieldsHighlightColor: fieldsHighlightColor || null, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + if (exported) return exported; + } catch (error) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.emit('exception', { error: error as Error, document: doc as any }); + } + + return fallbackDocx(); + }); + + const docxFiles = await Promise.all(docxPromises); + return docxFiles.filter((blob: Blob | null): blob is Blob => Boolean(blob)); + } + + /** + * Request an immediate save from all collaboration documents + * + * Triggers the collaboration provider to save immediately by setting + * 'immediate-save' on the Yjs metaMap. Resolves when all documents + * have finished saving. + * + * @returns A promise that resolves when all documents have saved + * @throws {Error} If save times out after 30 seconds + */ + async #triggerCollaborationSaves(): Promise { + this.#log('🦋 [superdoc] Triggering collaboration saves'); + + return new Promise((resolve, reject) => { + // Filter documents that have ydoc for collaboration saves + const docsWithYdoc = this.superdocStore.documents.filter((d) => d.ydoc.value); + + // If no collaborative documents, resolve immediately + if (docsWithYdoc.length === 0) { + this.#log('🦋 [superdoc] No collaborative documents to save'); + resolve(); + return; + } + + // Reset counter ONCE before the loop, not inside it + this.pendingCollaborationSaves = docsWithYdoc.length; + this.#log(`🦋 [superdoc] Waiting for ${this.pendingCollaborationSaves} documents to save`); + + // Track observers for cleanup + const observers: Array<{ + metaMap: ReturnType['getMap']>; + handler: (event: unknown) => void; + }> = []; + + // Set up timeout to prevent hanging forever + const timeoutId = setTimeout(() => { + // Clean up observers + observers.forEach(({ metaMap, handler }) => { + metaMap.unobserve(handler); + }); + reject(new Error('Collaboration save timed out after 30 seconds')); + }, 30000); + + docsWithYdoc.forEach((doc, index) => { + const metaMap = doc.ydoc.value!.getMap('meta'); + + const saveHandler = (event: unknown): void => { + const e = event as { changes: { keys: Map } }; + if (e.changes.keys.has('immediate-save-finished')) { + this.pendingCollaborationSaves--; + this.#log(`🦋 [superdoc] Doc ${index} saved, ${this.pendingCollaborationSaves} remaining`); + + if (this.pendingCollaborationSaves <= 0) { + // Clean up timeout and observers + clearTimeout(timeoutId); + observers.forEach(({ metaMap: m, handler }) => { + m.unobserve(handler); + }); + resolve(); + } + } + }; + + observers.push({ metaMap, handler: saveHandler }); + metaMap.observe(saveHandler); + metaMap.set('immediate-save', 'true'); + }); + }); + } + + /** + * Save the superdoc if in collaboration mode + * + * @returns A promise that resolves when all documents have saved + */ + async save(): Promise { + const savePromises = [ + this.#triggerCollaborationSaves(), + // this.exportEditorsToDOCX(), + ]; + + this.#log('🦋 [superdoc] Saving superdoc'); + const result = await Promise.all(savePromises); + this.#log('🦋 [superdoc] Save complete:', result); + return result; + } + + /** + * Destroy the superdoc instance + * + * Cleans up all resources, disconnects collaboration providers, + * and unmounts the Vue application. Errors during cleanup are + * caught and emitted as exception events to prevent partial cleanup. + * + * @throws {Error} Never throws - errors are emitted as exception events + */ + destroy(): void { + if (!this.app) { + return; + } + + this.#log('[superdoc] Unmounting app'); + + // Cleanup socket connection with error handling + try { + if (this.config.socket) { + this.config.socket.cancelWebsocketRetry?.(); + this.config.socket.disconnect?.(); + this.config.socket.destroy?.(); + } + } catch (error) { + this.#log('[superdoc] Error cleaning up socket:', error); + this.emit('exception', { error: error as Error }); + } + + // Cleanup main ydoc and provider with error handling + try { + if (this.provider) { + this.provider.disconnect(); + this.provider.destroy(); + } + if (this.ydoc) { + this.ydoc.destroy(); + } + } catch (error) { + this.#log('[superdoc] Error cleaning up main provider/ydoc:', error); + this.emit('exception', { error: error as Error }); + } + + // Cleanup document-level providers and ydocs with error handling + if (this.config.documents) { + this.config.documents.forEach((doc, index) => { + try { + if (doc.provider) { + doc.provider.disconnect(); + doc.provider.destroy(); + } + if (doc.ydoc) { + doc.ydoc.destroy(); + } + } catch (error) { + this.#log(`[superdoc] Error cleaning up document ${index}:`, error); + this.emit('exception', { error: error as Error, document: doc }); + } + }); + } + + // Reset store with error handling + try { + this.superdocStore.reset(); + } catch (error) { + this.#log('[superdoc] Error resetting store:', error); + this.emit('exception', { error: error as Error }); + } + + // Unmount Vue app and clean up listeners + try { + this.app.unmount(); + this.removeAllListeners(); + delete this.app.config.globalProperties.$config; + delete this.app.config.globalProperties.$superdoc; + } catch (error) { + this.#log('[superdoc] Error unmounting app:', error); + // Don't emit here since listeners are being removed + } + } + + /** + * Focus the active editor or the first editor in the superdoc + */ + focus(): void { + if (this.activeEditor) { + this.activeEditor.focus(); + } else { + this.superdocStore.documents.find((doc) => { + const editor = doc.getEditor(); + if (editor) { + editor.focus(); + return true; + } + return false; + }); + } + } + + /** + * Set the high contrast mode + * + * @param isHighContrast - Whether to enable high contrast mode + */ + setHighContrastMode(isHighContrast: boolean): void { + if (!this.activeEditor) return; + this.activeEditor.setHighContrastMode(isHighContrast); + this.highContrastModeStore.setHighContrastMode(isHighContrast); + } + + /** + * Capture layout pipeline events from PresentationEditor + * Forwards metrics and errors to host callbacks + * + * @param payload - Event payload from PresentationEditor.onTelemetry + * @param payload.type - Event type: 'layout' or 'error' + * @param payload.data - Event data (metrics for layout, error details for error) + */ + captureLayoutPipelineEvent(payload: { type: string; data: Record }): void { + // Emit as an event so hosts can listen + this.emit('layout-pipeline', payload); + + // Call the host callback if provided in config + if (typeof this.config.onLayoutPipelineEvent === 'function') { + this.config.onLayoutPipelineEvent(payload); + } + } +} diff --git a/packages/superdoc/src/core/SuperDoc.types.ts b/packages/superdoc/src/core/SuperDoc.types.ts new file mode 100644 index 000000000..1c98de2cc --- /dev/null +++ b/packages/superdoc/src/core/SuperDoc.types.ts @@ -0,0 +1,113 @@ +/** + * Type definitions for SuperDoc class + * + * This module contains all type definitions specific to the SuperDoc class, + * including event types, awareness state, and internal interfaces. + */ + +import type { User, Document, Editor } from './types'; +import type { SuperDoc } from './SuperDoc'; + +/** + * Awareness state for a connected user in collaboration mode. + * Represents the real-time presence information of a user. + */ +export interface AwarenessState { + /** User information */ + user?: User; + /** Client ID from Yjs */ + clientId?: number; + /** Cursor position in the document */ + cursor?: { anchor: number; head: number } | null; + /** Additional awareness fields (e.g., selection, name, color) */ + [key: string]: unknown; +} + +/** + * Comment update event data structure. + * Contains information about comment changes for the 'comments-update' event. + */ +export interface CommentsUpdateData { + /** Comment values */ + comment?: { + commentId: string; + fileId?: string; + [key: string]: unknown; + }; + /** Array of changes for update events */ + changes?: Array<{ + key: string; + value?: unknown; + previousValue?: unknown; + commentId?: string; + fileId?: string; + }>; +} + +/** + * ProseMirror transaction (simplified interface). + * Represents a transaction from the underlying ProseMirror editor. + */ +export interface PMTransaction { + /** Transaction steps */ + steps: unknown[]; + /** Transaction metadata */ + meta: Record; +} + +/** + * SuperDoc event map for EventEmitter type safety. + * Defines all events emitted by SuperDoc with their payload types. + * + * Note: Events that reference `SuperDoc` use a generic type parameter to avoid + * circular imports. The actual SuperDoc class passes itself when extending EventEmitter. + * + * @example + * ```typescript + * superdoc.on('ready', ({ superdoc }) => { + * console.log('SuperDoc is ready!'); + * }); + * + * superdoc.on('comments-update', ({ type, data }) => { + * if (type === 'add') { + * console.log('New comment:', data.comment); + * } + * }); + * ``` + */ +export interface SuperDocEvents { + /** Fired before an editor instance is created */ + editorBeforeCreate: [{ editor: Editor }]; + /** Fired after an editor instance is created */ + editorCreate: [{ editor: Editor }]; + /** Fired when an editor instance is destroyed */ + editorDestroy: []; + /** Fired when SuperDoc is fully initialized and ready */ + ready: [{ superdoc: SuperDoc }]; + /** Fired when comments are added, updated, deleted, or resolved */ + 'comments-update': [{ type: string; data: CommentsUpdateData }]; + /** Fired when awareness state changes (user cursors, selections) */ + 'awareness-update': [{ context: SuperDoc; states: AwarenessState[] }]; + /** Fired when document lock state changes */ + locked: [{ isLocked: boolean; lockedBy: User | null }]; + /** Fired when a PDF document is fully loaded and ready */ + 'pdf-document-ready': []; + /** Fired when the comments sidebar is toggled */ + 'sidebar-toggle': [boolean]; + /** Fired when collaboration is established for an editor */ + 'collaboration-ready': [{ editor: Editor }]; + /** Fired when editor content is updated */ + 'editor-update': [{ editor: Editor }]; + /** Fired when there's an error loading or processing content */ + 'content-error': [{ error: Error; editor: Editor; documentId: string; file: File | Blob | null }]; + /** Fired when an exception occurs */ + exception: [{ error: Error; document?: Document }]; + /** Fired when list definitions change in the document */ + 'list-definitions-change': [{ definitions: Record }]; + /** Fired when fonts are resolved and loaded */ + 'fonts-resolved': [{ fonts: string[] }]; + /** Fired for layout pipeline telemetry events */ + 'layout-pipeline': [{ type: string; data: Record }]; + /** Fired on editor transactions with timing information */ + transaction: [{ editor: Editor; transaction: PMTransaction; duration: number }]; +} diff --git a/packages/superdoc/src/core/collaboration/collaboration-comments.js b/packages/superdoc/src/core/collaboration/collaboration-comments.js deleted file mode 100644 index 08b359e2a..000000000 --- a/packages/superdoc/src/core/collaboration/collaboration-comments.js +++ /dev/null @@ -1,46 +0,0 @@ -import { Map as YMap } from 'yjs'; - -export const addYComment = (yArray, ydoc, event) => { - const { comment } = event; - const yComment = new YMap(Object.entries(comment)); - - ydoc.transact( - () => { - yArray.push([yComment]); - }, - { user: superdoc.user }, - ); -}; - -export const updateYComment = (yArray, ydoc, event) => { - const { comment } = event; - const yComment = new YMap(Object.entries(comment)); - const commentIndex = getCommentIndex(yArray, comment); - if (commentIndex === -1) return; - - ydoc.transact( - () => { - yArray.delete(commentIndex, 1); - yArray.insert(commentIndex, [yComment]); - }, - { user: superdoc.user }, - ); -}; - -export const deleteYComment = (yArray, ydoc, event) => { - const { comment } = event; - const commentIndex = getCommentIndex(yArray, comment); - if (commentIndex === -1) return; - - ydoc.transact( - () => { - yArray.delete(commentIndex, 1); - }, - { user: superdoc.user }, - ); -}; - -export const getCommentIndex = (yArray, comment) => { - const baseArray = yArray.toJSON(); - return baseArray.findIndex((c) => c.commentId === comment.commentId); -}; diff --git a/packages/superdoc/src/core/collaboration/collaboration-comments.ts b/packages/superdoc/src/core/collaboration/collaboration-comments.ts new file mode 100644 index 000000000..2ae95d4c0 --- /dev/null +++ b/packages/superdoc/src/core/collaboration/collaboration-comments.ts @@ -0,0 +1,112 @@ +import { Map as YMap, Array as YArray, Doc as YDoc } from 'yjs'; +import type { SuperDoc } from '../types/index'; + +/** + * Comment object structure + */ +export interface Comment { + commentId: string; + [key: string]: unknown; +} + +/** + * Event object containing a comment + */ +export interface CommentEvent { + comment: Comment; +} + +/** + * Get the index of a comment in the YArray + * + * @param yArray - The Yjs array containing comments + * @param comment - The comment to find + * @returns The index of the comment, or -1 if not found + */ +export const getCommentIndex = (yArray: YArray>, comment: Comment): number => { + const baseArray = yArray.toJSON(); + return baseArray.findIndex((c) => { + // Type assertion needed because toJSON() returns unknown[] + const commentData = c as { commentId?: string }; + return commentData.commentId === comment.commentId; + }); +}; + +/** + * Add a new comment to the Yjs document + * + * @param yArray - The Yjs array to add the comment to + * @param ydoc - The Yjs document + * @param event - The event containing the comment data + * @param superdoc - The SuperDoc instance for user context + */ +export const addYComment = ( + yArray: YArray>, + ydoc: YDoc, + event: CommentEvent, + superdoc: SuperDoc, +): void => { + const { comment } = event; + const yComment = new YMap(Object.entries(comment)); + + ydoc.transact( + () => { + yArray.push([yComment]); + }, + { user: superdoc.user }, + ); +}; + +/** + * Update an existing comment in the Yjs document + * + * @param yArray - The Yjs array containing the comment + * @param ydoc - The Yjs document + * @param event - The event containing the updated comment data + * @param superdoc - The SuperDoc instance for user context + */ +export const updateYComment = ( + yArray: YArray>, + ydoc: YDoc, + event: CommentEvent, + superdoc: SuperDoc, +): void => { + const { comment } = event; + const yComment = new YMap(Object.entries(comment)); + const commentIndex = getCommentIndex(yArray, comment); + if (commentIndex === -1) return; + + ydoc.transact( + () => { + yArray.delete(commentIndex, 1); + yArray.insert(commentIndex, [yComment]); + }, + { user: superdoc.user }, + ); +}; + +/** + * Delete a comment from the Yjs document + * + * @param yArray - The Yjs array containing the comment + * @param ydoc - The Yjs document + * @param event - The event containing the comment to delete + * @param superdoc - The SuperDoc instance for user context + */ +export const deleteYComment = ( + yArray: YArray>, + ydoc: YDoc, + event: CommentEvent, + superdoc: SuperDoc, +): void => { + const { comment } = event; + const commentIndex = getCommentIndex(yArray, comment); + if (commentIndex === -1) return; + + ydoc.transact( + () => { + yArray.delete(commentIndex, 1); + }, + { user: superdoc.user }, + ); +}; diff --git a/packages/superdoc/src/core/collaboration/collaboration.js b/packages/superdoc/src/core/collaboration/collaboration.js deleted file mode 100644 index 82199d444..000000000 --- a/packages/superdoc/src/core/collaboration/collaboration.js +++ /dev/null @@ -1,133 +0,0 @@ -import { WebsocketProvider } from 'y-websocket'; -import { HocuspocusProvider } from '@hocuspocus/provider'; -import { awarenessStatesToArray } from '@superdoc/common/collaboration/awareness'; -import { Doc as YDoc } from 'yjs'; - -/** - * Translate awareness states to an array of users. This will cause superdoc (context) to - * emit an awareness-update event with the list of users. - * - * @param {Object} context The superdoc instance - * @param {Object} param - * @param {Object} param.changes The changes in awareness states - * @param {Object} param.states The current awareness states - * @returns {void} - */ -function awarenessHandler(context, { changes = {}, states }) { - // Context is the superdoc instance - // Since co-presence is handled outside of superdoc, - // we need to emit an awareness-update event - - const { added = [], removed = [] } = changes; - const awarenessArray = awarenessStatesToArray(context, states); - - const payload = { - states: awarenessArray, - added, - removed, - superdoc: context, - }; - - context.emit('awareness-update', payload); -} - -/** - * Main function to create a provider for collaboration. - * Currently only hocuspocus is actually supported. - * - * @param {Object} param The config object - * @param {Object} param.config The configuration object - * @param {Object} param.ydoc The Yjs document - * @param {Object} param.user The user object - * @param {string} param.documentId The document ID - * @returns {Object} The provider and socket - */ -function createProvider({ config, user, documentId, socket, superdocInstance }) { - if (!config.providerType) config.providerType = 'superdoc'; - - const providers = { - hocuspocus: () => createHocuspocusProvider({ config, user, documentId, socket, superdocInstance }), - superdoc: () => createSuperDocProvider({ config, user, documentId, socket, superdocInstance }), - }; - if (!providers) throw new Error(`Provider type ${config.providerType} is not supported.`); - - return providers[config.providerType](); -} - -/** - * - * @param {Object} param The config object - * @param {Object} param.config The configuration object - * @param {Object} param.ydoc The Yjs document - * @param {Object} param.user The user object - * @param {string} param.documentId The document ID - * @returns {Object} The provider and socket - */ -function createSuperDocProvider({ config, user, documentId, superdocInstance }) { - const ydoc = new YDoc({ gc: false }); - const options = { - params: { - ...config.params, - }, - }; - - const provider = new WebsocketProvider(config.url, documentId, ydoc, options); - provider.awareness.setLocalStateField('user', user); - provider.awareness.on('update', (changes = {}) => { - return awarenessHandler(superdocInstance, { changes, states: provider.awareness.getStates() }); - }); - return { provider, ydoc }; -} - -/** - * - * @param {Object} param The config object - * @param {Object} param.config The configuration object - * @param {Object} param.ydoc The Yjs document - * @param {Object} param.user The user object - * @param {string} param.documentId The document ID - * @returns {Object} The provider and socket - */ -function createHocuspocusProvider({ config, user, documentId, socket, superdocInstance }) { - const ydoc = new YDoc({ gc: false }); - const options = { - websocketProvider: socket, - document: ydoc, - name: documentId, - token: config.token || '', - preserveConnection: false, - onAuthenticationFailed: () => onAuthenticationFailed(documentId), - onConnect: () => onConnect(superdocInstance, documentId), - onDisconnect: () => onDisconnect(superdocInstance, documentId), - onDestroy: () => onDestroy(superdocInstance, documentId), - }; - - const provider = new HocuspocusProvider(options); - provider.setAwarenessField('user', user); - - provider.on('awarenessUpdate', (params) => { - return awarenessHandler(superdocInstance, { - states: params.states, - }); - }); - - return { provider, ydoc }; -} - -const onAuthenticationFailed = (data, documentId) => { - console.warn('🔒 [superdoc] Authentication failed', data, 'document', documentId); -}; - -const onConnect = (superdocInstance, documentId) => { - console.warn('🔌 [superdoc] Connected -- ', documentId); -}; - -const onDisconnect = (superdocInstance, documentId) => { - console.warn('🔌 [superdoc] Disconnected', documentId); -}; - -const onDestroy = (superdocInstance, documentId) => { - console.warn('🔌 [superdoc] Destroyed', documentId); -}; - -export { createProvider }; diff --git a/packages/superdoc/src/core/collaboration/collaboration.test.js b/packages/superdoc/src/core/collaboration/collaboration.test.ts similarity index 53% rename from packages/superdoc/src/core/collaboration/collaboration.test.js rename to packages/superdoc/src/core/collaboration/collaboration.test.ts index 4e58b8e6b..75a2e10fc 100644 --- a/packages/superdoc/src/core/collaboration/collaboration.test.js +++ b/packages/superdoc/src/core/collaboration/collaboration.test.ts @@ -1,42 +1,115 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from 'vitest'; -import * as collaborationModule from './collaboration.js'; +import * as collaborationModule from './collaboration'; import { initCollaborationComments, initSuperdocYdoc, makeDocumentsCollaborative, syncCommentsToClients, -} from './helpers.js'; -import * as commentsModule from './collaboration-comments.js'; +} from './helpers'; +import * as commentsModule from './collaboration-comments'; const { addYComment, updateYComment, deleteYComment, getCommentIndex } = commentsModule; -import { SuperDoc } from '../SuperDoc.js'; -import { PERMISSIONS, isAllowed } from './permissions.js'; -import * as permissionsModule from './permissions.js'; +import { SuperDoc } from '../SuperDoc'; +import { PERMISSIONS, isAllowed } from './permissions'; +import * as permissionsModule from './permissions'; + +const awarenessStatesToArrayMock = vi.hoisted(() => vi.fn(() => [{ name: 'Remote User' }])); + +const { + MockWebsocketProvider, + MockHocuspocusProvider, + MockYMap, + MockYArray, + MockYDoc, + websocketInstances, + hocuspocusInstances, +} = vi.hoisted(() => { + class MockYMap extends Map { + toJSON(): Record { + return Object.fromEntries(this); + } + } -var awarenessStatesToArrayMock; + class MockYArray { + items: unknown[]; + _observers: Set<(event: unknown) => void>; -var MockYMap; -var MockYArray; -var MockYDoc; -var MockWebsocketProvider; -var MockHocuspocusProvider; -var websocketInstances = []; -var hocuspocusInstances = []; + constructor() { + this.items = []; + this._observers = new Set(); + } -vi.mock('@superdoc/common/collaboration/awareness', () => { - awarenessStatesToArrayMock = vi.fn(() => [{ name: 'Remote User' }]); - return { awarenessStatesToArray: awarenessStatesToArrayMock }; -}); + push(nodes: unknown[]): void { + this.items.push(...nodes); + } -vi.mock('y-websocket', () => { - MockWebsocketProvider = class { - constructor(url, name, ydoc, options) { + delete(index: number, count: number): void { + this.items.splice(index, count); + } + + insert(index: number, nodes: unknown[]): void { + this.items.splice(index, 0, ...nodes); + } + + toJSON(): unknown[] { + return this.items.map((item) => + (item as { toJSON?: () => unknown })?.toJSON ? (item as { toJSON: () => unknown }).toJSON() : item, + ); + } + + observe(handler: (event: unknown) => void): void { + this._observers.add(handler); + } + + emit(event: unknown): void { + for (const handler of this._observers) handler(event); + } + } + + class MockYDoc { + _arrays: Map>; + _lastMeta: unknown; + + constructor() { + this._arrays = new Map(); + this._lastMeta = null; + } + + getArray(name: string): InstanceType { + if (!this._arrays.has(name)) { + this._arrays.set(name, new MockYArray()); + } + return this._arrays.get(name)!; + } + + transact(fn: () => void, meta?: unknown): void { + this._lastMeta = meta; + fn(); + } + } + + const websocketInstances: MockWebsocketProvider[] = []; + + class MockWebsocketProvider { + url: string; + name: string; + ydoc: unknown; + options: unknown; + awareness: { + setLocalStateField: ReturnType; + on: ReturnType; + getStates: ReturnType; + }; + _states?: Map; + _awarenessHandler?: (changes: unknown) => void; + + constructor(url: string, name: string, ydoc: unknown, options?: unknown) { this.url = url; this.name = name; this.ydoc = ydoc; this.options = options; this.awareness = { setLocalStateField: vi.fn(), - on: vi.fn((event, handler) => { + on: vi.fn((event: string, handler: (changes: unknown) => void) => { if (event === 'update') this._awarenessHandler = handler; }), getStates: vi.fn(() => this._states || new Map()), @@ -44,115 +117,95 @@ vi.mock('y-websocket', () => { websocketInstances.push(this); } - emitAwareness(changes, states = new Map()) { + emitAwareness(changes: unknown, states = new Map()): void { this._states = states; this._awarenessHandler?.(changes); } - }; + } - return { - WebsocketProvider: vi.fn((...args) => new MockWebsocketProvider(...args)), - }; -}); + const hocuspocusInstances: MockHocuspocusProvider[] = []; -vi.mock('@hocuspocus/provider', () => { - MockHocuspocusProvider = class { - constructor(options) { - this.options = options; + class MockHocuspocusProvider { + options: Record; + _handlers: Record void>; + _awarenessField?: { field: string; value: unknown }; + + constructor(options: unknown) { + this.options = options as Record; this._handlers = {}; hocuspocusInstances.push(this); } - setAwarenessField(field, value) { + setAwarenessField(field: string, value: unknown): void { this._awarenessField = { field, value }; } - on(event, handler) { + on(event: string, handler: (payload: unknown) => void): void { this._handlers[event] = handler; } - emit(event, payload) { + emit(event: string, payload: unknown): void { this._handlers[event]?.(payload); } - }; + } return { - HocuspocusProvider: vi.fn((options) => new MockHocuspocusProvider(options)), + MockWebsocketProvider, + MockHocuspocusProvider, + MockYMap, + MockYArray, + MockYDoc, + websocketInstances, + hocuspocusInstances, }; }); -vi.mock('yjs', () => { - MockYMap = class extends Map { - toJSON() { - return Object.fromEntries(this); - } - }; - - MockYArray = class { - constructor() { - this.items = []; - this._observers = new Set(); - } - - push(nodes) { - this.items.push(...nodes); - } - - delete(index, count) { - this.items.splice(index, count); - } - - insert(index, nodes) { - this.items.splice(index, 0, ...nodes); - } - - toJSON() { - return this.items.map((item) => (item?.toJSON ? item.toJSON() : item)); - } - - observe(handler) { - this._observers.add(handler); - } +vi.mock('@superdoc/common/collaboration/awareness', () => { + return { awarenessStatesToArray: awarenessStatesToArrayMock }; +}); - emit(event) { - for (const handler of this._observers) handler(event); - } +vi.mock('y-websocket', () => { + return { + WebsocketProvider: vi.fn((...args: [string, string, unknown, unknown?]) => new MockWebsocketProvider(...args)), }; +}); - MockYDoc = class { - constructor() { - this._arrays = new Map(); - this._lastMeta = null; - } - - getArray(name) { - if (!this._arrays.has(name)) { - this._arrays.set(name, new MockYArray()); - } - return this._arrays.get(name); - } - - transact(fn, meta) { - this._lastMeta = meta; - fn(); - } +vi.mock('@hocuspocus/provider', () => { + return { + HocuspocusProvider: vi.fn((options: unknown) => new MockHocuspocusProvider(options)), }; +}); +vi.mock('yjs', () => { return { Doc: MockYDoc, Map: MockYMap, }; }); -var useCommentMock; -vi.mock('../../components/CommentsLayer/use-comment', () => { - useCommentMock = vi.fn((comment) => ({ normalized: comment.commentId })); - return { default: useCommentMock }; -}); +const useCommentMock = vi.hoisted(() => + vi.fn((comment: { commentId?: string; selection?: unknown } = {}) => { + const selection = comment.selection || { source: 'mock', selectionBounds: {} }; + return { + ...comment, + commentId: comment.commentId ?? 'mock-id', + selection, + isInternal: (comment as { isInternal?: boolean }).isInternal ?? true, + getValues: () => ({ ...comment, commentId: comment.commentId ?? 'mock-id', selection }), + setText: vi.fn(), + }; + }), +); + +vi.mock('../../components/CommentsLayer/use-comment', () => ({ + default: useCommentMock, +})); beforeAll(() => { - globalThis.superdoc = { user: { name: 'Global User', email: 'global@example.com' } }; - globalThis.__IS_DEBUG__ = false; + (globalThis as { superdoc: { user: { name: string; email: string } } }).superdoc = { + user: { name: 'Global User', email: 'global@example.com' }, + }; + (globalThis as { __IS_DEBUG__: boolean }).__IS_DEBUG__ = false; }); beforeEach(() => { @@ -183,7 +236,7 @@ describe('collaboration.createProvider', () => { const states = new Map([[1, { user: { name: 'Other' } }]]); awarenessStatesToArrayMock.mockReturnValueOnce([{ name: 'Other' }]); - result.provider.emitAwareness({ added: [1], removed: [] }, states); + (result.provider as InstanceType).emitAwareness({ added: [1], removed: [] }, states); expect(context.emit).toHaveBeenCalledWith( 'awareness-update', @@ -205,13 +258,18 @@ describe('collaboration.createProvider', () => { }); expect(provider).toBeInstanceOf(MockHocuspocusProvider); - expect(provider._awarenessField).toEqual({ field: 'user', value: user }); + expect((provider as InstanceType)._awarenessField).toEqual({ + field: 'user', + value: user, + }); - provider.options.onConnect(); - provider.options.onDisconnect(); - provider.options.onDestroy(); - provider.options.onAuthenticationFailed('bad-token'); - provider.emit('awarenessUpdate', { states: new Map([[2, { user: user }]]) }); + (provider as InstanceType).options.onConnect?.(); + (provider as InstanceType).options.onDisconnect?.(); + (provider as InstanceType).options.onDestroy?.(); + (provider as InstanceType).options.onAuthenticationFailed?.('bad-token'); + (provider as InstanceType).emit('awarenessUpdate', { + states: new Map([[2, { user }]]), + }); expect(awarenessStatesToArrayMock).toHaveBeenCalled(); expect(context.emit).toHaveBeenCalledWith( 'awareness-update', @@ -223,8 +281,8 @@ describe('collaboration.createProvider', () => { }); describe('collaboration helpers', () => { - let superdoc; - let commentsArray; + let superdoc: Record; + let commentsArray: InstanceType; beforeEach(() => { const ydoc = new MockYDoc(); @@ -261,16 +319,23 @@ describe('collaboration helpers', () => { }); it('initCollaborationComments wires provider sync and deduplicates updates', () => { - initCollaborationComments(superdoc); + initCollaborationComments(superdoc as never); - expect(superdoc.provider.on).toHaveBeenCalledWith('synced', expect.any(Function)); + expect((superdoc.provider as { on: ReturnType }).on).toHaveBeenCalledWith( + 'synced', + expect.any(Function), + ); expect(commentsArray._observers.size).toBe(1); // Trigger synced event - const syncedHandler = superdoc.provider.on.mock.calls[0][1]; + const syncedHandler = (superdoc.provider as { on: ReturnType }).on.mock.calls[0][1] as () => void; syncedHandler(); - expect(superdoc.commentsStore.handleEditorLocationsUpdate).toHaveBeenCalled(); - expect(superdoc.commentsStore.hasSyncedCollaborationComments).toBe(true); + expect( + (superdoc.commentsStore as { handleEditorLocationsUpdate: ReturnType }).handleEditorLocationsUpdate, + ).toHaveBeenCalled(); + expect((superdoc.commentsStore as { hasSyncedCollaborationComments: boolean }).hasSyncedCollaborationComments).toBe( + true, + ); // Trigger observation from another user commentsArray.items = [ @@ -285,24 +350,32 @@ describe('collaboration helpers', () => { commentsArray.emit(event); expect(useCommentMock).toHaveBeenCalledTimes(2); - expect(superdoc.commentsStore.commentsList).toEqual([{ normalized: 'c1' }, { normalized: 'c2' }]); + // useComment is called with the filtered comment data, and the mock spreads those properties + // So commentsList should contain objects with commentId, text, and the mocked properties + const commentsList = (superdoc.commentsStore as { commentsList: { commentId: string; text: string }[] }) + .commentsList; + expect(commentsList).toHaveLength(2); + expect(commentsList[0].commentId).toBe('c1'); + expect(commentsList[0].text).toBe('Hello'); + expect(commentsList[1].commentId).toBe('c2'); + expect(commentsList[1].text).toBe('Another'); // Event from same user should be ignored - commentsArray.emit({ transaction: { origin: { user: superdoc.config.user } } }); + commentsArray.emit({ transaction: { origin: { user: (superdoc.config as { user: unknown }).user } } }); expect(useCommentMock).toHaveBeenCalledTimes(2); }); it('initCollaborationComments skips when module disabled', () => { - superdoc.config.modules.comments = false; - initCollaborationComments(superdoc); - expect(superdoc.provider.on).not.toHaveBeenCalled(); + (superdoc.config as { modules: { comments: boolean } }).modules.comments = false; + initCollaborationComments(superdoc as never); + expect((superdoc.provider as { on: ReturnType }).on).not.toHaveBeenCalled(); }); it('initSuperdocYdoc delegates to createProvider with derived document id', () => { const mockProvider = { provider: 'p', ydoc: 'y' }; - const spy = vi.spyOn(collaborationModule, 'createProvider').mockReturnValue(mockProvider); + const spy = vi.spyOn(collaborationModule, 'createProvider').mockReturnValue(mockProvider as never); - const result = initSuperdocYdoc(superdoc); + const result = initSuperdocYdoc(superdoc as never); expect(spy).toHaveBeenCalledWith( expect.objectContaining({ documentId: 'doc-123-superdoc-external', @@ -313,13 +386,13 @@ describe('collaboration helpers', () => { }); it('makeDocumentsCollaborative mutates documents with provider metadata', () => { - const created = makeDocumentsCollaborative(superdoc); + const created = makeDocumentsCollaborative(superdoc as never); expect(created).toHaveLength(2); created.forEach((doc) => { - expect(doc.provider).toBeInstanceOf(MockWebsocketProvider); - expect(doc.ydoc).toBeInstanceOf(MockYDoc); - expect(doc.socket).toEqual(superdoc.config.socket); - expect(doc.role).toBe(superdoc.config.role); + expect((doc as { provider: unknown }).provider).toBeInstanceOf(MockWebsocketProvider); + expect((doc as { ydoc: unknown }).ydoc).toBeInstanceOf(MockYDoc); + expect((doc as { socket: unknown }).socket).toEqual((superdoc.config as { socket: unknown }).socket); + expect((doc as { role: string }).role).toBe((superdoc.config as { role: string }).role); }); }); }); @@ -329,33 +402,41 @@ describe('collaboration comments primitives', () => { const ydoc = new MockYDoc(); const yArray = ydoc.getArray('comments'); const baseComment = { commentId: 'c1', body: 'Hello' }; + const mockSuperdoc = { + user: (globalThis as { superdoc: { user: { name: string; email: string } } }).superdoc.user, + } as unknown as SuperDoc; - addYComment(yArray, ydoc, { comment: baseComment }); + addYComment(yArray as never, ydoc as never, { comment: baseComment }, mockSuperdoc); expect(yArray.toJSON()).toEqual([baseComment]); - expect(ydoc._lastMeta.user).toEqual(globalThis.superdoc.user); + expect((ydoc._lastMeta as { user: { name: string; email: string } }).user).toEqual( + (globalThis as { superdoc: { user: { name: string; email: string } } }).superdoc.user, + ); const updatedComment = { commentId: 'c1', body: 'Updated' }; - updateYComment(yArray, ydoc, { comment: updatedComment }); + updateYComment(yArray as never, ydoc as never, { comment: updatedComment }, mockSuperdoc); expect(yArray.toJSON()).toEqual([updatedComment]); - deleteYComment(yArray, ydoc, { comment: updatedComment }); + deleteYComment(yArray as never, ydoc as never, { comment: updatedComment }, mockSuperdoc); expect(yArray.toJSON()).toEqual([]); }); it('getCommentIndex finds matching comment ids', () => { const ydoc = new MockYDoc(); const yArray = ydoc.getArray('comments'); - addYComment(yArray, ydoc, { comment: { commentId: 'c5', body: 'Test' } }); - expect(getCommentIndex(yArray, { commentId: 'missing' })).toBe(-1); - expect(getCommentIndex(yArray, { commentId: 'c5' })).toBe(0); + const mockSuperdoc = { + user: (globalThis as { superdoc: { user: { name: string; email: string } } }).superdoc.user, + } as unknown as SuperDoc; + addYComment(yArray as never, ydoc as never, { comment: { commentId: 'c5', body: 'Test' } }, mockSuperdoc); + expect(getCommentIndex(yArray as never, { commentId: 'missing' })).toBe(-1); + expect(getCommentIndex(yArray as never, { commentId: 'c5' })).toBe(0); }); }); describe('syncCommentsToClients routing', () => { - let superdoc; - let addSpy; - let updateSpy; - let deleteSpy; + let superdoc: Record; + let addSpy: ReturnType; + let updateSpy: ReturnType; + let deleteSpy: ReturnType; beforeEach(() => { const ydoc = new MockYDoc(); @@ -379,28 +460,28 @@ describe('syncCommentsToClients routing', () => { }); it('routes events to the correct helpers', () => { - syncCommentsToClients(superdoc, { type: 'add', comment: { commentId: 'a' } }); + syncCommentsToClients(superdoc as never, { type: 'add', comment: { commentId: 'a' } }); expect(addSpy).toHaveBeenCalled(); - syncCommentsToClients(superdoc, { type: 'update', comment: { commentId: 'a' } }); + syncCommentsToClients(superdoc as never, { type: 'update', comment: { commentId: 'a' } }); expect(updateSpy).toHaveBeenCalledTimes(1); - syncCommentsToClients(superdoc, { type: 'resolved', comment: { commentId: 'a' } }); + syncCommentsToClients(superdoc as never, { type: 'resolved', comment: { commentId: 'a' } }); expect(updateSpy).toHaveBeenCalledTimes(2); - syncCommentsToClients(superdoc, { type: 'deleted', comment: { commentId: 'a' } }); + syncCommentsToClients(superdoc as never, { type: 'deleted', comment: { commentId: 'a' } }); expect(deleteSpy).toHaveBeenCalled(); }); it('ignores events when collaboration disabled', () => { - superdoc.isCollaborative = false; - syncCommentsToClients(superdoc, { type: 'add', comment: {} }); + (superdoc as { isCollaborative: boolean }).isCollaborative = false; + syncCommentsToClients(superdoc as never, { type: 'add', comment: {} }); expect(addSpy).not.toHaveBeenCalled(); }); it('ignores events when comments module disabled', () => { - superdoc.config.modules.comments = false; - syncCommentsToClients(superdoc, { type: 'add', comment: {} }); + (superdoc.config as { modules: { comments: boolean } }).modules.comments = false; + syncCommentsToClients(superdoc as never, { type: 'add', comment: {} }); expect(addSpy).not.toHaveBeenCalled(); }); }); @@ -418,13 +499,27 @@ describe('permissions', () => { }); it('delegates permission decisions to a hook when provided', () => { - const permissionResolver = vi.fn().mockImplementation(({ defaultDecision, comment, currentUser, superdoc }) => { - expect(defaultDecision).toBe(true); - expect(comment.commentId).toBe('comment-1'); - expect(currentUser.email).toBe('editor@example.com'); - expect(superdoc).toBeDefined(); - return false; - }); + const permissionResolver = vi + .fn() + .mockImplementation( + ({ + defaultDecision, + comment, + currentUser, + superdoc, + }: { + defaultDecision: boolean; + comment: { commentId: string }; + currentUser: { email: string }; + superdoc: unknown; + }) => { + expect(defaultDecision).toBe(true); + expect(comment.commentId).toBe('comment-1'); + expect(currentUser.email).toBe('editor@example.com'); + expect(superdoc).toBeDefined(); + return false; + }, + ); const superdoc = { config: { @@ -477,7 +572,7 @@ describe('permissions', () => { it('canPerformPermission resolves tracked-change comments via store', () => { const originalIsAllowed = permissionsModule.isAllowed; - const resolver = vi.fn(({ comment }) => { + const resolver = vi.fn(({ comment }: { comment: { commentId: string; text: string } }) => { expect(comment).toEqual({ commentId: 'change-1', text: 'hello' }); return true; }); @@ -502,10 +597,17 @@ describe('permissions', () => { const isAllowedSpy = vi .spyOn(permissionsModule, 'isAllowed') - .mockImplementation((permission, role, isInternal, ctx) => { - expect(ctx.comment).toEqual({ commentId: 'change-1', text: 'hello' }); - return originalIsAllowed(permission, role, isInternal, ctx); - }); + .mockImplementation( + ( + permission: string, + role: string, + isInternal: boolean, + ctx?: { comment: { commentId: string; text: string } }, + ) => { + expect(ctx?.comment).toEqual({ commentId: 'change-1', text: 'hello' }); + return originalIsAllowed(permission, role, isInternal, ctx as never); + }, + ); const result = SuperDoc.prototype.canPerformPermission.call(superdoc, { permission: PERMISSIONS.RESOLVE_OWN, @@ -513,7 +615,9 @@ describe('permissions', () => { }); expect(result).toBe(true); - expect(superdoc.commentsStore.getComment).toHaveBeenCalledWith('change-1'); + expect((superdoc.commentsStore as { getComment: ReturnType }).getComment).toHaveBeenCalledWith( + 'change-1', + ); expect(resolver).toHaveBeenCalled(); expect(isAllowedSpy).toHaveBeenCalledWith( PERMISSIONS.RESOLVE_OWN, diff --git a/packages/superdoc/src/core/collaboration/collaboration.ts b/packages/superdoc/src/core/collaboration/collaboration.ts new file mode 100644 index 000000000..0547274f6 --- /dev/null +++ b/packages/superdoc/src/core/collaboration/collaboration.ts @@ -0,0 +1,203 @@ +import { WebsocketProvider } from 'y-websocket'; +import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider'; +import { awarenessStatesToArray } from '@superdoc/common/collaboration/awareness'; +import type { AwarenessContext } from '@superdoc/common/collaboration/awareness'; +import { Doc as YDoc } from 'yjs'; +import type { SuperDoc } from '../types/index'; +import type { User } from './permissions'; + +/** + * Awareness change information + */ +interface AwarenessChanges { + added?: number[]; + updated?: number[]; + removed?: number[]; +} + +/** + * Awareness update handler parameters + */ +interface AwarenessHandlerParams { + changes?: AwarenessChanges; + states: Map>; +} + +/** + * Collaboration provider configuration + */ +export interface CollaborationConfig { + /** The provider type (hocuspocus or superdoc) */ + providerType?: 'hocuspocus' | 'superdoc'; + /** The WebSocket URL for connection */ + url?: string; + /** Authentication token */ + token?: string; + /** Additional connection parameters */ + params?: Record; +} + +/** + * Provider creation options + */ +interface ProviderOptions { + config: CollaborationConfig; + user: User; + documentId: string; + socket?: unknown; + superdocInstance: SuperDoc; +} + +/** + * Provider creation result + */ +interface ProviderResult { + provider: WebsocketProvider | HocuspocusProvider; + ydoc: YDoc; +} + +/** + * Translate awareness states to an array of users. This will cause superdoc (context) to + * emit an awareness-update event with the list of users. + * + * @param context - The superdoc instance + * @param params - The awareness changes and states + */ +function awarenessHandler(context: SuperDoc, params: AwarenessHandlerParams): void { + // Context is the superdoc instance + // Since co-presence is handled outside of superdoc, + // we need to emit an awareness-update event + + const { changes = {}, states } = params; + const { added = [], removed = [] } = changes; + const awarenessArray = awarenessStatesToArray(context as unknown as AwarenessContext, states); + + // Emit with added and removed arrays so downstream listeners can track who joined/left + context.emit?.('awareness-update', { context, states: awarenessArray, added, removed }); +} + +/** + * Main function to create a provider for collaboration. + * Currently only hocuspocus is actually supported. + * + * @param options - The provider configuration options + * @returns The provider and ydoc + */ +function createProvider(options: ProviderOptions): ProviderResult { + const { config, user, documentId, socket, superdocInstance } = options; + + if (!config.providerType) config.providerType = 'superdoc'; + + const providers: Record ProviderResult> = { + hocuspocus: () => createHocuspocusProvider({ config, user, documentId, socket, superdocInstance }), + superdoc: () => createSuperDocProvider({ config, user, documentId, socket, superdocInstance }), + }; + + if (!providers[config.providerType]) { + throw new Error(`Provider type ${config.providerType} is not supported.`); + } + + return providers[config.providerType](); +} + +/** + * Create a SuperDoc WebSocket provider + * + * @param options - The provider configuration options + * @returns The provider and ydoc + */ +function createSuperDocProvider(options: ProviderOptions): ProviderResult { + const { config, user, documentId, superdocInstance } = options; + const ydoc = new YDoc({ gc: false }); + const wsOptions = { + params: { + ...config.params, + }, + }; + + if (!config.url) { + throw new Error('WebSocket URL is required for SuperDoc provider'); + } + + const provider = new WebsocketProvider(config.url, documentId, ydoc, wsOptions); + provider.awareness.setLocalStateField('user', user); + provider.awareness.on('update', (changes: AwarenessChanges = {}) => { + return awarenessHandler(superdocInstance, { changes, states: provider.awareness.getStates() }); + }); + + return { provider, ydoc }; +} + +/** + * Create a Hocuspocus provider for collaboration + * + * @param options - The provider configuration options + * @returns The provider and ydoc + */ +function createHocuspocusProvider(options: ProviderOptions): ProviderResult { + const { config, user, documentId, socket, superdocInstance } = options; + const ydoc = new YDoc({ gc: false }); + const hocuspocusOptions = { + websocketProvider: socket as HocuspocusProviderWebsocket, + document: ydoc, + name: documentId, + token: config.token || '', + preserveConnection: false, + onAuthenticationFailed: () => onAuthenticationFailed(documentId), + onConnect: () => onConnect(superdocInstance, documentId), + onDisconnect: () => onDisconnect(superdocInstance, documentId), + onDestroy: () => onDestroy(superdocInstance, documentId), + }; + + const provider = new HocuspocusProvider(hocuspocusOptions); + provider.setAwarenessField('user', user); + + provider.on('awarenessUpdate', (params: { states: Map> }) => { + return awarenessHandler(superdocInstance, { + states: params.states, + }); + }); + + return { provider, ydoc }; +} + +/** + * Handle authentication failure events + * + * @param documentId - The document ID that failed authentication + */ +const onAuthenticationFailed = (documentId: string): void => { + console.warn('🔒 [superdoc] Authentication failed', 'document', documentId); +}; + +/** + * Handle connection events + * + * @param superdocInstance - The SuperDoc instance + * @param documentId - The document ID that connected + */ +const onConnect = (superdocInstance: SuperDoc, documentId: string): void => { + console.warn('🔌 [superdoc] Connected -- ', documentId); +}; + +/** + * Handle disconnection events + * + * @param superdocInstance - The SuperDoc instance + * @param documentId - The document ID that disconnected + */ +const onDisconnect = (superdocInstance: SuperDoc, documentId: string): void => { + console.warn('🔌 [superdoc] Disconnected', documentId); +}; + +/** + * Handle provider destroy events + * + * @param superdocInstance - The SuperDoc instance + * @param documentId - The document ID that was destroyed + */ +const onDestroy = (superdocInstance: SuperDoc, documentId: string): void => { + console.warn('🔌 [superdoc] Destroyed', documentId); +}; + +export { createProvider }; diff --git a/packages/superdoc/src/core/collaboration/helpers.js b/packages/superdoc/src/core/collaboration/helpers.js deleted file mode 100644 index f5d17964f..000000000 --- a/packages/superdoc/src/core/collaboration/helpers.js +++ /dev/null @@ -1,136 +0,0 @@ -import { createProvider } from '../collaboration/collaboration'; -import useComment from '../../components/CommentsLayer/use-comment'; - -import { addYComment, updateYComment, deleteYComment } from './collaboration-comments'; - -/** - * Initialize sync for comments if the module is enabled - * - * @param {Object} superdoc The SuperDoc instance - * @returns {void} - */ -export const initCollaborationComments = (superdoc) => { - if (!superdoc.config.modules.comments || !superdoc.provider) return; - - // If we have comments and collaboration, wait for sync and then let the store know when its ready - const onSuperDocYdocSynced = () => { - // Update the editor comment locations - const parent = superdoc.commentsStore.commentsParentElement; - const ids = superdoc.commentsStore.editorCommentIds; - superdoc.commentsStore.handleEditorLocationsUpdate(parent, ids); - superdoc.commentsStore.hasSyncedCollaborationComments = true; - - superdoc.provider.off('synced', onSuperDocYdocSynced); - }; - - // Listen for the synced event - superdoc.provider.on('synced', onSuperDocYdocSynced); - - // Get the comments map from the Y.Doc - const commentsArray = superdoc.ydoc.getArray('comments'); - - // Observe changes to the comments map - commentsArray.observe((event) => { - // Ignore events if triggered by the current user - const currentUser = superdoc.config.user; - const { user = {} } = event.transaction.origin; - - if (currentUser.name === user.name && currentUser.email === user.email) return; - - // Update conversations - const comments = commentsArray.toJSON(); - - const seen = new Set(); - const filtered = []; - comments.forEach((c) => { - if (!seen.has(c.commentId)) { - seen.add(c.commentId); - filtered.push(c); - } - }); - superdoc.commentsStore.commentsList = filtered.map((c) => useComment(c)); - }); -}; - -/** - * Initialize SuperDoc general Y.Doc for high level collaboration - * Assigns superdoc.ydoc and superdoc.provider in place - * - * @param {Object} superdoc The SuperDoc instance - * @returns {void} - */ -export const initSuperdocYdoc = (superdoc) => { - const { isInternal } = superdoc.config; - const baseName = `${superdoc.config.superdocId}-superdoc`; - if (!superdoc.config.superdocId) return; - - const documentId = isInternal ? baseName : `${baseName}-external`; - const superdocCollaborationOptions = { - config: superdoc.config.modules.collaboration, - user: superdoc.config.user, - documentId, - socket: superdoc.config.socket, - superdocInstance: superdoc, - }; - - const { provider: superdocProvider, ydoc: superdocYdoc } = createProvider(superdocCollaborationOptions); - - return { ydoc: superdocYdoc, provider: superdocProvider }; -}; - -/** - * Process SuperDoc's documents to make them collaborative by - * adding provider, ydoc, awareness handler, and socket to each document. - * - * @param {Object} superdoc The SuperDoc instance - * @returns {Array[Object]} The processed documents - */ -export const makeDocumentsCollaborative = (superdoc) => { - const processedDocuments = []; - superdoc.config.documents.forEach((doc) => { - superdoc.config.user.color = superdoc.colors[0]; - const options = { - config: superdoc.config.modules.collaboration, - user: superdoc.config.user, - documentId: doc.id, - socket: superdoc.config.socket, - superdocInstance: superdoc, - }; - - const { provider, ydoc } = createProvider(options); - doc.provider = provider; - doc.socket = superdoc.config.socket; - doc.ydoc = ydoc; - doc.role = superdoc.config.role; - processedDocuments.push(doc); - }); - return processedDocuments; -}; - -/** - * Sync local comments with ydoc and other clients if in collaboration mode and comments module is enabled - * - * @param {Object} superdoc - * @param {Object} event - * @returns {void} - */ -export const syncCommentsToClients = (superdoc, event) => { - if (!superdoc.isCollaborative || !superdoc.config.modules.comments) return; - - const yArray = superdoc.ydoc.getArray('comments'); - - switch (event.type) { - case 'add': - addYComment(yArray, superdoc.ydoc, event); - break; - case 'update': - updateYComment(yArray, superdoc.ydoc, event); - break; - case 'resolved': - updateYComment(yArray, superdoc.ydoc, event); - break; - case 'deleted': - deleteYComment(yArray, superdoc.ydoc, event); - break; - } -}; diff --git a/packages/superdoc/src/core/collaboration/helpers.ts b/packages/superdoc/src/core/collaboration/helpers.ts new file mode 100644 index 000000000..475ec1f50 --- /dev/null +++ b/packages/superdoc/src/core/collaboration/helpers.ts @@ -0,0 +1,286 @@ +import type { Doc as YDoc, Array as YArray, Map as YMap } from 'yjs'; +import type { WebsocketProvider } from 'y-websocket'; +import type { HocuspocusProvider } from '@hocuspocus/provider'; +import { createProvider } from '../collaboration/collaboration'; +import type { SuperDoc, Document } from '../types/index'; +import type { User } from './permissions'; +import type { CollaborationConfig } from './collaboration'; +import { addYComment, updateYComment, deleteYComment } from './collaboration-comments'; +import useComment from '../../components/CommentsLayer/use-comment'; + +/** + * Comment object returned by useComment composable + */ +interface UseCommentReturn { + uid: { value: string }; + commentId: string; + importedId?: string; + parentCommentId?: string; + fileId?: string; + fileType?: string; + mentions: { value: Array<{ name: string; email: string }> }; + commentElement: { value: HTMLElement | null }; + isFocused: { value: boolean }; + creatorEmail?: string; + creatorName?: string; + creatorImage?: string; + createdTime?: number; + isInternal: { value: boolean }; + commentText: { value: string }; + selection: unknown; + floatingPosition: { top: number; left: number; right: number; bottom: number }; + trackedChange: { value: unknown }; + deletedText: { value: string | null }; + trackedChangeType: { value: string | null }; + trackedChangeText: { value: string | null }; + resolvedTime: { value: number | null }; + resolvedByEmail: { value: string | null }; + resolvedByName: { value: string | null }; + importedAuthor: { value: { name?: string; email?: string } | null }; + setText: (params: { text: string; superdoc: SuperDoc; suppressUpdate?: boolean }) => void; + getValues: () => CommentValues; + resolveComment: (params: { email: string; name: string; superdoc: SuperDoc }) => void; + setIsInternal: (params: { isInternal: boolean; superdoc: SuperDoc }) => void; + setActive: (superdoc: SuperDoc) => void; + updatePosition: ( + coords: { top: number; left: number; right: number; bottom: number }, + parentElement: HTMLElement, + ) => void; + getCommentUser: () => { name: string; email: string; image?: string }; +} + +/** + * Comment values object + */ +interface CommentValues { + commentId: string; + fileId?: string; + [key: string]: unknown; +} + +/** + * Type guard to validate comment data from Yjs before converting + * Ensures the data has the required structure for useComment + * + * @param data - Unknown data to validate + * @returns True if data is a valid CommentValues object + */ +const isValidCommentData = (data: unknown): data is CommentValues => { + if (!data || typeof data !== 'object') return false; + const obj = data as Record; + return typeof obj.commentId === 'string' && obj.commentId.length > 0; +}; + +/** + * Comment event for synchronization + */ +interface CommentSyncEvent { + type: 'add' | 'update' | 'resolved' | 'deleted'; + comment: CommentValues; +} + +/** + * Document object with collaboration properties + * Extends base Document - all collaboration properties are now in the base Document interface + */ +type CollaborativeDocument = Document; + +/** + * Comments store interface + */ +interface CommentsStore { + commentsParentElement: HTMLElement | null; + editorCommentIds: string[]; + hasSyncedCollaborationComments: boolean; + commentsList: UseCommentReturn[]; + handleEditorLocationsUpdate: (parent: HTMLElement | null, ids: string[]) => void; +} + +/** + * SuperDoc instance with collaboration properties + */ +interface SuperDocWithCollaboration { + provider?: WebsocketProvider | HocuspocusProvider; + ydoc: YDoc; + config: SuperDoc['config']; + commentsStore: CommentsStore; + isCollaborative?: boolean; + colors: string[]; +} + +/** + * Provider result from createProvider + */ +interface ProviderResult { + provider: WebsocketProvider | HocuspocusProvider; + ydoc: YDoc; +} + +/** + * Initialize sync for comments if the module is enabled + * + * @param superdoc - The SuperDoc instance + */ +export const initCollaborationComments = (superdoc: SuperDocWithCollaboration): void => { + if (!superdoc.config.modules?.comments || !superdoc.provider) return; + + // If we have comments and collaboration, wait for sync and then let the store know when its ready + const onSuperDocYdocSynced = () => { + // Update the editor comment locations + const parent = superdoc.commentsStore.commentsParentElement; + const ids = superdoc.commentsStore.editorCommentIds; + superdoc.commentsStore.handleEditorLocationsUpdate(parent, ids); + superdoc.commentsStore.hasSyncedCollaborationComments = true; + + // Unsubscribe from both events + (superdoc.provider as unknown as { off: (event: string, callback: () => void) => void })?.off( + 'synced', + onSuperDocYdocSynced, + ); + (superdoc.provider as unknown as { off: (event: string, callback: () => void) => void })?.off( + 'sync', + onSuperDocYdocSynced, + ); + }; + + // Subscribe to both events to support both provider types + // WebsocketProvider uses 'synced', HocuspocusProvider uses 'sync' + (superdoc.provider as unknown as { on: (event: string, callback: () => void) => void }).on( + 'synced', + onSuperDocYdocSynced, + ); + (superdoc.provider as unknown as { on: (event: string, callback: () => void) => void }).on( + 'sync', + onSuperDocYdocSynced, + ); + + // Get the comments array from the Y.Doc + const commentsArray = superdoc.ydoc!.getArray('comments') as YArray>; + + // Observe changes to the comments array + commentsArray.observe((event) => { + // Ignore events if triggered by the current user + const currentUser = superdoc.config.user; + const origin = event.transaction.origin as { user?: User } | undefined; + const user = origin?.user; + + if (currentUser && user && currentUser.name === user.name && currentUser.email === user.email) return; + + // Update conversations + const comments = commentsArray.toJSON(); + + const seen = new Set(); + const filtered: CommentValues[] = []; + comments.forEach((c) => { + // Validate comment data before adding to filtered list + if (isValidCommentData(c) && !seen.has(c.commentId)) { + seen.add(c.commentId); + filtered.push(c); + } + }); + + // Map validated comments to useComment instances + // Type assertion needed as useComment returns reactive objects that differ from interface + superdoc.commentsStore.commentsList = filtered.map((c) => useComment(c)) as unknown as UseCommentReturn[]; + }); +}; + +/** + * Initialize SuperDoc general Y.Doc for high level collaboration + * Assigns superdoc.ydoc and superdoc.provider in place + * + * @param superdoc - The SuperDoc instance + * @returns The ydoc and provider, or undefined if not configured + */ +export const initSuperdocYdoc = (superdoc: SuperDoc): ProviderResult | undefined => { + const { isInternal } = superdoc.config; + const baseName = `${superdoc.config.superdocId}-superdoc`; + if (!superdoc.config.superdocId) return; + + const documentId = isInternal ? baseName : `${baseName}-external`; + const superdocCollaborationOptions = { + config: superdoc.config.modules?.collaboration as CollaborationConfig, + user: superdoc.config.user as User, + documentId, + socket: (superdoc.config as unknown as Record).socket as unknown, + superdocInstance: superdoc, + }; + + const { provider: superdocProvider, ydoc: superdocYdoc } = createProvider(superdocCollaborationOptions); + + return { ydoc: superdocYdoc, provider: superdocProvider }; +}; + +/** + * Process SuperDoc's documents to make them collaborative by + * adding provider, ydoc, awareness handler, and socket to each document. + * + * @param superdoc - The SuperDoc instance + * @returns The processed documents + */ +export const makeDocumentsCollaborative = (superdoc: SuperDocWithCollaboration): CollaborativeDocument[] => { + const processedDocuments: CollaborativeDocument[] = []; + + if (!superdoc.config.documents) return processedDocuments; + + superdoc.config.documents.forEach((doc, index) => { + if (superdoc.config.user && superdoc.colors && superdoc.colors.length > 0) { + // Add color property to user (not in base User type but set at runtime) + (superdoc.config.user as unknown as Record).color = superdoc.colors[0]; + } + + const options = { + config: superdoc.config.modules?.collaboration as CollaborationConfig, + user: superdoc.config.user as User, + documentId: doc.id || '', + socket: (superdoc.config as unknown as Record).socket as unknown, + superdocInstance: superdoc as unknown as SuperDoc, + }; + + const { provider, ydoc } = createProvider(options); + + // Mutate the existing document entry so downstream consumers (including Pinia store) + // continue to see provider/ydoc on the same object reference. + doc.provider = provider; + doc.socket = (superdoc.config as unknown as Record).socket as unknown; + doc.ydoc = ydoc; + doc.role = superdoc.config.role; + doc.id = doc.id || options.documentId; + + processedDocuments.push(doc as CollaborativeDocument); + // Keep config.documents in sync with the mutated document + superdoc.config.documents![index] = doc; + }); + + // Ensure config.documents references the updated objects with provider/ydoc + superdoc.config.documents = processedDocuments as unknown as SuperDoc['config']['documents']; + + return processedDocuments; +}; + +/** + * Sync local comments with ydoc and other clients if in collaboration mode and comments module is enabled + * + * @param superdoc - The SuperDoc instance + * @param event - The comment synchronization event + */ +export const syncCommentsToClients = (superdoc: SuperDocWithCollaboration, event: CommentSyncEvent): void => { + if (!superdoc.isCollaborative || !superdoc.config.modules?.comments) return; + + const yArray = superdoc.ydoc!.getArray('comments') as YArray>; + + switch (event.type) { + case 'add': + addYComment(yArray, superdoc.ydoc!, event, superdoc as unknown as SuperDoc); + break; + case 'update': + updateYComment(yArray, superdoc.ydoc!, event, superdoc as unknown as SuperDoc); + break; + case 'resolved': + updateYComment(yArray, superdoc.ydoc!, event, superdoc as unknown as SuperDoc); + break; + case 'deleted': + deleteYComment(yArray, superdoc.ydoc!, event, superdoc as unknown as SuperDoc); + break; + } +}; diff --git a/packages/superdoc/src/core/collaboration/index.ts b/packages/superdoc/src/core/collaboration/index.ts new file mode 100644 index 000000000..089824a56 --- /dev/null +++ b/packages/superdoc/src/core/collaboration/index.ts @@ -0,0 +1,33 @@ +/** + * Collaboration module exports + * Provides functionality for real-time collaboration, permissions, and comment synchronization + */ + +// Permissions +export { PERMISSIONS, isAllowed } from './permissions'; +export type { + Permission, + Role, + User, + Comment, + TrackedChange, + PermissionResolverParams, + PermissionResolver, + PermissionContext, +} from './permissions'; + +// Collaboration provider +export { createProvider } from './collaboration'; +export type { CollaborationConfig } from './collaboration'; + +// Comment synchronization +export { addYComment, updateYComment, deleteYComment, getCommentIndex } from './collaboration-comments'; +export type { Comment as CommentData, CommentEvent } from './collaboration-comments'; + +// Collaboration helpers +export { + initCollaborationComments, + initSuperdocYdoc, + makeDocumentsCollaborative, + syncCommentsToClients, +} from './helpers'; diff --git a/packages/superdoc/src/core/collaboration/permissions.js b/packages/superdoc/src/core/collaboration/permissions.js deleted file mode 100644 index 3c9712576..000000000 --- a/packages/superdoc/src/core/collaboration/permissions.js +++ /dev/null @@ -1,112 +0,0 @@ -export const PERMISSIONS = Object.freeze({ - RESOLVE_OWN: 'RESOLVE_OWN', - RESOLVE_OTHER: 'RESOLVE_OTHER', - REJECT_OWN: 'REJECT_OWN', - REJECT_OTHER: 'REJECT_OTHER', - COMMENTS_OVERFLOW_OWN: 'COMMENTS_OVERFLOW', - COMMENTS_OVERFLOW_OTHER: 'COMMENTS_OVERFLOW_OTHER', - COMMENTS_DELETE_OWN: 'COMMENTS_DELETE_OWN', - COMMENTS_DELETE_OTHER: 'COMMENTS_DELETE_OTHER', - UPLOAD_VERSION: 'UPLOAD_VERSION', - VERSION_HISTORY: 'VERSION_HISTORY', -}); - -const ROLES = Object.freeze({ - EDITOR: 'editor', - SUGGESTER: 'suggester', - VIEWER: 'viewer', -}); - -const PERMISSION_MATRIX = Object.freeze({ - [PERMISSIONS.RESOLVE_OWN]: { - internal: [ROLES.EDITOR], - external: [ROLES.EDITOR], - }, - [PERMISSIONS.RESOLVE_OTHER]: { - internal: [ROLES.EDITOR], - external: [], - }, - [PERMISSIONS.REJECT_OWN]: { - internal: [ROLES.EDITOR, ROLES.SUGGESTER], - external: [ROLES.EDITOR, ROLES.SUGGESTER], - }, - [PERMISSIONS.REJECT_OTHER]: { - internal: [ROLES.EDITOR], - external: [], - }, - [PERMISSIONS.COMMENTS_OVERFLOW_OWN]: { - internal: [ROLES.EDITOR, ROLES.SUGGESTER], - external: [ROLES.EDITOR, ROLES.SUGGESTER], - }, - [PERMISSIONS.COMMENTS_OVERFLOW_OTHER]: { - internal: [ROLES.EDITOR], - external: [], - }, - [PERMISSIONS.COMMENTS_DELETE_OWN]: { - internal: [ROLES.EDITOR, ROLES.SUGGESTER], - external: [ROLES.EDITOR, ROLES.SUGGESTER], - }, - [PERMISSIONS.COMMENTS_DELETE_OTHER]: { - internal: [ROLES.EDITOR], - external: [], - }, - [PERMISSIONS.UPLOAD_VERSION]: { - internal: [ROLES.EDITOR], - external: [], - }, - [PERMISSIONS.VERSION_HISTORY]: { - internal: [ROLES.EDITOR], - external: [], - }, -}); - -const pickResolver = (context = {}) => { - if (typeof context.permissionResolver === 'function') return context.permissionResolver; - if (context.superdoc?.config?.modules?.comments?.permissionResolver) { - const resolver = context.superdoc.config.modules.comments.permissionResolver; - if (typeof resolver === 'function') return resolver; - } - if (typeof context.superdoc?.config?.permissionResolver === 'function') { - return context.superdoc.config.permissionResolver; - } - return null; -}; - -const defaultDecisionFor = (permission, role, isInternal) => { - const internalExternal = isInternal ? 'internal' : 'external'; - return PERMISSION_MATRIX[permission]?.[internalExternal]?.includes(role) ?? false; -}; - -/** - * Check if a role is allowed to perform a permission - * - * @param {String} permission The permission to check - * @param {String} role The role to check - * @param {Boolean} isInternal The internal/external flag - * @param {Object} [context] Optional context used by the permission resolver - * @param {Object} [context.comment] The comment/tracked change being evaluated - * @param {Object} [context.superdoc] The superdoc instance - * @param {Object} [context.currentUser] The active user object performing the action - * @param {Function} [context.permissionResolver] Explicit resolver override - * @param {Object} [context.trackedChange] Tracked change metadata (for tracked-change permissions) - * @returns {Boolean} True if the role is allowed to perform the permission - */ -export const isAllowed = (permission, role, isInternal, context = {}) => { - const defaultDecision = defaultDecisionFor(permission, role, isInternal); - const resolver = pickResolver(context); - - if (typeof resolver !== 'function') return defaultDecision; - - const decision = resolver({ - permission, - role, - isInternal, - defaultDecision, - comment: context.comment ?? null, - currentUser: context.currentUser ?? context.superdoc?.config?.user ?? null, - superdoc: context.superdoc ?? null, - trackedChange: context.trackedChange ?? null, - }); - - return typeof decision === 'boolean' ? decision : defaultDecision; -}; diff --git a/packages/superdoc/src/core/collaboration/permissions.ts b/packages/superdoc/src/core/collaboration/permissions.ts new file mode 100644 index 000000000..af529b362 --- /dev/null +++ b/packages/superdoc/src/core/collaboration/permissions.ts @@ -0,0 +1,220 @@ +import type { SuperDoc } from '../types/index'; + +/** + * Available permission types for SuperDoc operations + */ +export const PERMISSIONS = Object.freeze({ + RESOLVE_OWN: 'RESOLVE_OWN', + RESOLVE_OTHER: 'RESOLVE_OTHER', + REJECT_OWN: 'REJECT_OWN', + REJECT_OTHER: 'REJECT_OTHER', + COMMENTS_OVERFLOW_OWN: 'COMMENTS_OVERFLOW', + COMMENTS_OVERFLOW_OTHER: 'COMMENTS_OVERFLOW_OTHER', + COMMENTS_DELETE_OWN: 'COMMENTS_DELETE_OWN', + COMMENTS_DELETE_OTHER: 'COMMENTS_DELETE_OTHER', + UPLOAD_VERSION: 'UPLOAD_VERSION', + VERSION_HISTORY: 'VERSION_HISTORY', +} as const); + +/** + * Permission key type derived from PERMISSIONS object + */ +export type Permission = (typeof PERMISSIONS)[keyof typeof PERMISSIONS]; + +/** + * Available user roles + */ +const ROLES = Object.freeze({ + EDITOR: 'editor', + SUGGESTER: 'suggester', + VIEWER: 'viewer', +} as const); + +/** + * Role type derived from ROLES object + */ +export type Role = (typeof ROLES)[keyof typeof ROLES]; + +/** + * Internal/External context type + */ +type InternalExternalKey = 'internal' | 'external'; + +/** + * Permission matrix entry structure + */ +interface PermissionMatrixEntry { + internal: ReadonlyArray; + external: ReadonlyArray; +} + +/** + * Permission matrix mapping permissions to allowed roles by context + */ +type PermissionMatrix = Readonly>; + +/** + * The permission matrix defining which roles can perform which permissions + * in internal vs external contexts + */ +const PERMISSION_MATRIX: PermissionMatrix = Object.freeze({ + [PERMISSIONS.RESOLVE_OWN]: { + internal: [ROLES.EDITOR], + external: [ROLES.EDITOR], + }, + [PERMISSIONS.RESOLVE_OTHER]: { + internal: [ROLES.EDITOR], + external: [], + }, + [PERMISSIONS.REJECT_OWN]: { + internal: [ROLES.EDITOR, ROLES.SUGGESTER], + external: [ROLES.EDITOR, ROLES.SUGGESTER], + }, + [PERMISSIONS.REJECT_OTHER]: { + internal: [ROLES.EDITOR], + external: [], + }, + [PERMISSIONS.COMMENTS_OVERFLOW_OWN]: { + internal: [ROLES.EDITOR, ROLES.SUGGESTER], + external: [ROLES.EDITOR, ROLES.SUGGESTER], + }, + [PERMISSIONS.COMMENTS_OVERFLOW_OTHER]: { + internal: [ROLES.EDITOR], + external: [], + }, + [PERMISSIONS.COMMENTS_DELETE_OWN]: { + internal: [ROLES.EDITOR, ROLES.SUGGESTER], + external: [ROLES.EDITOR, ROLES.SUGGESTER], + }, + [PERMISSIONS.COMMENTS_DELETE_OTHER]: { + internal: [ROLES.EDITOR], + external: [], + }, + [PERMISSIONS.UPLOAD_VERSION]: { + internal: [ROLES.EDITOR], + external: [], + }, + [PERMISSIONS.VERSION_HISTORY]: { + internal: [ROLES.EDITOR], + external: [], + }, +}); + +/** + * User object structure + */ +export interface User { + name: string; + email: string | null; + image?: string | null; +} + +/** + * Comment object structure (minimal definition) + */ +export interface Comment { + commentId?: string; + [key: string]: unknown; +} + +/** + * Tracked change object structure (minimal definition) + */ +export interface TrackedChange { + [key: string]: unknown; +} + +/** + * Permission resolver function signature + */ +export interface PermissionResolverParams { + permission: Permission; + role: Role; + isInternal: boolean; + defaultDecision: boolean; + comment: Comment | null; + currentUser: User | null; + superdoc: SuperDoc | null; + trackedChange: TrackedChange | null; +} + +/** + * Custom permission resolver function type + */ +export type PermissionResolver = (params: PermissionResolverParams) => boolean | undefined; + +/** + * Context object for permission checking + */ +export interface PermissionContext { + comment?: Comment; + superdoc?: SuperDoc; + currentUser?: User; + permissionResolver?: PermissionResolver; + trackedChange?: TrackedChange; +} + +/** + * Pick the appropriate permission resolver from the context + * + * @param context - The context object containing potential resolvers + * @returns The permission resolver function or null if none found + */ +const pickResolver = (context: PermissionContext = {}): PermissionResolver | null => { + if (typeof context.permissionResolver === 'function') return context.permissionResolver; + if (context.superdoc?.config?.modules?.comments?.permissionResolver) { + const resolver = context.superdoc.config.modules.comments.permissionResolver; + if (typeof resolver === 'function') return resolver; + } + if (typeof context.superdoc?.config?.permissionResolver === 'function') { + return context.superdoc.config.permissionResolver; + } + return null; +}; + +/** + * Get the default decision for a permission based on the permission matrix + * + * @param permission - The permission to check + * @param role - The role to check + * @param isInternal - Whether this is an internal or external context + * @returns True if the role is allowed by default, false otherwise + */ +const defaultDecisionFor = (permission: Permission, role: Role, isInternal: boolean): boolean => { + const internalExternal: InternalExternalKey = isInternal ? 'internal' : 'external'; + return PERMISSION_MATRIX[permission]?.[internalExternal]?.includes(role) ?? false; +}; + +/** + * Check if a role is allowed to perform a permission + * + * @param permission - The permission to check + * @param role - The role to check + * @param isInternal - The internal/external flag + * @param context - Optional context used by the permission resolver + * @returns True if the role is allowed to perform the permission + */ +export const isAllowed = ( + permission: Permission, + role: Role, + isInternal: boolean, + context: PermissionContext = {}, +): boolean => { + const defaultDecision = defaultDecisionFor(permission, role, isInternal); + const resolver = pickResolver(context); + + if (typeof resolver !== 'function') return defaultDecision; + + const decision = resolver({ + permission, + role, + isInternal, + defaultDecision, + comment: context.comment ?? null, + currentUser: context.currentUser ?? context.superdoc?.config?.user ?? null, + superdoc: context.superdoc ?? null, + trackedChange: context.trackedChange ?? null, + }); + + return typeof decision === 'boolean' ? decision : defaultDecision; +}; diff --git a/packages/superdoc/src/core/create-app.js b/packages/superdoc/src/core/create-app.js deleted file mode 100644 index 584cc77a9..000000000 --- a/packages/superdoc/src/core/create-app.js +++ /dev/null @@ -1,25 +0,0 @@ -import { createApp } from 'vue'; -import { createPinia } from 'pinia'; - -import { vClickOutside } from '@superdoc/common'; -import { useSuperdocStore } from '../stores/superdoc-store'; -import { useCommentsStore } from '../stores/comments-store'; -import App from '../SuperDoc.vue'; -import { useHighContrastMode } from '../composables/use-high-contrast-mode'; -/** - * Generate the superdoc vue app - * - * @returns {Object} An object containing the vue app, the pinia reference, and the superdoc store - */ -export const createSuperdocVueApp = () => { - const app = createApp(App); - const pinia = createPinia(); - app.use(pinia); - app.directive('click-outside', vClickOutside); - - const superdocStore = useSuperdocStore(); - const commentsStore = useCommentsStore(); - const highContrastModeStore = useHighContrastMode(); - - return { app, pinia, superdocStore, commentsStore, highContrastModeStore }; -}; diff --git a/packages/superdoc/src/core/create-app.ts b/packages/superdoc/src/core/create-app.ts new file mode 100644 index 000000000..192edd5ab --- /dev/null +++ b/packages/superdoc/src/core/create-app.ts @@ -0,0 +1,49 @@ +import { createApp, type App as VueApp } from 'vue'; +import { createPinia, type Pinia } from 'pinia'; + +import { vClickOutside } from '@superdoc/common'; +import { useSuperdocStore } from '../stores/superdoc-store'; +import { useCommentsStore } from '../stores/comments-store'; +import SuperDocApp from '../SuperDoc.vue'; +import { useHighContrastMode, type UseHighContrastModeReturn } from '../composables/use-high-contrast-mode'; + +/** + * Return type for createSuperdocVueApp function + */ +export interface CreateSuperdocVueAppReturn { + /** The Vue application instance */ + app: VueApp; + /** The Pinia store instance */ + pinia: Pinia; + /** The superdoc store */ + superdocStore: ReturnType; + /** The comments store */ + commentsStore: ReturnType; + /** The high contrast mode store */ + highContrastModeStore: UseHighContrastModeReturn; +} + +/** + * Generate the superdoc Vue app + * + * Creates and configures the Vue application instance with Pinia for state management, + * registers custom directives, and initializes all required stores. + * + * @returns An object containing the Vue app, the Pinia reference, and all initialized stores + * + * @example + * const { app, pinia, superdocStore, commentsStore } = createSuperdocVueApp(); + * app.mount('#app'); + */ +export const createSuperdocVueApp = (): CreateSuperdocVueAppReturn => { + const app = createApp(SuperDocApp); + const pinia = createPinia(); + app.use(pinia); + app.directive('click-outside', vClickOutside); + + const superdocStore = useSuperdocStore(); + const commentsStore = useCommentsStore(); + const highContrastModeStore = useHighContrastMode(); + + return { app, pinia, superdocStore, commentsStore, highContrastModeStore }; +}; diff --git a/packages/superdoc/src/core/helpers/export.js b/packages/superdoc/src/core/helpers/export.js deleted file mode 100644 index f7aa169a4..000000000 --- a/packages/superdoc/src/core/helpers/export.js +++ /dev/null @@ -1,90 +0,0 @@ -const MIME_TYPES = { - docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - pdf: 'application/pdf', - zip: 'application/zip', - html: 'text/html', - txt: 'text/plain;charset=utf-8', - json: 'application/json', -}; - -const getMimeType = (extension) => { - if (!extension || typeof extension.toLowerCase !== 'function') return 'application/octet-stream'; - return MIME_TYPES[extension.toLowerCase()] || 'application/octet-stream'; -}; - -const ensureBlob = (data, extension) => { - if (data instanceof Blob) return data; - - const mimeType = getMimeType(extension); - - if (data instanceof ArrayBuffer) { - return new Blob([data], { type: mimeType }); - } - - if (ArrayBuffer.isView(data)) { - const { buffer, byteOffset, byteLength } = data; - const slice = buffer.slice(byteOffset, byteOffset + byteLength); - return new Blob([slice], { type: mimeType }); - } - - if (typeof data === 'string') { - return new Blob([data], { type: mimeType }); - } - - if (data == null) { - throw new TypeError('createDownload requires a Blob, ArrayBuffer, or ArrayBufferView.'); - } - - throw new TypeError(`Cannot create download from value of type ${typeof data}`); -}; - -/** - * Create a download link for a blob - * - * @param {Blob|ArrayBuffer|ArrayBufferView|string} data The data to download - * @param {string} name The name of the file - * @param {string} extension The extension of the file - * @returns {Blob} The blob that was used for the download. - */ -export const createDownload = (data, name, extension) => { - const blob = ensureBlob(data, extension); - - if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') return blob; - if (typeof document === 'undefined' || typeof document.createElement !== 'function') return blob; - - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${name}.${extension}`; - - // Some browsers require the link to be in the DOM for the click to trigger. - const shouldAppend = document.body && typeof document.body.appendChild === 'function'; - if (shouldAppend) document.body.appendChild(a); - - a.click(); - - if (shouldAppend) document.body.removeChild(a); - - if (typeof URL.revokeObjectURL === 'function') { - setTimeout(() => URL.revokeObjectURL(url), 0); - } - - return blob; -}; - -/** - * Generate a filename safe string - * - * @param {string} currentName The current name of the file - * @returns {string} The cleaned name - */ -export const cleanName = (currentName) => { - const lowerName = currentName.toLowerCase(); - if (lowerName.endsWith('.docx')) { - return currentName.slice(0, -5); - } - if (lowerName.endsWith('.pdf')) { - return currentName.slice(0, -4); - } - return currentName; -}; diff --git a/packages/superdoc/src/core/helpers/export.test.js b/packages/superdoc/src/core/helpers/export.test.ts similarity index 78% rename from packages/superdoc/src/core/helpers/export.test.js rename to packages/superdoc/src/core/helpers/export.test.ts index 64336d7a3..172fa8f7f 100644 --- a/packages/superdoc/src/core/helpers/export.test.js +++ b/packages/superdoc/src/core/helpers/export.test.ts @@ -7,7 +7,7 @@ describe('createDownload', () => { const originalCreateElement = document.createElement; const originalAppendChild = document.body.appendChild; const originalRemoveChild = document.body.removeChild; - let clickMock; + let clickMock: ReturnType; beforeEach(() => { vi.useFakeTimers(); @@ -15,9 +15,9 @@ describe('createDownload', () => { // Stub DOM APIs so createDownload can run without touching the real browser environment. URL.createObjectURL = vi.fn(() => 'blob:mock'); URL.revokeObjectURL = vi.fn(); - document.createElement = vi.fn(() => ({ href: '', download: '', click: clickMock })); - document.body.appendChild = vi.fn(); - document.body.removeChild = vi.fn(); + document.createElement = vi.fn(() => ({ href: '', download: '', click: clickMock }) as unknown as HTMLElement); + document.body.appendChild = vi.fn() as typeof document.body.appendChild; + document.body.removeChild = vi.fn() as typeof document.body.removeChild; }); afterEach(() => { @@ -37,10 +37,10 @@ describe('createDownload', () => { const blob = createDownload(buffer, 'Document', 'docx'); expect(URL.createObjectURL).toHaveBeenCalledTimes(1); - const createdBlob = URL.createObjectURL.mock.calls[0][0]; + const createdBlob = (URL.createObjectURL as ReturnType).mock.calls[0][0]; expect(createdBlob).toBeInstanceOf(Blob); - expect(createdBlob.size).toBe(3); - expect(createdBlob.type).toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document'); + expect((createdBlob as Blob).size).toBe(3); + expect((createdBlob as Blob).type).toBe('application/vnd.openxmlformats-officedocument.wordprocessingml.document'); expect(blob).toBe(createdBlob); expect(document.body.appendChild).toHaveBeenCalledTimes(1); expect(clickMock).toHaveBeenCalledTimes(1); @@ -56,7 +56,7 @@ describe('createDownload', () => { expect(result).toBe(input); expect(URL.createObjectURL).toHaveBeenCalledTimes(1); - const createdBlob = URL.createObjectURL.mock.calls[0][0]; + const createdBlob = (URL.createObjectURL as ReturnType).mock.calls[0][0]; expect(createdBlob).toBe(input); }); @@ -64,7 +64,7 @@ describe('createDownload', () => { const result = createDownload('payload', 'data', 'json'); expect(result).toBeInstanceOf(Blob); - const createdBlob = URL.createObjectURL.mock.calls[0][0]; + const createdBlob = (URL.createObjectURL as ReturnType).mock.calls[0][0] as Blob; expect(createdBlob.type).toBe('application/json'); expect(createdBlob.size).toBe(result.size); }); @@ -77,13 +77,13 @@ describe('createDownload', () => { const result = createDownload(view, 'snippet', 'txt'); expect(result).toBeInstanceOf(Blob); - const createdBlob = URL.createObjectURL.mock.calls[0][0]; + const createdBlob = (URL.createObjectURL as ReturnType).mock.calls[0][0] as Blob; expect(createdBlob.size).toBe(4); expect(createdBlob.type).toBe('text/plain;charset=utf-8'); }); it('throws for nullish inputs', () => { - expect(() => createDownload(null, 'invalid', 'docx')).toThrow(TypeError); + expect(() => createDownload(null as unknown as Blob, 'invalid', 'docx')).toThrow(TypeError); expect(URL.createObjectURL).not.toHaveBeenCalled(); }); }); diff --git a/packages/superdoc/src/core/helpers/export.ts b/packages/superdoc/src/core/helpers/export.ts new file mode 100644 index 000000000..a1b089ed0 --- /dev/null +++ b/packages/superdoc/src/core/helpers/export.ts @@ -0,0 +1,132 @@ +/** + * Mapping of file extensions to their corresponding MIME types + */ +const MIME_TYPES: Record = { + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + pdf: 'application/pdf', + zip: 'application/zip', + html: 'text/html', + txt: 'text/plain;charset=utf-8', + json: 'application/json', +}; + +/** + * Get the MIME type for a file extension + * + * @param extension - The file extension (with or without leading dot) + * @returns The corresponding MIME type, or 'application/octet-stream' if unknown + */ +const getMimeType = (extension: string): string => { + if (!extension || typeof extension.toLowerCase !== 'function') return 'application/octet-stream'; + return MIME_TYPES[extension.toLowerCase()] || 'application/octet-stream'; +}; + +/** + * Convert various data types into a Blob with the appropriate MIME type + * + * @param data - The data to convert (Blob, ArrayBuffer, ArrayBufferView, or string) + * @param extension - File extension to determine MIME type + * @returns A Blob with the appropriate MIME type + * @throws {TypeError} If data is null/undefined or an unsupported type + */ +const ensureBlob = ( + data: Blob | ArrayBuffer | ArrayBufferView | string | null | undefined, + extension: string, +): Blob => { + if (data instanceof Blob) return data; + + const mimeType = getMimeType(extension); + + if (data instanceof ArrayBuffer) { + return new Blob([data], { type: mimeType }); + } + + if (ArrayBuffer.isView(data)) { + const { buffer, byteOffset, byteLength } = data; + // Explicitly cast to ArrayBuffer to satisfy TypeScript's BlobPart type requirements + const slice = buffer.slice(byteOffset, byteOffset + byteLength) as ArrayBuffer; + return new Blob([slice], { type: mimeType }); + } + + if (typeof data === 'string') { + return new Blob([data], { type: mimeType }); + } + + if (data == null) { + throw new TypeError('createDownload requires a Blob, ArrayBuffer, or ArrayBufferView.'); + } + + throw new TypeError(`Cannot create download from value of type ${typeof data}`); +}; + +/** + * Create and trigger a browser download for the provided data + * + * Creates a Blob from the provided data, generates a temporary download URL, + * and programmatically triggers a download in the browser. The URL is automatically + * revoked after the download to free up memory. + * + * @param data - The data to download (Blob, ArrayBuffer, ArrayBufferView, or string) + * @param name - The base filename without extension + * @param extension - The file extension (determines MIME type and download name) + * @returns The Blob that was used for the download + * @throws {TypeError} If data is null/undefined or an unsupported type + * + * @example + * const blob = createDownload(myData, 'report', 'pdf'); + * // Downloads as 'report.pdf' + */ +export const createDownload = ( + data: Blob | ArrayBuffer | ArrayBufferView | string, + name: string, + extension: string, +): Blob => { + const blob = ensureBlob(data, extension); + + if (typeof URL === 'undefined' || typeof URL.createObjectURL !== 'function') return blob; + if (typeof document === 'undefined' || typeof document.createElement !== 'function') return blob; + + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `${name}.${extension}`; + + // Some browsers require the link to be in the DOM for the click to trigger. + const shouldAppend = document.body && typeof document.body.appendChild === 'function'; + if (shouldAppend) document.body.appendChild(a); + + a.click(); + + if (shouldAppend) document.body.removeChild(a); + + if (typeof URL.revokeObjectURL === 'function') { + setTimeout(() => URL.revokeObjectURL(url), 0); + } + + return blob; +}; + +/** + * Remove file extensions from a filename to get the base name + * + * Strips common document extensions (.docx, .pdf) from filenames. + * Other extensions are left as-is. + * + * @param currentName - The current filename with extension + * @returns The filename without the extension + * + * @example + * cleanName('report.docx') // Returns 'report' + * cleanName('document.pdf') // Returns 'document' + * cleanName('file.txt') // Returns 'file.txt' (unrecognized extension kept) + */ +export const cleanName = (currentName: string): string => { + const lowerName = currentName.toLowerCase(); + if (lowerName.endsWith('.docx')) { + return currentName.slice(0, -5); + } + if (lowerName.endsWith('.pdf')) { + return currentName.slice(0, -4); + } + return currentName; +}; diff --git a/packages/superdoc/src/core/helpers/file.js b/packages/superdoc/src/core/helpers/file.js deleted file mode 100644 index 24a867967..000000000 --- a/packages/superdoc/src/core/helpers/file.js +++ /dev/null @@ -1,107 +0,0 @@ -import { DOCX, PDF, HTML } from '@superdoc/common'; - -/** - * @typedef {Object} UploadWrapper - * @property {File|Blob} [originFileObj] Underlying file reference used by some uploaders - * @property {File|Blob} [file] Underlying file reference used by some uploaders - * @property {File|Blob} [raw] Underlying file reference used by some uploaders - * @property {string|number} [uid] Optional unique id from uploaders (ignored) - * @property {string} [name] Display name (not always reliable for the native file) - */ - -/** - * @typedef {Object} DocumentEntry - * @property {string} [type] Mime type or shorthand ('docx' | 'pdf' | 'html') - * @property {string} [name] Filename to display - * @property {File|Blob|UploadWrapper} [data] File-like data; normalized to File when available, otherwise Blob - * @property {string} [url] Remote URL to fetch; left as-is for URL flows - * @property {boolean} [isNewFile] - */ - -/** - * Extract a native File from various wrapper shapes used by UI uploader libraries. - * Safely handles common wrapper keys or plain Blob/File inputs. - * - * @param {File|Blob|UploadWrapper|any} input File-like object or an uploader wrapper - * @returns {File|Blob|null} Extracted native File/Blob or null if not resolvable - */ -export const extractBrowserFile = (input) => { - if (!input) return null; - - // Already a File - if (typeof File === 'function' && input instanceof File) return input; - - // Blob without name → wrap as File with a default name - if (typeof Blob === 'function' && input instanceof Blob) { - const hasFileCtor = typeof File === 'function'; - if (hasFileCtor) { - const name = input.name || 'document'; - return new File([input], name, { type: input.type }); - } - // In Node.js without File constructor, return the Blob as-is - return input; - } - - // Common: real file often lives in `originFileObj` - if (input.originFileObj) return extractBrowserFile(input.originFileObj); - - // Other libraries sometimes use `file` or `raw` - if (input.file) return extractBrowserFile(input.file); - if (input.raw) return extractBrowserFile(input.raw); - - return null; -}; - -/** - * Infer a mime type from filename when missing - * @param {string} [name] - * @returns {string} - */ -const inferTypeFromName = (name = '') => { - const lower = String(name).toLowerCase(); - if (lower.endsWith('.docx')) return DOCX; - if (lower.endsWith('.pdf')) return PDF; - if (lower.endsWith('.html') || lower.endsWith('.htm')) return HTML; - if (lower.endsWith('.md') || lower.endsWith('.markdown')) return 'text/markdown'; - return ''; -}; - -/** - * Normalize any supported document input into SuperDoc's expected shape. - * Accepts File/Blob/uploader wrappers directly, or a config-like object with a `data` field. - * URL-based entries are returned unchanged for downstream handling. - * - * @param {File|Blob|UploadWrapper|DocumentEntry|any} entry - * @returns {DocumentEntry|any} Normalized document entry or the original value when unchanged - */ -export const normalizeDocumentEntry = (entry) => { - // Direct file-like input (e.g., uploader wrapper or File) - const maybeFile = extractBrowserFile(entry); - if (maybeFile) { - const name = /** @type {any} */ (maybeFile).name || (entry && entry.name) || 'document'; - const type = maybeFile.type || inferTypeFromName(name) || DOCX; - return { - type, - data: maybeFile, - name, - isNewFile: true, - }; - } - - // Config object with a `data` property that could be file-like - if (entry && typeof entry === 'object' && 'data' in entry) { - const file = extractBrowserFile(entry.data); - if (file) { - const type = entry.type || file.type || inferTypeFromName(file.name) || DOCX; - return { - ...entry, - type, - data: file, - name: entry.name || file.name || 'document', - }; - } - } - - // Unchanged (e.g., URL-based configs handled later) - return entry; -}; diff --git a/packages/superdoc/src/core/helpers/file.test.js b/packages/superdoc/src/core/helpers/file.test.ts similarity index 95% rename from packages/superdoc/src/core/helpers/file.test.js rename to packages/superdoc/src/core/helpers/file.test.ts index 144aef1ad..ea79b03f8 100644 --- a/packages/superdoc/src/core/helpers/file.test.js +++ b/packages/superdoc/src/core/helpers/file.test.ts @@ -15,8 +15,8 @@ describe('extractBrowserFile', () => { const blob = new Blob(['%PDF'], { type: 'application/pdf' }); const out = extractBrowserFile(blob); expect(out).toBeInstanceOf(File); - expect(out.name).toBe('document'); - expect(out.type).toBe('application/pdf'); + expect(out?.name).toBe('document'); + expect(out?.type).toBe('application/pdf'); }); it('unwraps wrapper object via originFileObj', () => { @@ -35,11 +35,10 @@ describe('extractBrowserFile', () => { it('ignores uid and other extra props on File', () => { const rc = new File([new Blob(['x'], { type: DOCX })], 'b.docx', { type: DOCX }); // simulate uploader adding uid - // @ts-ignore - rc.uid = 'rc-1'; + (rc as File & { uid?: string }).uid = 'rc-1'; const out = extractBrowserFile(rc); expect(out).toBe(rc); - expect(out.name).toBe('b.docx'); + expect(out?.name).toBe('b.docx'); }); it('returns null for falsy inputs', () => { diff --git a/packages/superdoc/src/core/helpers/file.ts b/packages/superdoc/src/core/helpers/file.ts new file mode 100644 index 000000000..b82510d45 --- /dev/null +++ b/packages/superdoc/src/core/helpers/file.ts @@ -0,0 +1,135 @@ +import { DOCX, PDF, HTML } from '@superdoc/common'; + +/** + * Wrapper object used by various UI file uploader libraries + * These libraries often wrap the native File object with additional metadata + */ +export interface UploadWrapper { + /** Underlying file reference used by some uploaders */ + originFileObj?: File | Blob; + /** Underlying file reference used by some uploaders */ + file?: File | Blob; + /** Underlying file reference used by some uploaders */ + raw?: File | Blob; + /** Optional unique id from uploaders (ignored) */ + uid?: string | number; + /** Display name (not always reliable for the native file) */ + name?: string; +} + +/** + * Represents a document entry that can be loaded into SuperDoc + * Supports both file-based and URL-based document sources + */ +export interface DocumentEntry { + /** Mime type or shorthand ('docx' | 'pdf' | 'html') */ + type?: string; + /** Filename to display */ + name?: string; + /** File-like data; normalized to File when available, otherwise Blob */ + data?: File | Blob | UploadWrapper; + /** Remote URL to fetch; left as-is for URL flows */ + url?: string; + /** Indicates if this is a newly uploaded file */ + isNewFile?: boolean; +} + +/** + * Extract a native File from various wrapper shapes used by UI uploader libraries. + * Safely handles common wrapper keys or plain Blob/File inputs. + * + * @param input - File-like object or an uploader wrapper + * @returns Extracted native File/Blob or null if not resolvable + */ +export const extractBrowserFile = (input: File | Blob | UploadWrapper | unknown): File | Blob | null => { + if (!input) return null; + + // Already a File + if (typeof File === 'function' && input instanceof File) return input; + + // Blob without name → wrap as File with a default name + if (typeof Blob === 'function' && input instanceof Blob) { + const hasFileCtor = typeof File === 'function'; + if (hasFileCtor) { + const name = (input as File).name || 'document'; + return new File([input], name, { type: input.type }); + } + // In Node.js without File constructor, return the Blob as-is + return input; + } + + // Handle wrapper objects + const wrapper = input as UploadWrapper; + + // Common: real file often lives in `originFileObj` + if (wrapper.originFileObj) return extractBrowserFile(wrapper.originFileObj); + + // Other libraries sometimes use `file` or `raw` + if (wrapper.file) return extractBrowserFile(wrapper.file); + if (wrapper.raw) return extractBrowserFile(wrapper.raw); + + return null; +}; + +/** + * Infer a mime type from filename when missing + * + * @param name - The filename to infer type from + * @returns The inferred MIME type or empty string if unknown + */ +const inferTypeFromName = (name?: string): string => { + const lower = String(name ?? '').toLowerCase(); + if (lower.endsWith('.docx')) return DOCX; + if (lower.endsWith('.pdf')) return PDF; + if (lower.endsWith('.html') || lower.endsWith('.htm')) return HTML; + if (lower.endsWith('.md') || lower.endsWith('.markdown')) return 'text/markdown'; + return ''; +}; + +/** + * Normalize any supported document input into SuperDoc's expected shape. + * Accepts File/Blob/uploader wrappers directly, or a config-like object with a `data` field. + * URL-based entries are returned unchanged for downstream handling. + * + * @param entry - Document entry to normalize + * @returns Normalized document entry or the original value when unchanged + */ +export const normalizeDocumentEntry = ( + entry: File | Blob | UploadWrapper | DocumentEntry | unknown, +): DocumentEntry | unknown => { + // Direct file-like input (e.g., uploader wrapper or File) + const maybeFile = extractBrowserFile(entry); + if (maybeFile) { + const name: string = + (maybeFile as File).name || + (entry && typeof entry === 'object' && 'name' in entry && typeof (entry as { name?: string }).name === 'string' + ? (entry as { name: string }).name + : null) || + 'document'; + const type = maybeFile.type || inferTypeFromName(name) || DOCX; + return { + type, + data: maybeFile, + name, + isNewFile: true, + }; + } + + // Config object with a `data` property that could be file-like + if (entry && typeof entry === 'object' && 'data' in entry) { + const docEntry = entry as DocumentEntry; + const file = extractBrowserFile(docEntry.data); + if (file) { + const type = docEntry.type || file.type || inferTypeFromName((file as File).name) || DOCX; + return { + ...docEntry, + type, + data: file, + name: docEntry.name || (file as File).name || 'document', + }; + } + } + + // Unchanged (e.g., URL-based configs handled later) + return entry; +}; diff --git a/packages/superdoc/src/core/index.js b/packages/superdoc/src/core/index.js deleted file mode 100644 index 2023869be..000000000 --- a/packages/superdoc/src/core/index.js +++ /dev/null @@ -1 +0,0 @@ -export { SuperDoc } from './SuperDoc.js'; diff --git a/packages/superdoc/src/core/index.ts b/packages/superdoc/src/core/index.ts new file mode 100644 index 000000000..d8f92f5d2 --- /dev/null +++ b/packages/superdoc/src/core/index.ts @@ -0,0 +1 @@ +export { SuperDoc } from './SuperDoc'; diff --git a/packages/superdoc/src/core/types/index.js b/packages/superdoc/src/core/types/index.js deleted file mode 100644 index 8c288dca0..000000000 --- a/packages/superdoc/src/core/types/index.js +++ /dev/null @@ -1,139 +0,0 @@ -/** - * @typedef {Object} User The current user of this superdoc - * @property {string} name The user's name - * @property {string} email The user's email - * @property {string | null} [image] The user's photo - */ - -/** - * @typedef {Object} TelemetryConfig Telemetry configuration - * @property {boolean} [enabled=true] Whether telemetry is enabled - * @property {string} [licenseKey] The licence key for telemetry - * @property {string} [endpoint] The endpoint for telemetry - * @property {string} [superdocVersion] The version of the superdoc - */ - -/** - * @typedef {Object} Document - * @property {string} [id] The ID of the document - * @property {string} type The type of the document - * @property {File | Blob | null} [data] The initial data of the document (File, Blob, or null) - * @property {string} [name] The name of the document - * @property {string} [url] The URL of the document - * @property {boolean} [isNewFile] Whether the document is a new file - * @property {import('yjs').Doc} [ydoc] The Yjs document for collaboration - * @property {import('@hocuspocus/provider').HocuspocusProvider} [provider] The provider for collaboration - */ - -/** - * @typedef {Object} Modules - * @property {Object} [comments] Comments module configuration - * @property {(params: { - * permission: string, - * role?: string, - * isInternal?: boolean, - * comment?: Object | null, - * trackedChange?: Object | null, - * currentUser?: User | null, - * superdoc?: SuperDoc | null, - * }) => boolean | undefined} [comments.permissionResolver] Custom permission resolver for comment actions - * @property {Object} [ai] AI module configuration - * @property {string} [ai.apiKey] Harbour API key for AI features - * @property {string} [ai.endpoint] Custom endpoint URL for AI services - * @property {Object} [collaboration] Collaboration module configuration - * @property {Object} [toolbar] Toolbar module configuration - * @property {Object} [slashMenu] Slash menu module configuration - * @property {Array} [slashMenu.customItems] Array of custom menu sections with items - * @property {Function} [slashMenu.menuProvider] Function to customize menu items - * @property {boolean} [slashMenu.includeDefaultItems] Whether to include default menu items - */ - -/** @typedef {import('@harbour-enterprises/super-editor').Editor} Editor */ -/** @typedef {import('../SuperDoc.js').SuperDoc} SuperDoc */ - -/** - * @typedef {'editing' | 'viewing' | 'suggesting'} DocumentMode - */ - -/** - * @typedef {'docx' | 'pdf' | 'html'} ExportType - */ - -/** - * @typedef {'external' | 'clean'} CommentsType - * - 'external': Include only external comments (default) - * - 'clean': Export without any comments - */ - -/** - * @typedef {Object} ExportParams - * @property {ExportType[]} [exportType=['docx']] - File formats to export - * @property {CommentsType} [commentsType='external'] - How to handle comments - * @property {string} [exportedName] - Custom filename (without extension) - * @property {boolean} [triggerDownload=true] - Auto-download or return blob - * @property {string} [fieldsHighlightColor] - Color for field highlights - */ - -/** - * @typedef {Object} Config - * @property {string} [superdocId] The ID of the SuperDoc - * @property {string | HTMLElement} selector The selector or element to mount the SuperDoc into - * @property {DocumentMode} documentMode The mode of the document - * @property {'editor' | 'viewer' | 'suggester'} [role] The role of the user in this SuperDoc - * @property {Object | string | File | Blob} [document] The document to load. If a string, it will be treated as a URL. If a File or Blob, it will be used directly. - * @property {Array} [documents] The documents to load -> Soon to be deprecated - * @property {User} [user] The current user of this SuperDoc - * @property {Array} [users] All users of this SuperDoc (can be used for "@"-mentions) - * @property {Array} [colors] Colors to use for user awareness - * @property {Modules} [modules] Modules to load - * @property {(params: { - * permission: string, - * role?: string, - * isInternal?: boolean, - * comment?: Object | null, - * trackedChange?: Object | null, - * currentUser?: User | null, - * superdoc?: SuperDoc | null, - * }) => boolean | undefined} [permissionResolver] Top-level override for permission checks - * @property {string} [toolbar] Optional DOM element to render the toolbar in - * @property {Array} [toolbarGroups] Toolbar groups to show - * @property {Object} [toolbarIcons] Icons to show in the toolbar - * @property {Object} [toolbarTexts] Texts to override in the toolbar - * @property {boolean} [isDev] Whether the SuperDoc is in development mode - * @property {TelemetryConfig} [telemetry] Telemetry configuration - * @property {Object} [layoutEngineOptions] Layout engine overrides passed through to PresentationEditor (page size, margins, virtualization, zoom, debug label, etc.) - * @property {Object} [layoutEngineOptions.trackedChanges] Optional override for paginated track-changes rendering (e.g., `{ mode: 'final' }` to force final view or `{ enabled: false }` to strip metadata entirely) - * @property {(editor: Editor) => void} [onEditorBeforeCreate] Callback before an editor is created - * @property {(editor: Editor) => void} [onEditorCreate] Callback after an editor is created - * @property {(params: { editor: Editor, transaction: any, duration: number }) => void} [onTransaction] Callback when a transaction is made - * @property {() => void} [onEditorDestroy] Callback after an editor is destroyed - * @property {(params: { error: object, editor: Editor, documentId: string, file: File }) => void} [onContentError] Callback when there is an error in the content - * @property {(editor: { superdoc: SuperDoc }) => void} [onReady] Callback when the SuperDoc is ready - * @property {(params: { type: string, data: object}) => void} [onCommentsUpdate] Callback when comments are updated - * @property {(params: { context: SuperDoc, states: Array }) => void} [onAwarenessUpdate] Callback when awareness is updated - * @property {(params: { isLocked: boolean, lockedBy: User }) => void} [onLocked] Callback when the SuperDoc is locked - * @property {() => void} [onPdfDocumentReady] Callback when the PDF document is ready - * @property {(isOpened: boolean) => void} [onSidebarToggle] Callback when the sidebar is toggled - * @property {(params: { editor: Editor }) => void} [onCollaborationReady] Callback when collaboration is ready - * @property {(params: { editor: Editor }) => void} [onEditorUpdate] Callback when document is updated - * @property {(params: { error: Error }) => void} [onException] Callback when an exception is thrown - * @property {(params: { isRendered: boolean }) => void} [onCommentsListChange] Callback when the comments list is rendered - * @property {(params: {})} [onListDefinitionsChange] Callback when the list definitions change - * @property {string} [format] The format of the document (docx, pdf, html) - * @property {Object[]} [editorExtensions] The extensions to load for the editor - * @property {boolean} [isInternal] Whether the SuperDoc is internal - * @property {string} [title] The title of the SuperDoc - * @property {Object[]} [conversations] The conversations to load - * @property {boolean} [isLocked] Whether the SuperDoc is locked - * @property {function(File): Promise} [handleImageUpload] The function to handle image uploads - * @property {User} [lockedBy] The user who locked the SuperDoc - * @property {boolean} [rulers] Whether to show the ruler in the editor - * @property {boolean} [suppressDefaultDocxStyles] Whether to suppress default styles in docx mode - * @property {Object} [jsonOverride] Provided JSON to override content with - * @property {boolean} [disableContextMenu] Whether to disable slash / right-click custom context menu - * @property {string} [html] HTML content to initialize the editor with - * @property {string} [markdown] Markdown content to initialize the editor with - * @property {boolean} [isDebug=false] Whether to enable debug mode - */ - -export {}; diff --git a/packages/superdoc/src/core/types/index.ts b/packages/superdoc/src/core/types/index.ts new file mode 100644 index 000000000..1eb45f0ca --- /dev/null +++ b/packages/superdoc/src/core/types/index.ts @@ -0,0 +1,437 @@ +// Editor is a class from super-editor, so we extract its instance type +import { Editor as EditorClass } from '@harbour-enterprises/super-editor'; +import type { Doc as YDoc } from 'yjs'; + +export type Editor = InstanceType; + +/** + * Minimal provider interface for collaboration providers + * Compatible with both HocuspocusProvider and WebsocketProvider + */ +export interface CollaborationProvider { + disconnect: () => void; + destroy: () => void; +} + +/** + * The current user of this superdoc + */ +export interface User { + /** The user's name */ + name: string; + /** The user's email (can be null for default users) */ + email: string | null; + /** The user's photo */ + image?: string | null; +} + +/** + * Telemetry configuration + */ +export interface TelemetryConfig { + /** Whether telemetry is enabled */ + enabled?: boolean; + /** The licence key for telemetry */ + licenseKey?: string; + /** The endpoint for telemetry */ + endpoint?: string; + /** The version of the superdoc */ + superdocVersion?: string; +} + +export interface Document { + /** The ID of the document */ + id?: string; + /** The type of the document */ + type: string; + /** The initial data of the document (File, Blob, or null) */ + data?: File | Blob | null; + /** The name of the document */ + name?: string; + /** The URL of the document */ + url?: string; + /** Whether the document is a new file */ + isNewFile?: boolean; + /** The Yjs document for collaboration */ + ydoc?: YDoc; + /** The provider for collaboration (supports both HocuspocusProvider and WebsocketProvider) */ + provider?: CollaborationProvider; + /** WebSocket connection for collaboration */ + socket?: unknown; + /** User role for this document */ + role?: string; +} + +/** + * Document editing mode + */ +export type DocumentMode = 'editing' | 'viewing' | 'suggesting'; + +/** + * Export file type + */ +export type ExportType = 'docx' | 'pdf' | 'html'; + +/** + * Comments handling type + * - 'external': Include only external comments (default) + * - 'clean': Export without any comments + */ +// CommentsType is optional for callers; defaults to 'external'. Historically we +// allowed additional values (e.g., 'all') so we keep a permissive union. +export type CommentsType = 'external' | 'clean' | 'all'; + +export interface ExportParams { + /** File formats to export */ + exportType?: ExportType[]; + /** How to handle comments */ + commentsType?: CommentsType; + /** Custom filename (without extension) */ + exportedName?: string; + /** Auto-download or return blob */ + triggerDownload?: boolean; + /** Color for field highlights */ + fieldsHighlightColor?: string; +} + +export interface PermissionResolverParams { + permission: string; + role?: string; + isInternal?: boolean; + comment?: object | null; + trackedChange?: object | null; + currentUser?: User | null; + superdoc?: SuperDoc | null; +} + +export type PermissionResolver = (params: PermissionResolverParams) => boolean | undefined; + +export interface CommentsModuleConfig { + /** Custom permission resolver for comment actions */ + permissionResolver?: PermissionResolver; + /** Whether to use internal/external comments distinction */ + useInternalExternalComments?: boolean; + /** Whether to suppress internal/external comments */ + suppressInternalExternalComments?: boolean; + /** DOM element to mount the comments list */ + element?: HTMLElement; + /** CSS selector for the element to mount to */ + selector?: string; +} + +export interface AIModuleConfig { + /** Harbour API key for AI features */ + apiKey?: string; + /** Custom endpoint URL for AI services */ + endpoint?: string; +} + +export interface CollaborationModuleConfig { + /** URL for the collaboration provider */ + url?: string; + /** Type of collaboration provider */ + providerType?: string; + [key: string]: unknown; +} + +export interface ToolbarModuleConfig { + /** Toolbar selector */ + selector?: string | HTMLElement; + /** Toolbar groups to show */ + groups?: string[]; + /** Icons to show in the toolbar */ + icons?: Record; + /** Texts to override in the toolbar */ + texts?: Record; + /** Fonts to show in the toolbar */ + fonts?: string[] | null; + /** Whether to hide buttons */ + hideButtons?: boolean; + /** Whether toolbar is responsive to its container */ + responsiveToContainer?: boolean; + [key: string]: unknown; +} + +export interface SlashMenuModuleConfig { + /** Array of custom menu sections with items */ + customItems?: Array<{ title: string; items: unknown[] }>; + /** Function to customize menu items */ + menuProvider?: (defaultItems: unknown[]) => unknown[]; + /** Whether to include default menu items */ + includeDefaultItems?: boolean; +} + +export interface Modules { + /** Comments module configuration */ + comments?: CommentsModuleConfig; + /** AI module configuration */ + ai?: AIModuleConfig; + /** Collaboration module configuration */ + collaboration?: CollaborationModuleConfig; + /** Toolbar module configuration */ + toolbar?: ToolbarModuleConfig; + /** Slash menu module configuration */ + slashMenu?: SlashMenuModuleConfig; +} + +export interface LayoutEngineOptions { + /** Optional override for paginated track-changes rendering */ + trackedChanges?: { + mode?: 'final' | 'review' | 'original' | 'off'; + enabled?: boolean; + }; + [key: string]: unknown; +} + +export interface Config { + /** The ID of the SuperDoc */ + superdocId?: string; + /** The selector or element to mount the SuperDoc into */ + selector: string | HTMLElement; + /** The mode of the document */ + documentMode: DocumentMode; + /** The role of the user in this SuperDoc */ + role?: 'editor' | 'viewer' | 'suggester'; + /** The document to load. If a string, it will be treated as a URL. If a File or Blob, it will be used directly. */ + document?: object | string | File | Blob; + /** The documents to load -> Soon to be deprecated */ + documents?: Document[]; + /** The current user of this SuperDoc */ + user?: User; + /** All users of this SuperDoc (can be used for "@"-mentions) */ + users?: User[]; + /** Colors to use for user awareness */ + colors?: string[]; + /** Modules to load */ + modules?: Modules; + /** Top-level override for permission checks */ + permissionResolver?: PermissionResolver; + /** Optional DOM element to render the toolbar in */ + toolbar?: string; + /** Toolbar groups to show */ + toolbarGroups?: string[]; + /** Icons to show in the toolbar */ + toolbarIcons?: Record; + /** Texts to override in the toolbar */ + toolbarTexts?: Record; + /** Whether the SuperDoc is in development mode */ + isDev?: boolean; + /** Telemetry configuration */ + telemetry?: TelemetryConfig; + /** Layout engine overrides passed through to PresentationEditor */ + layoutEngineOptions?: LayoutEngineOptions; + /** Callback before an editor is created */ + onEditorBeforeCreate?: (editor: Editor) => void; + /** Callback after an editor is created */ + onEditorCreate?: (editor: Editor) => void; + /** Callback when a transaction is made */ + onTransaction?: (params: { editor: Editor; transaction: unknown; duration: number }) => void; + /** Callback after an editor is destroyed */ + onEditorDestroy?: () => void; + /** Callback when there is an error in the content */ + onContentError?: (params: { error: object; editor: Editor; documentId: string; file: File | Blob | null }) => void; + /** Callback when the SuperDoc is ready */ + onReady?: (editor: { superdoc: SuperDoc }) => void; + /** Callback when comments are updated */ + onCommentsUpdate?: (params: { type: string; data: object }) => void; + /** Callback when awareness is updated */ + onAwarenessUpdate?: (params: { context: SuperDoc; states: unknown[] }) => void; + /** Callback when the SuperDoc is locked */ + onLocked?: (params: { isLocked: boolean; lockedBy: User | null }) => void; + /** Callback when the PDF document is ready */ + onPdfDocumentReady?: () => void; + /** Callback when the sidebar is toggled */ + onSidebarToggle?: (isOpened: boolean) => void; + /** Callback when collaboration is ready */ + onCollaborationReady?: (params: { editor: Editor }) => void; + /** Callback when document is updated */ + onEditorUpdate?: (params: { editor: Editor }) => void; + /** Callback when an exception is thrown */ + onException?: (params: { error: Error }) => void; + /** Callback when the comments list is rendered */ + onCommentsListChange?: (params: { isRendered: boolean }) => void; + /** Callback when the list definitions change */ + onListDefinitionsChange?: (params: object) => void; + /** The format of the document (docx, pdf, html) */ + format?: string; + /** The extensions to load for the editor */ + editorExtensions?: Array>; + /** Whether the SuperDoc is internal */ + isInternal?: boolean; + /** The title of the SuperDoc */ + title?: string; + /** The conversations to load */ + conversations?: Array>; + /** Whether the SuperDoc is locked */ + isLocked?: boolean; + /** The function to handle image uploads */ + handleImageUpload?: (file: File) => Promise; + /** The user who locked the SuperDoc */ + lockedBy?: User | null; + /** Whether to show the ruler in the editor */ + rulers?: boolean; + /** Whether to suppress default styles in docx mode */ + suppressDefaultDocxStyles?: boolean; + /** Provided JSON to override content with */ + jsonOverride?: Record; + /** Whether to disable slash / right-click custom context menu */ + disableContextMenu?: boolean; + /** HTML content to initialize the editor with */ + html?: string; + /** Markdown content to initialize the editor with */ + markdown?: string; + /** Whether to enable debug mode */ + isDebug?: boolean; + /** CSP nonce for inline styles */ + cspNonce?: string; + /** Socket connection for collaboration */ + socket?: { cancelWebsocketRetry: () => void; disconnect: () => void; destroy: () => void }; + /** Whether to use the layout engine */ + useLayoutEngine?: boolean; + /** Callback when fonts are resolved */ + onFontsResolved?: ((data: unknown) => void) | null; + /** Callback for layout pipeline events */ + onLayoutPipelineEvent?: (payload: { type: string; data: object }) => void; +} + +/** + * SuperDoc type for callback references + * This is a minimal type to avoid circular dependencies. + * The full implementation is in SuperDoc.ts + * + * This interface represents the public API surface that external code commonly + * uses. It declares the most frequently accessed properties and methods, allowing + * the actual SuperDoc class (which extends EventEmitter and has many additional + * properties) to be assignable to this type without conflicts. + * + * Note: The `emit` method is typed as a generic function to maintain compatibility + * with EventEmitter's strongly-typed signature. The actual SuperDoc class has a + * more specific emit method from EventEmitter. + * + * We avoid an index signature `[key: string]: unknown` because it would conflict + * with EventEmitter's method signatures and prevent proper type checking. + */ +export interface SuperDoc { + // Core configuration and state + /** Configuration object */ + config: Config; + /** Current user */ + user: User; + /** All users who have access to this superdoc */ + users: User[]; + /** Unique ID for this SuperDoc instance */ + superdocId: string; + /** Version of SuperDoc */ + version: string; + /** Whether running in development mode */ + isDev: boolean; + + // Editor and document state + /** Currently active editor instance */ + activeEditor: Editor | null; + /** All comments in the document */ + comments: unknown[]; + /** Number of editors that have been initialized */ + readyEditors: number; + /** The number of editors required for this superdoc */ + readonly requiredNumberOfEditors: number; + /** Current state containing documents and users */ + readonly state: { documents: unknown[]; users: User[] }; + /** The SuperDoc container element */ + readonly element: HTMLElement | null; + + // Collaboration properties + /** Yjs document for collaboration */ + ydoc?: YDoc; + /** Whether this SuperDoc uses collaboration */ + isCollaborative?: boolean; + /** Pending collaboration saves counter */ + pendingCollaborationSaves: number; + + // Lock state + /** Whether the document is locked */ + isLocked: boolean; + /** User who locked the document */ + lockedBy: User | null; + + // Stores (typed as unknown to avoid deep circular dependencies with store types) + /** Comments store */ + commentsStore: unknown; + /** SuperDoc store */ + superdocStore: unknown; + + // User management methods + /** Add a user to the shared users list */ + addSharedUser(user: User): void; + /** Remove a user from the shared users list */ + removeSharedUser(email: string): void; + + // Editor management methods + /** Set the active editor */ + setActiveEditor(editor: Editor): void; + /** Focus the active editor or the first editor in the superdoc */ + focus(): void; + + // Document mode and state management + /** Set the document mode */ + setDocumentMode(type: DocumentMode): void; + /** Configure how tracked changes are displayed */ + setTrackedChangesPreferences(preferences?: { + mode?: 'review' | 'original' | 'final' | 'off'; + enabled?: boolean; + }): void; + + // Lock management + /** Set the document to locked or unlocked */ + setLocked(lock?: boolean): void; + /** Lock the current superdoc */ + lockSuperdoc(isLocked?: boolean, lockedBy?: User): void; + + // Permission checking + /** Determine whether the current configuration allows a given permission */ + canPerformPermission(params: { + permission: string; + role?: string; + isInternal?: boolean; + comment?: object | null; + trackedChange?: { commentId?: string; id?: string; comment?: object } | null; + }): boolean; + + // Search functionality + /** Search for text or regex in the active editor */ + search(text: string | RegExp): unknown[]; + /** Go to the next search result */ + goToSearchResult(match: unknown): void; + + // Export and content methods + /** Get the HTML content of all editors */ + getHTML(options?: object): string[]; + /** Export the superdoc to a file */ + export( + params?: ExportParams & { + additionalFiles?: Blob[]; + additionalFileNames?: string[]; + isFinalDoc?: boolean; + }, + ): Promise; + /** Export editors to DOCX format */ + exportEditorsToDOCX(options?: { + commentsType?: CommentsType; + isFinalDoc?: boolean; + fieldsHighlightColor?: string | null; + }): Promise; + + // Lifecycle methods + /** Save the superdoc if in collaboration mode */ + save(): Promise; + /** Destroy the superdoc instance */ + destroy(): void; + + /** + * Event emitter method + * The actual implementation has strongly typed events, but we use a generic + * signature here to avoid circular dependencies and maintain compatibility. + * The unknown[] allows any arguments while maintaining type safety. + */ + emit?(event: string, ...args: unknown[]): boolean; +} diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue index 0e04dedc1..e7ed77ee9 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -13,7 +13,7 @@ import { toolbarIcons } from '../../../../super-editor/src/components/toolbar/to import BlankDOCX from '@superdoc/common/data/blank.docx?url'; import * as pdfjsLib from 'pdfjs-dist/build/pdf.mjs'; import * as pdfjsViewer from 'pdfjs-dist/web/pdf_viewer.mjs'; -import { getWorkerSrcFromCDN } from '../../components/PdfViewer/pdf/pdf-adapter.js'; +import { getWorkerSrcFromCDN } from '../../components/PdfViewer/pdf/pdf-adapter'; // Or set worker globally outside the component. // pdfjsLib.GlobalWorkerOptions.workerSrc = new URL( diff --git a/packages/superdoc/src/helpers/floor.js b/packages/superdoc/src/helpers/floor.js deleted file mode 100644 index 20129179b..000000000 --- a/packages/superdoc/src/helpers/floor.js +++ /dev/null @@ -1,4 +0,0 @@ -export const floor = (val, precision) => { - const multiplier = 10 ** (precision || 0); - return Math.floor(val * multiplier) / multiplier; -}; diff --git a/packages/superdoc/src/helpers/floor.ts b/packages/superdoc/src/helpers/floor.ts new file mode 100644 index 000000000..b1f7a4d3f --- /dev/null +++ b/packages/superdoc/src/helpers/floor.ts @@ -0,0 +1,16 @@ +/** + * Floors a number to a specified precision + * + * @param val - The value to floor + * @param precision - The number of decimal places to round to (default: 0) + * @returns The floored value with the specified precision + * + * @example + * floor(3.456, 2) // Returns 3.45 + * floor(3.456, 1) // Returns 3.4 + * floor(3.456) // Returns 3 + */ +export const floor = (val: number, precision?: number): number => { + const multiplier = 10 ** (precision || 0); + return Math.floor(val * multiplier) / multiplier; +}; diff --git a/packages/superdoc/src/helpers/group-changes.js b/packages/superdoc/src/helpers/group-changes.js deleted file mode 100644 index b76151218..000000000 --- a/packages/superdoc/src/helpers/group-changes.js +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Track changes helper - * Combines replace transaction which is represented by insertion + deletion - * - * @param {Array} changes array of tracked changes - * @returns {Array} grouped track changes array - */ - -export const groupChanges = (changes) => { - const markMetaKeys = { - trackInsert: 'insertedMark', - trackDelete: 'deletionMark', - trackFormat: 'formatMark', - }; - const grouped = []; - - for (let i = 0; i < changes.length; i++) { - const c1 = changes[i]; - const c2 = changes[i + 1]; - const c1Key = markMetaKeys[c1.mark.type.name]; - - if (c1 && c2 && c1.to === c2.from && c1.mark.attrs.id === c2.mark.attrs.id) { - const c2Key = markMetaKeys[c2.mark.type.name]; - grouped.push({ - from: c1.from, - to: c2.to, - [c1Key]: c1, - [c2Key]: c2, - }); - i++; - } else { - grouped.push({ - from: c1.from, - to: c1.to, - [c1Key]: c1, - }); - } - } - return grouped; -}; diff --git a/packages/superdoc/src/helpers/group-changes.ts b/packages/superdoc/src/helpers/group-changes.ts new file mode 100644 index 000000000..b4f3851b7 --- /dev/null +++ b/packages/superdoc/src/helpers/group-changes.ts @@ -0,0 +1,86 @@ +import type { Mark } from 'prosemirror-model'; + +/** + * Represents a single tracked change in the document + */ +export interface Change { + /** Start position of the change */ + from: number; + /** End position of the change */ + to: number; + /** ProseMirror mark containing the change metadata */ + mark: Mark; +} + +/** + * Represents a grouped change that may combine insertion, deletion, and formatting + */ +export interface GroupedChange { + /** Start position of the grouped change */ + from: number; + /** End position of the grouped change */ + to: number; + /** Optional insertion mark */ + insertedMark?: Change; + /** Optional deletion mark */ + deletionMark?: Change; + /** Optional formatting mark */ + formatMark?: Change; +} + +/** + * Valid mark property keys for grouped changes + */ +type MarkKey = 'insertedMark' | 'deletionMark' | 'formatMark'; + +/** + * Track changes helper + * Combines replace transactions which are represented by insertion + deletion + * + * This function groups consecutive tracked changes that share the same ID and + * are adjacent in the document. When two changes with the same ID are found + * at adjacent positions, they are combined into a single GroupedChange object. + * + * @param changes - Array of tracked changes from the document + * @returns Grouped track changes array with combined adjacent changes + * + * @example + * const changes = [ + * { from: 0, to: 5, mark: insertMark }, + * { from: 5, to: 10, mark: deleteMark } + * ]; + * const grouped = groupChanges(changes); + * // Returns: [{ from: 0, to: 10, insertedMark: {...}, deletionMark: {...} }] + */ +export const groupChanges = (changes: Change[]): GroupedChange[] => { + const markMetaKeys: Record = { + trackInsert: 'insertedMark', + trackDelete: 'deletionMark', + trackFormat: 'formatMark', + }; + const grouped: GroupedChange[] = []; + + for (let i = 0; i < changes.length; i++) { + const c1 = changes[i]; + const c2 = changes[i + 1]; + const c1Key = markMetaKeys[c1.mark.type.name]; + + if (c1 && c2 && c1.to === c2.from && c1.mark.attrs.id === c2.mark.attrs.id) { + const c2Key = markMetaKeys[c2.mark.type.name]; + grouped.push({ + from: c1.from, + to: c2.to, + [c1Key]: c1, + [c2Key]: c2, + }); + i++; + } else { + grouped.push({ + from: c1.from, + to: c1.to, + [c1Key]: c1, + }); + } + } + return grouped; +}; diff --git a/packages/superdoc/src/helpers/use-selection.js b/packages/superdoc/src/helpers/use-selection.js deleted file mode 100644 index 309206484..000000000 --- a/packages/superdoc/src/helpers/use-selection.js +++ /dev/null @@ -1,50 +0,0 @@ -import { ref, reactive, toRaw } from 'vue'; - -export default function useSelection(params) { - const documentId = ref(params.documentId); - const page = ref(params.page); - const selectionBounds = reactive(params.selectionBounds || {}); - const source = ref(params.source); - - /* Get the ID of the container */ - const getContainerId = () => `${documentId.value}-page-${page.value}`; - - /* Get the location of the container */ - const getContainerLocation = (parentContainer) => { - if (!parentContainer) return { top: 0, left: 0 }; - const parentBounds = parentContainer.getBoundingClientRect(); - const container = document.getElementById(getContainerId()); - - let containerBounds = { - top: 0, - left: 0, - }; - if (container) containerBounds = container.getBoundingClientRect(); - - return { - top: Number((containerBounds.top - parentBounds.top).toFixed(3)), - left: Number((containerBounds.left - parentBounds.left).toFixed(3)), - }; - }; - - const getValues = () => { - return { - documentId: documentId.value, - page: page.value, - selectionBounds: toRaw(selectionBounds), - source: source.value, - }; - }; - - return { - documentId, - page, - selectionBounds, - source, - - // Actions - getValues, - getContainerId, - getContainerLocation, - }; -} diff --git a/packages/superdoc/src/helpers/use-selection.ts b/packages/superdoc/src/helpers/use-selection.ts new file mode 100644 index 000000000..36d303d96 --- /dev/null +++ b/packages/superdoc/src/helpers/use-selection.ts @@ -0,0 +1,149 @@ +import { ref, reactive, toRaw, type Ref } from 'vue'; + +/** + * Bounds of a text selection within a document page + */ +export interface SelectionBounds { + /** Top position in pixels */ + top?: number; + /** Left position in pixels */ + left?: number; + /** Width in pixels */ + width?: number; + /** Height in pixels */ + height?: number; +} + +/** + * Parameters for initializing the selection composable + */ +export interface UseSelectionParams { + /** Unique identifier for the document */ + documentId: string; + /** Page number (1-indexed) */ + page: number; + /** Optional selection bounds on the page */ + selectionBounds?: SelectionBounds; + /** Optional source identifier for the selection */ + source?: string; +} + +/** + * Container location relative to parent element + */ +export interface ContainerLocation { + /** Top offset in pixels */ + top: number; + /** Left offset in pixels */ + left: number; +} + +/** + * Return type of the useSelection composable + */ +export interface UseSelectionReturn { + /** Reactive reference to document ID */ + documentId: Ref; + /** Reactive reference to page number */ + page: Ref; + /** Reactive selection bounds */ + selectionBounds: SelectionBounds; + /** Reactive reference to source identifier */ + source: Ref; + /** Get all current values as plain objects */ + getValues: () => { + documentId: string; + page: number; + selectionBounds: SelectionBounds; + source: string | undefined; + }; + /** Get the container element ID for the current page */ + getContainerId: () => string; + /** Get the container's position relative to a parent element */ + getContainerLocation: (parentContainer?: HTMLElement | null) => ContainerLocation; +} + +/** + * Vue composable for managing document text selection state + * + * This composable provides reactive state management for tracking text selections + * within a document viewer, including the selected bounds, page number, and helper + * methods for calculating container positions. + * + * @param params - Selection initialization parameters + * @returns Selection state and utility methods + * + * @example + * const selection = useSelection({ + * documentId: 'doc-123', + * page: 1, + * selectionBounds: { top: 100, left: 50, width: 200, height: 20 } + * }); + * + * const containerId = selection.getContainerId(); // 'doc-123-page-1' + */ +export default function useSelection(params: UseSelectionParams): UseSelectionReturn { + const documentId = ref(params.documentId); + const page = ref(params.page); + const selectionBounds = reactive(params.selectionBounds || {}); + const source = ref(params.source); + + /** + * Get the DOM element ID for the current page container + * + * @returns Element ID in the format '{documentId}-page-{pageNumber}' + */ + const getContainerId = (): string => `${documentId.value}-page-${page.value}`; + + /** + * Get the location of the page container relative to a parent element + * + * Calculates the offset position of the current page container relative to + * a parent container element. Useful for positioning overlays or annotations. + * + * @param parentContainer - Parent element to calculate relative position from + * @returns Top and left offsets in pixels, rounded to 3 decimal places + */ + const getContainerLocation = (parentContainer?: HTMLElement | null): ContainerLocation => { + if (!parentContainer) return { top: 0, left: 0 }; + const parentBounds = parentContainer.getBoundingClientRect(); + const container = document.getElementById(getContainerId()); + + let containerBounds = { + top: 0, + left: 0, + }; + if (container) containerBounds = container.getBoundingClientRect(); + + return { + top: Number((containerBounds.top - parentBounds.top).toFixed(3)), + left: Number((containerBounds.left - parentBounds.left).toFixed(3)), + }; + }; + + /** + * Get all selection values as plain objects (non-reactive) + * + * @returns Plain object containing all current selection state values + */ + const getValues = () => { + return { + documentId: documentId.value, + page: page.value, + selectionBounds: toRaw(selectionBounds), + source: source.value, + }; + }; + + return { + documentId, + page, + selectionBounds, + source, + + // Actions + getValues, + getContainerId, + getContainerLocation, + }; +} diff --git a/packages/superdoc/src/icons.js b/packages/superdoc/src/icons.ts similarity index 93% rename from packages/superdoc/src/icons.js rename to packages/superdoc/src/icons.ts index f18e89803..1ebe92f86 100644 --- a/packages/superdoc/src/icons.js +++ b/packages/superdoc/src/icons.ts @@ -6,7 +6,7 @@ import checkIconSvg from '@superdoc/common/icons/check-solid.svg?raw'; import xmarkIconSvg from '@superdoc/common/icons/xmark-solid.svg?raw'; import ellipsisVerticalSvg from '@superdoc/common/icons/ellipsis-vertical-solid.svg?raw'; -export const superdocIcons = { +export const superdocIcons: Record = { comment: commentIconSvg, caretDown: caretDownIconSvg, internal: userCheckIconSvg, diff --git a/packages/superdoc/src/index.js b/packages/superdoc/src/index.ts similarity index 89% rename from packages/superdoc/src/index.js rename to packages/superdoc/src/index.ts index 9c9f1aa4e..0b70cb619 100644 --- a/packages/superdoc/src/index.js +++ b/packages/superdoc/src/index.ts @@ -17,7 +17,7 @@ import { DOCX, PDF, HTML, getFileObject, compareVersions } from '@superdoc/commo import BlankDOCX from '@superdoc/common/data/blank.docx?url'; // Beta channel note: keep this file touched so CI picks up prerelease runs. -export { SuperDoc } from './core/SuperDoc.js'; +export { SuperDoc } from './core/SuperDoc'; export { BlankDOCX, getFileObject, @@ -45,3 +45,6 @@ export { Extensions, registeredHandlers, }; + +// Re-export types +export type * from './core/types/index.js'; diff --git a/packages/superdoc/src/main.js b/packages/superdoc/src/main.ts similarity index 100% rename from packages/superdoc/src/main.js rename to packages/superdoc/src/main.ts diff --git a/packages/superdoc/src/stores/comments-store.js b/packages/superdoc/src/stores/comments-store.js deleted file mode 100644 index 5782e68de..000000000 --- a/packages/superdoc/src/stores/comments-store.js +++ /dev/null @@ -1,682 +0,0 @@ -import { defineStore } from 'pinia'; -import { ref, reactive, computed } from 'vue'; -import { comments_module_events } from '@superdoc/common'; -import { useSuperdocStore } from '@superdoc/stores/superdoc-store'; -import { syncCommentsToClients } from '../core/collaboration/helpers.js'; -import { - Editor, - trackChangesHelpers, - TrackChangesBasePluginKey, - CommentsPluginKey, -} from '@harbour-enterprises/super-editor'; -import { getRichTextExtensions } from '@harbour-enterprises/super-editor'; -import useComment from '@superdoc/components/CommentsLayer/use-comment'; -import { groupChanges } from '../helpers/group-changes.js'; - -export const useCommentsStore = defineStore('comments', () => { - const superdocStore = useSuperdocStore(); - const commentsConfig = reactive({ - name: 'comments', - readOnly: false, - allowResolve: true, - showResolved: false, - }); - - const isDebugging = false; - const debounceTimers = {}; - - const COMMENT_EVENTS = comments_module_events; - const hasInitializedComments = ref(false); - const hasSyncedCollaborationComments = ref(false); - const commentsParentElement = ref(null); - const hasInitializedLocations = ref(false); - const activeComment = ref(null); - const editingCommentId = ref(null); - const commentDialogs = ref([]); - const overlappingComments = ref([]); - const overlappedIds = new Set([]); - const suppressInternalExternal = ref(true); - const currentCommentText = ref(''); - const commentsList = ref([]); - const isCommentsListVisible = ref(false); - const editorCommentIds = ref([]); - const editorCommentPositions = ref({}); - const isCommentHighlighted = ref(false); - - // Floating comments - const floatingCommentsOffset = ref(0); - const sortedConversations = ref([]); - const visibleConversations = ref([]); - const skipSelectionUpdate = ref(false); - const isFloatingCommentsReady = ref(false); - const generalCommentIds = ref([]); - - const pendingComment = ref(null); - - /** - * Initialize the store - * - * @param {Object} config The comments module config from SuperDoc - * @returns {void} - */ - const init = (config = {}) => { - const updatedConfig = { ...commentsConfig, ...config }; - Object.assign(commentsConfig, updatedConfig); - - suppressInternalExternal.value = commentsConfig.suppressInternalExternal || false; - - // Map initial comments state - if (config.comments && config.comments.length) { - commentsList.value = config.comments?.map((c) => useComment(c)) || []; - } - }; - - /** - * Get a comment by either ID or imported ID - * - * @param {string} id The comment ID - * @returns {Object} The comment object - */ - const getComment = (id) => { - if (id === undefined || id === null) return null; - return commentsList.value.find((c) => c.commentId == id || c.importedId == id); - }; - - /** - * Set the active comment or clear all active comments - * - * @param {string | undefined | null} id The comment ID - * @returns {void} - */ - const setActiveComment = (superdoc, id) => { - // If no ID, we clear any focused comments - if (id === undefined || id === null) { - activeComment.value = null; - if (superdoc.activeEditor) { - superdoc.activeEditor.commands?.setActiveComment({ commentId: null }); - } - return; - } - - const comment = getComment(id); - if (comment) activeComment.value = comment.commentId; - if (superdoc.activeEditor) { - superdoc.activeEditor.commands?.setActiveComment({ commentId: activeComment.value }); - } - }; - - /** - * Called when a tracked change is updated. Creates a new comment if necessary, - * or updates an existing tracked-change comment. - * - * @param {Object} param0 - * @param {Object} param0.superdoc The SuperDoc instance - * @param {Object} param0.params The tracked change params - * @returns {void} - */ - const handleTrackedChangeUpdate = ({ superdoc, params }) => { - const { - event, - changeId, - trackedChangeText, - trackedChangeType, - deletedText, - authorEmail, - authorImage, - date, - author: authorName, - importedAuthor, - documentId, - coords, - } = params; - - const comment = getPendingComment({ - documentId, - commentId: changeId, - trackedChange: true, - trackedChangeText, - trackedChangeType, - deletedText, - createdTime: date, - creatorName: authorName, - creatorEmail: authorEmail, - creatorImage: authorImage, - isInternal: false, - importedAuthor, - selection: { - selectionBounds: coords, - }, - }); - - if (event === 'add') { - // If this is a new tracked change, add it to our comments - addComment({ superdoc, comment }); - } else if (event === 'update') { - // If we have an update event, simply update the composable comment - const existingTrackedChange = commentsList.value.find((comment) => comment.commentId === changeId); - if (!existingTrackedChange) return; - - existingTrackedChange.trackedChangeText = trackedChangeText; - - if (deletedText) { - existingTrackedChange.deletedText = deletedText; - } - - const emitData = { - type: COMMENT_EVENTS.UPDATE, - comment: existingTrackedChange.getValues(), - }; - - syncCommentsToClients(superdoc, emitData); - debounceEmit(changeId, emitData, superdoc); - } - }; - - const debounceEmit = (commentId, event, superdoc, delay = 1000) => { - if (debounceTimers[commentId]) { - clearTimeout(debounceTimers[commentId]); - } - - debounceTimers[commentId] = setTimeout(() => { - if (superdoc) { - superdoc.emit('comments-update', event); - } - delete debounceTimers[commentId]; - }, delay); - }; - - const showAddComment = (superdoc) => { - const event = { type: COMMENT_EVENTS.PENDING }; - superdoc.emit('comments-update', event); - - const selection = { ...superdocStore.activeSelection }; - selection.selectionBounds = { ...selection.selectionBounds }; - - if (superdocStore.selectionPosition?.source) { - superdocStore.selectionPosition.source = null; - } - - pendingComment.value = getPendingComment({ selection, documentId: selection.documentId, parentCommentId: null }); - if (!superdoc.config.isInternal) pendingComment.value.isInternal = false; - - if (superdoc.activeEditor?.commands) { - superdoc.activeEditor.commands.insertComment({ - ...pendingComment.value.getValues(), - commentId: 'pending', - skipEmit: true, - }); - } - - if (pendingComment.value.selection.source === 'super-editor' && superdocStore.selectionPosition) { - superdocStore.selectionPosition.source = 'super-editor'; - } - - activeComment.value = pendingComment.value.commentID; - }; - - /** - * Generate the comments list separating resolved and active - * We only return parent comments here, since CommentDialog.vue will handle threaded comments - */ - const getGroupedComments = computed(() => { - const parentComments = []; - const resolvedComments = []; - const childCommentMap = new Map(); - - commentsList.value.forEach((comment) => { - // Track resolved comments - if (comment.resolvedTime) { - resolvedComments.push(comment); - } - - // Track parent comments - else if (!comment.parentCommentId && !comment.resolvedTime) { - parentComments.push({ ...comment }); - } - - // Track child comments (threaded comments) - else if (comment.parentCommentId) { - if (!childCommentMap.has(comment.parentCommentId)) { - childCommentMap.set(comment.parentCommentId, []); - } - childCommentMap.get(comment.parentCommentId).push(comment); - } - }); - - // Return only parent comments - const sortedParentComments = parentComments.sort((a, b) => a.createdTime - b.createdTime); - const sortedResolvedComments = resolvedComments.sort((a, b) => a.createdTime - b.createdTime); - - return { - parentComments: sortedParentComments, - resolvedComments: sortedResolvedComments, - }; - }); - - const hasOverlapId = (id) => overlappedIds.includes(id); - const documentsWithConverations = computed(() => { - return superdocStore.documents; - }); - - const getConfig = computed(() => { - return commentsConfig; - }); - - const getCommentLocation = (selection, parent) => { - const containerBounds = selection.getContainerLocation(parent); - const top = containerBounds.top + selection.selectionBounds.top; - const left = containerBounds.left + selection.selectionBounds.left; - return { - top: top, - left: left, - }; - }; - - /** - * Get a new pending comment - * - * @param {Object} param0 - * @param {Object} param0.selection The selection object - * @param {String} param0.documentId The document ID - * @param {String} param0.parentCommentId The parent comment - * @returns {Object} The new comment object - */ - const getPendingComment = ({ selection, documentId, parentCommentId, ...options }) => { - return _getNewcomment({ selection, documentId, parentCommentId, ...options }); - }; - - /** - * Get the new comment object - * - * @param {Object} param0 - * @param {Object} param0.selection The selection object - * @param {String} param0.documentId The document ID - * @param {String} param0.parentCommentId The parent comment ID - * @returns {Object} The new comment object - */ - const _getNewcomment = ({ selection, documentId, parentCommentId, ...options }) => { - let activeDocument; - if (documentId) activeDocument = superdocStore.getDocument(documentId); - else if (selection) activeDocument = superdocStore.getDocument(selection.documentId); - - if (!activeDocument) activeDocument = superdocStore.documents[0]; - - return useComment({ - fileId: activeDocument.id, - fileType: activeDocument.type, - parentCommentId, - creatorEmail: superdocStore.user.email, - creatorName: superdocStore.user.name, - creatorImage: superdocStore.user.image, - commentText: currentCommentText.value, - selection, - ...options, - }); - }; - - /** - * Remove the pending comment - * - * @returns {void} - */ - const removePendingComment = (superdoc) => { - currentCommentText.value = ''; - pendingComment.value = null; - activeComment.value = null; - superdocStore.selectionPosition = null; - - superdoc.activeEditor?.commands.removeComment({ commentId: 'pending' }); - }; - - /** - * Add a new comment to the document - * - * @param {Object} param0 - * @param {Object} param0.superdoc The SuperDoc instance - * @returns {void} - */ - const addComment = ({ superdoc, comment, skipEditorUpdate = false }) => { - let parentComment = commentsList.value.find((c) => c.commentId === activeComment.value); - if (!parentComment) parentComment = comment; - - const newComment = useComment(comment.getValues()); - - if (pendingComment.value) newComment.setText({ text: currentCommentText.value, suppressUpdate: true }); - else newComment.setText({ text: comment.commentText, suppressUpdate: true }); - newComment.selection.source = pendingComment.value?.selection?.source; - - // Set isInternal flag - if (parentComment) { - const isParentInternal = parentComment.isInternal; - newComment.isInternal = isParentInternal; - } - - // If the current user is not internal, set the comment to external - if (!superdoc.config.isInternal) newComment.isInternal = false; - - // Add the new comments to our global list - commentsList.value.push(newComment); - - // Clean up the pending comment - removePendingComment(superdoc); - - // If this is not a tracked change, and it belongs to a Super Editor, and its not a child comment - // We need to let the editor know about the new comment - if (!skipEditorUpdate && !comment.trackedChange && superdoc.activeEditor?.commands && !comment.parentCommentId) { - // Add the comment to the active editor - superdoc.activeEditor.commands.insertComment({ ...newComment.getValues(), skipEmit: true }); - } - - const event = { type: COMMENT_EVENTS.ADD, comment: newComment.getValues() }; - - // If collaboration is enabled, sync the comments to all clients - syncCommentsToClients(superdoc, event); - - // Emit event for end users - superdoc.emit('comments-update', event); - }; - - const deleteComment = ({ commentId: commentIdToDelete, superdoc }) => { - const commentIndex = commentsList.value.findIndex((c) => c.commentId === commentIdToDelete); - const comment = commentsList.value[commentIndex]; - const { commentId, importedId } = comment; - const { fileId } = comment; - - superdoc.activeEditor?.commands?.removeComment({ commentId, importedId }); - - // Remove the current comment - commentsList.value.splice(commentIndex, 1); - - // Remove any child comments of the removed comment - const childCommentIds = commentsList.value - .filter((c) => c.parentCommentId === commentId) - .map((c) => c.commentId || c.importedId); - commentsList.value = commentsList.value.filter((c) => !childCommentIds.includes(c.commentId)); - - const event = { - type: COMMENT_EVENTS.DELETED, - comment: comment.getValues(), - changes: [{ key: 'deleted', commentId, fileId }], - }; - - superdoc.emit('comments-update', event); - syncCommentsToClients(superdoc, event); - }; - - /** - * Cancel the pending comment - * - * @returns {void} - */ - const cancelComment = (superdoc) => { - removePendingComment(superdoc); - }; - - /** - * Initialize loaded comments into SuperDoc by mapping the imported - * comment data to SuperDoc useComment objects. - * - * Updates the commentsList ref with the new comments. - * - * @param {Object} param0 - * @param {Array} param0.comments The comments to be loaded - * @param {String} param0.documentId The document ID - * @returns {void} - */ - const processLoadedDocxComments = async ({ superdoc, editor, comments, documentId }) => { - const document = superdocStore.getDocument(documentId); - - comments.forEach((comment) => { - const htmlContent = getHtmlFromComment(comment.textJson); - - if (!htmlContent && !comment.trackedChange) { - return; - } - - const creatorName = comment.creatorName.replace('(imported)', ''); - const importedName = `${creatorName} (imported)`; - const newComment = useComment({ - fileId: documentId, - fileType: document.type, - commentId: comment.commentId, - isInternal: false, - parentCommentId: comment.parentCommentId, - creatorName, - createdTime: comment.createdTime, - creatorEmail: comment.creatorEmail, - importedAuthor: { - name: importedName, - email: comment.creatorEmail, - }, - commentText: getHtmlFromComment(comment.textJson), - resolvedTime: comment.isDone ? Date.now() : null, - resolvedByEmail: comment.isDone ? comment.creatorEmail : null, - resolvedByName: comment.isDone ? importedName : null, - trackedChange: comment.trackedChange || false, - trackedChangeText: comment.trackedChangeText, - trackedChangeType: comment.trackedChangeType, - deletedText: comment.trackedDeletedText, - }); - - addComment({ superdoc, comment: newComment }); - }); - - setTimeout(() => { - // do not block the first rendering of the doc - // and create comments asynchronously. - createCommentForTrackChanges(editor); - }, 0); - }; - - const createCommentForTrackChanges = (editor) => { - let trackedChanges = trackChangesHelpers.getTrackChanges(editor.state); - - const groupedChanges = groupChanges(trackedChanges); - - // Create comments for tracked changes - // that do not have a corresponding comment (created in Word). - const { tr } = editor.view.state; - const { dispatch } = editor.view; - - groupedChanges.forEach(({ insertedMark, deletionMark, formatMark }, index) => { - console.debug(`Create comment for track change: ${index}`); - const foundComment = commentsList.value.find( - (i) => - i.commentId === insertedMark?.mark.attrs.id || - i.commentId === deletionMark?.mark.attrs.id || - i.commentId === formatMark?.mark.attrs.id, - ); - const isLastIteration = trackedChanges.length === index + 1; - - if (foundComment) { - if (isLastIteration) { - tr.setMeta(CommentsPluginKey, { type: 'force' }); - } - return; - } - - if (insertedMark || deletionMark || formatMark) { - const trackChangesPayload = { - ...(insertedMark && { insertedMark: insertedMark.mark }), - ...(deletionMark && { deletionMark: deletionMark.mark }), - ...(formatMark && { formatMark: formatMark.mark }), - }; - - if (isLastIteration) tr.setMeta(CommentsPluginKey, { type: 'force' }); - tr.setMeta(CommentsPluginKey, { type: 'forceTrackChanges' }); - tr.setMeta(TrackChangesBasePluginKey, trackChangesPayload); - } - dispatch(tr); - }); - }; - - const translateCommentsForExport = () => { - const processedComments = []; - commentsList.value.forEach((comment) => { - const values = comment.getValues(); - const richText = values.commentText; - const schema = convertHtmlToSchema(richText); - processedComments.push({ - ...values, - commentJSON: schema, - }); - }); - return processedComments; - }; - - const convertHtmlToSchema = (commentHTML) => { - const div = document.createElement('div'); - div.innerHTML = commentHTML; - const editor = new Editor({ - mode: 'text', - isHeadless: true, - content: div, - extensions: getRichTextExtensions(), - }); - return editor.getJSON().content[0]; - }; - - /** - * Triggered when the editor locations are updated - * Updates floating comment locations from the editor - * - * @param {DOMElement} parentElement The parent element of the editor - * @returns {void} - */ - const handleEditorLocationsUpdate = (allCommentPositions) => { - editorCommentPositions.value = allCommentPositions || {}; - }; - - const getFloatingComments = computed(() => { - const comments = getGroupedComments.value?.parentComments - .filter((c) => !c.resolvedTime) - .filter((c) => { - const keys = Object.keys(editorCommentPositions.value); - const isPdfComment = c.selection?.source !== 'super-editor'; - if (isPdfComment) return true; - const commentKey = c.commentId || c.importedId; - return keys.includes(commentKey); - }); - return comments; - }); - - /** - * Get HTML content from the comment text JSON (which uses DOCX schema) - * - * @param {Object} commentTextJson The comment text JSON - * @returns {string} The HTML content - */ - const normalizeCommentForEditor = (node) => { - if (!node || typeof node !== 'object') return node; - - const cloneMarks = (marks) => - Array.isArray(marks) - ? marks.filter(Boolean).map((mark) => ({ - ...mark, - attrs: mark?.attrs ? { ...mark.attrs } : undefined, - })) - : undefined; - - const cloneAttrs = (attrs) => (attrs && typeof attrs === 'object' ? { ...attrs } : undefined); - - if (!Array.isArray(node.content)) { - return { - type: node.type, - ...(node.text !== undefined ? { text: node.text } : {}), - ...(node.attrs ? { attrs: cloneAttrs(node.attrs) } : {}), - ...(node.marks ? { marks: cloneMarks(node.marks) } : {}), - }; - } - - const normalizedChildren = node.content - .map((child) => normalizeCommentForEditor(child)) - .flat() - .filter(Boolean); - - if (node.type === 'run') { - return normalizedChildren; - } - - return { - type: node.type, - ...(node.attrs ? { attrs: cloneAttrs(node.attrs) } : {}), - ...(node.marks ? { marks: cloneMarks(node.marks) } : {}), - content: normalizedChildren, - }; - }; - - const getHtmlFromComment = (commentTextJson) => { - // If no content, we can't convert and its not a valid comment - if (!commentTextJson.content?.length) return; - - try { - const normalizedContent = normalizeCommentForEditor(commentTextJson); - const schemaContent = Array.isArray(normalizedContent) ? normalizedContent[0] : normalizedContent; - if (!schemaContent.content.length) return null; - const editor = new Editor({ - mode: 'text', - isHeadless: true, - content: schemaContent, - loadFromSchema: true, - extensions: getRichTextExtensions(), - }); - return editor.getHTML(); - } catch (error) { - console.warn('Failed to convert comment', error); - return; - } - }; - - return { - COMMENT_EVENTS, - isDebugging, - hasInitializedComments, - hasSyncedCollaborationComments, - editingCommentId, - activeComment, - commentDialogs, - overlappingComments, - overlappedIds, - suppressInternalExternal, - pendingComment, - currentCommentText, - commentsList, - isCommentsListVisible, - generalCommentIds, - editorCommentIds, - commentsParentElement, - editorCommentPositions, - hasInitializedLocations, - isCommentHighlighted, - - // Floating comments - floatingCommentsOffset, - sortedConversations, - visibleConversations, - skipSelectionUpdate, - isFloatingCommentsReady, - - // Getters - getConfig, - documentsWithConverations, - getGroupedComments, - getFloatingComments, - - // Actions - init, - getComment, - setActiveComment, - getCommentLocation, - hasOverlapId, - getPendingComment, - showAddComment, - addComment, - cancelComment, - deleteComment, - removePendingComment, - processLoadedDocxComments, - translateCommentsForExport, - handleEditorLocationsUpdate, - handleTrackedChangeUpdate, - }; -}); diff --git a/packages/superdoc/src/stores/comments-store.test.js b/packages/superdoc/src/stores/comments-store.test.ts similarity index 85% rename from packages/superdoc/src/stores/comments-store.test.js rename to packages/superdoc/src/stores/comments-store.test.ts index b2efe9c72..a0514329e 100644 --- a/packages/superdoc/src/stores/comments-store.test.js +++ b/packages/superdoc/src/stores/comments-store.test.ts @@ -3,11 +3,11 @@ import { createPinia, setActivePinia, defineStore } from 'pinia'; import { ref, reactive } from 'vue'; vi.mock('./superdoc-store.js', () => { - const documents = ref([]); + const documents = ref([]); const user = reactive({ name: 'Alice', email: 'alice@example.com' }); const activeSelection = reactive({ documentId: 'doc-1', selectionBounds: {} }); - const selectionPosition = reactive({ source: null }); - const getDocument = (id) => documents.value.find((doc) => doc.id === id); + const selectionPosition = reactive({ source: null as string | null }); + const getDocument = (id: string) => documents.value.find((doc: { id: string }) => doc.id === id); const useMockStore = defineStore('superdoc', () => ({ documents, @@ -33,7 +33,7 @@ vi.mock('./superdoc-store.js', () => { }); vi.mock('@superdoc/components/CommentsLayer/use-comment', () => { - const mock = vi.fn((params = {}) => { + const mock = vi.fn((params: { commentId?: string; selection?: unknown; isInternal?: boolean } = {}) => { const selection = params.selection || { source: 'mock', selectionBounds: {} }; return { ...params, @@ -87,11 +87,11 @@ import { comments_module_events } from '@superdoc/common'; import useComment from '@superdoc/components/CommentsLayer/use-comment'; import { syncCommentsToClients } from '../core/collaboration/helpers.js'; -const useCommentMock = useComment; -const syncCommentsToClientsMock = syncCommentsToClients; +const useCommentMock = useComment as ReturnType; +const syncCommentsToClientsMock = syncCommentsToClients as ReturnType; describe('comments-store', () => { - let store; + let store: ReturnType; beforeEach(() => { vi.useFakeTimers(); @@ -114,8 +114,8 @@ describe('comments-store', () => { comments: [initialComment], }); - expect(store.getConfig.readOnly).toBe(true); - expect(store.getConfig.allowResolve).toBe(false); + expect((store.getConfig as { readOnly: boolean }).readOnly).toBe(true); + expect((store.getConfig as { allowResolve: boolean }).allowResolve).toBe(false); expect(store.commentsList.length).toBe(1); expect(useCommentMock).toHaveBeenCalledWith(initialComment); }); @@ -143,11 +143,11 @@ describe('comments-store', () => { const comment = { commentId: 'comment-1' }; store.commentsList = [comment]; - store.setActiveComment(superdoc, 'comment-1'); + store.setActiveComment(superdoc as never, 'comment-1'); expect(store.activeComment).toBe('comment-1'); expect(setActiveCommentSpy).toHaveBeenCalledWith({ commentId: 'comment-1' }); - store.setActiveComment(superdoc, null); + store.setActiveComment(superdoc as never, null); expect(store.activeComment).toBeNull(); expect(setActiveCommentSpy).toHaveBeenCalledWith({ commentId: null }); }); @@ -166,7 +166,7 @@ describe('comments-store', () => { store.commentsList = [existingComment]; store.handleTrackedChangeUpdate({ - superdoc, + superdoc: superdoc as never, params: { event: 'update', changeId: 'change-1', @@ -182,8 +182,8 @@ describe('comments-store', () => { }, }); - expect(existingComment.trackedChangeText).toBe('new text'); - expect(existingComment.deletedText).toBe('removed'); + expect((existingComment as { trackedChangeText: string }).trackedChangeText).toBe('new text'); + expect((existingComment as { deletedText: string }).deletedText).toBe('removed'); expect(syncCommentsToClientsMock).toHaveBeenCalledWith( superdoc, expect.objectContaining({ @@ -212,7 +212,7 @@ describe('comments-store', () => { const now = Date.now(); store.processLoadedDocxComments({ - superdoc: __mockSuperdoc, + superdoc: __mockSuperdoc as never, editor: null, comments: [ { @@ -264,6 +264,6 @@ describe('comments-store', () => { documentId: 'doc-1', }); - expect(store.commentsList[0].createdTime).toBe(now); + expect((store.commentsList[0] as { createdTime: number }).createdTime).toBe(now); }); }); diff --git a/packages/superdoc/src/stores/comments-store.ts b/packages/superdoc/src/stores/comments-store.ts new file mode 100644 index 000000000..88637b63b --- /dev/null +++ b/packages/superdoc/src/stores/comments-store.ts @@ -0,0 +1,1174 @@ +import { defineStore } from 'pinia'; +import { ref, reactive, computed, type Ref, type UnwrapNestedRefs, type ComputedRef } from 'vue'; +import { comments_module_events } from '@superdoc/common'; +import { useSuperdocStore } from './superdoc-store'; +import { syncCommentsToClients } from '../core/collaboration/helpers'; +import { + Editor as EditorClass, + trackChangesHelpers, + TrackChangesBasePluginKey, + CommentsPluginKey, +} from '@harbour-enterprises/super-editor'; +import { getRichTextExtensions } from '@harbour-enterprises/super-editor'; +import useComment, { type UseCommentReturn, type UseCommentParams } from '../components/CommentsLayer/use-comment'; +import { groupChanges, type GroupedChange } from '../helpers/group-changes'; +import type { SuperDoc, Editor, CommentsModuleConfig } from '../core/types'; +import type { UseDocumentReturn } from '../composables/use-document'; +import type { SelectionBounds } from './types'; + +/** + * Editor commands interface for comment operations + * Defines the command methods available on the editor for managing comments + */ +interface EditorCommentCommands { + setActiveComment: (params: { commentId: string | null }) => void; + insertComment: (params: Record & { commentId: string; skipEmit?: boolean }) => void; + removeComment: (params: { commentId: string; importedId?: string }) => void; +} + +/** + * Extended editor interface with comment commands + */ +interface EditorWithCommentCommands extends Editor { + commands: EditorCommentCommands; +} + +/** + * ProseMirror transaction interface + */ +interface PMTransaction { + setMeta: (key: unknown, value: unknown) => PMTransaction; +} + +/** + * ProseMirror editor state interface + */ +interface PMEditorState { + tr: PMTransaction; +} + +/** + * ProseMirror view interface + */ +interface PMView { + state: PMEditorState; + dispatch: (tr: PMTransaction) => void; +} + +/** + * Extended editor interface with ProseMirror view access + */ +interface EditorWithView extends Editor { + view: PMView; + state: unknown; +} + +/** + * Comments module configuration + */ +interface CommentsConfig { + /** Name of the module */ + name: string; + /** Whether comments are read-only */ + readOnly: boolean; + /** Whether users can resolve comments */ + allowResolve: boolean; + /** Whether to show resolved comments */ + showResolved: boolean; + /** Whether to suppress internal/external distinction */ + suppressInternalExternal?: boolean; + /** Initial comments to load */ + comments?: UseCommentParams[]; +} + +/** + * Selection object for creating comments + */ +interface Selection { + /** Document ID */ + documentId: string; + /** Page number */ + page: number; + /** Selection bounds on the page */ + selectionBounds: SelectionBounds; + /** Source of the selection */ + source?: string; +} + +/** + * Coordinates for tracked change events + */ +interface TrackedChangeCoords { + /** Top position */ + top: number; + /** Left position */ + left: number; + /** Width */ + width: number; + /** Height */ + height: number; + [key: string]: unknown; +} + +/** + * Parameters for tracked change updates + */ +interface TrackedChangeParams { + /** Event type ('add' or 'update') */ + event: 'add' | 'update'; + /** Change ID */ + changeId: string; + /** Text of the tracked change */ + trackedChangeText?: string; + /** Type of tracked change */ + trackedChangeType?: 'trackInsert' | 'trackDelete' | 'both' | 'trackFormat'; + /** Text that was deleted */ + deletedText?: string; + /** Author email */ + authorEmail?: string; + /** Author image/avatar */ + authorImage?: string; + /** Date of the change */ + date?: number; + /** Author name */ + author?: string; + /** Imported author information */ + importedAuthor?: { name?: string; email?: string }; + /** Document ID */ + documentId: string; + /** Coordinates of the change */ + coords?: TrackedChangeCoords; +} + +/** + * Comment event data structure + */ +interface CommentEventData { + /** Event type */ + type: string; + /** Comment data */ + comment: ReturnType; + /** Array of changes (for update events) */ + changes?: Array<{ key: string; value?: unknown; previousValue?: unknown; commentId?: string; fileId?: string }>; +} + +/** + * Comment text JSON structure from DOCX + */ +interface CommentTextJson { + /** Type of the node */ + type: string; + /** Node attributes */ + attrs?: Record; + /** Content array */ + content?: CommentTextJson[]; + /** Text content */ + text?: string; + /** Text marks */ + marks?: unknown[]; +} + +/** + * Loaded DOCX comment structure + */ +interface LoadedDocxComment { + /** Comment ID */ + commentId: string; + /** Parent comment ID */ + parentCommentId?: string; + /** Comment text JSON */ + textJson: CommentTextJson; + /** Creator name */ + creatorName: string; + /** Creator email */ + creatorEmail: string; + /** Creation time */ + createdTime: number; + /** Whether the comment is resolved */ + isDone?: boolean; + /** Whether this is a tracked change */ + trackedChange?: boolean; + /** Tracked change text */ + trackedChangeText?: string; + /** Tracked change type */ + trackedChangeType?: 'trackInsert' | 'trackDelete' | 'both' | 'trackFormat'; + /** Tracked deleted text */ + trackedDeletedText?: string; +} + +/** + * Comment position from editor + */ +interface CommentPosition { + /** Top position */ + top: number; + /** Left position */ + left: number; + /** Right position */ + right: number; + /** Bottom position */ + bottom: number; + [key: string]: unknown; +} + +/** + * Comment positions keyed by comment ID + */ +type EditorCommentPositions = Record; + +/** + * Grouped comments return type + */ +interface GroupedComments { + /** Active parent comments */ + parentComments: UnwrapNestedRefs[]; + /** Resolved comments */ + resolvedComments: UnwrapNestedRefs[]; +} + +/** + * Debounce timers keyed by comment ID + */ +type DebounceTimers = Record>; + +/** + * Comments store return type + */ +interface CommentsStoreReturn { + // Constants + COMMENT_EVENTS: typeof comments_module_events; + isDebugging: boolean; + + // State refs + hasInitializedComments: Ref; + hasSyncedCollaborationComments: Ref; + editingCommentId: Ref; + activeComment: Ref; + commentDialogs: Ref; + overlappingComments: Ref; + overlappedIds: Set; + suppressInternalExternal: Ref; + pendingComment: Ref | null>; + currentCommentText: Ref; + commentsList: Ref[]>; + isCommentsListVisible: Ref; + generalCommentIds: Ref; + editorCommentIds: Ref; + commentsParentElement: Ref; + editorCommentPositions: Ref; + hasInitializedLocations: Ref; + isCommentHighlighted: Ref; + + // Floating comments state + floatingCommentsOffset: Ref; + sortedConversations: Ref; + visibleConversations: Ref; + skipSelectionUpdate: Ref; + isFloatingCommentsReady: Ref; + + // Computed getters + getConfig: ComputedRef; + documentsWithConverations: ComputedRef; + getGroupedComments: ComputedRef; + getFloatingComments: ComputedRef[]>; + + // Actions + init: (config?: Partial) => void; + getComment: (id: string | number | undefined | null) => UnwrapNestedRefs | null; + setActiveComment: (superdoc: SuperDoc, id: string | undefined | null) => void; + getCommentLocation: ( + selection: { + getContainerLocation: (parent: HTMLElement) => { top: number; left: number }; + selectionBounds: SelectionBounds; + }, + parent: HTMLElement, + ) => { top: number; left: number }; + hasOverlapId: (id: string) => boolean; + getPendingComment: (params: { + selection?: Selection; + documentId?: string; + parentCommentId?: string; + [key: string]: unknown; + }) => UnwrapNestedRefs; + showAddComment: (superdoc: SuperDoc) => void; + addComment: (params: { + superdoc: SuperDoc; + comment: UnwrapNestedRefs; + skipEditorUpdate?: boolean; + }) => void; + cancelComment: (superdoc: SuperDoc) => void; + deleteComment: (params: { commentId: string; superdoc: SuperDoc }) => void; + removePendingComment: (superdoc: SuperDoc) => void; + processLoadedDocxComments: (params: { + superdoc: SuperDoc; + editor: Editor; + comments: LoadedDocxComment[]; + documentId: string; + }) => Promise; + translateCommentsForExport: () => Array & { commentJSON: unknown }>; + handleEditorLocationsUpdate: (allCommentPositions: EditorCommentPositions | null) => void; + handleTrackedChangeUpdate: (params: { superdoc: SuperDoc; params: TrackedChangeParams }) => void; +} + +/** + * Pinia store for managing comments and tracked changes + * + * This store handles: + * - Comment creation, editing, deletion, and resolution + * - Tracked changes (suggestions) from DOCX documents + * - Comment positioning and floating comment layout + * - Collaboration sync for multi-user editing + * - Import/export of comments from DOCX format + * - Threaded comments and conversations + * + * It works closely with the Super Editor to maintain comment state + * and provides computed getters for grouped and filtered comment lists. + */ +export const useCommentsStore = defineStore('comments', (): CommentsStoreReturn => { + // Lazy load superdocStore to avoid circular dependency + // Access it within functions instead of module level + const commentsConfig = reactive({ + name: 'comments', + readOnly: false, + allowResolve: true, + showResolved: false, + }); + + const isDebugging = false; + const debounceTimers: DebounceTimers = {}; + + const COMMENT_EVENTS = comments_module_events; + const hasInitializedComments = ref(false); + const hasSyncedCollaborationComments = ref(false); + const commentsParentElement = ref(null); + const hasInitializedLocations = ref(false); + const activeComment = ref(null); + const editingCommentId = ref(null); + const commentDialogs = ref([]); + const overlappingComments = ref([]); + const overlappedIds = new Set([]); + const suppressInternalExternal = ref(true); + const currentCommentText = ref(''); + const commentsList = ref[]>([]); + const isCommentsListVisible = ref(false); + const editorCommentIds = ref([]); + const editorCommentPositions = ref({}); + const isCommentHighlighted = ref(false); + + // Floating comments + const floatingCommentsOffset = ref(0); + const sortedConversations = ref([]); + const visibleConversations = ref([]); + const skipSelectionUpdate = ref(false); + const isFloatingCommentsReady = ref(false); + const generalCommentIds = ref([]); + + const pendingComment = ref | null>(null); + + /** + * Initialize the store + * + * @param config - The comments module config from SuperDoc + */ + const init = (config: Partial = {}): void => { + const updatedConfig = { ...commentsConfig, ...config }; + Object.assign(commentsConfig, updatedConfig); + + suppressInternalExternal.value = commentsConfig.suppressInternalExternal || false; + + // Map initial comments state + if (config.comments && config.comments.length) { + commentsList.value = config.comments?.map((c) => useComment(c)) || []; + } + }; + + /** + * Get a comment by either ID or imported ID + * + * @param id - The comment ID + * @returns The comment object or null + */ + const getComment = (id: string | number | undefined | null): UnwrapNestedRefs | null => { + if (id === undefined || id === null) return null; + return commentsList.value.find((c) => c.commentId == id || c.importedId == id) || null; + }; + + /** + * Set the active comment or clear all active comments + * + * @param superdoc - The SuperDoc instance + * @param id - The comment ID or null to clear + */ + const setActiveComment = (superdoc: SuperDoc, id: string | undefined | null): void => { + // If no ID, we clear any focused comments + if (id === undefined || id === null) { + activeComment.value = null; + const editor = superdoc.activeEditor as EditorWithCommentCommands | null; + if (editor?.commands?.setActiveComment) { + editor.commands.setActiveComment({ commentId: null }); + } + return; + } + + const comment = getComment(id); + if (comment) activeComment.value = comment.commentId; + const editor = superdoc.activeEditor as EditorWithCommentCommands | null; + if (editor?.commands?.setActiveComment) { + editor.commands.setActiveComment({ commentId: activeComment.value }); + } + }; + + /** + * Handle tracked change events from the editor. + * + * This method is called when a tracked change (insertion, deletion, or format change) + * is created or updated in the document. It manages the corresponding comment-like + * representation of the tracked change for display in the comments sidebar. + * + * For 'add' events, a new comment is created to represent the tracked change. + * For 'update' events, the existing tracked change comment is updated with new text. + * + * @param params - Parameters containing superdoc and tracked change data + * @param params.superdoc - The SuperDoc instance + * @param params.params - Tracked change parameters + * @param params.params.event - Event type: 'add' for new changes, 'update' for modifications + * @param params.params.changeId - Unique identifier for the tracked change + * @param params.params.trackedChangeText - The text content of the change + * @param params.params.trackedChangeType - Type of change: 'trackInsert', 'trackDelete', 'both', or 'trackFormat' + * @param params.params.deletedText - Original text that was deleted (for delete operations) + * @param params.params.authorEmail - Email of the author who made the change + * @param params.params.authorImage - Avatar image URL of the author + * @param params.params.date - Timestamp when the change was made + * @param params.params.author - Display name of the author + * @param params.params.importedAuthor - Author info if imported from DOCX + * @param params.params.documentId - ID of the document containing the change + * @param params.params.coords - Position coordinates for the change + */ + const handleTrackedChangeUpdate = ({ + superdoc, + params, + }: { + superdoc: SuperDoc; + params: TrackedChangeParams; + }): void => { + const { + event, + changeId, + trackedChangeText, + trackedChangeType, + deletedText, + authorEmail, + authorImage, + date, + author: authorName, + importedAuthor, + documentId, + coords, + } = params; + + const comment = getPendingComment({ + documentId, + commentId: changeId, + trackedChange: true, + trackedChangeText, + trackedChangeType, + deletedText, + createdTime: date, + creatorName: authorName, + creatorEmail: authorEmail, + creatorImage: authorImage, + isInternal: false, + importedAuthor, + selection: coords + ? { + documentId, + page: 1, + selectionBounds: coords as SelectionBounds, + } + : undefined, + }); + + if (event === 'add') { + // If this is a new tracked change, add it to our comments + addComment({ superdoc, comment }); + } else if (event === 'update') { + // If we have an update event, simply update the composable comment + const existingTrackedChange = commentsList.value.find((comment) => comment.commentId === changeId); + if (!existingTrackedChange) return; + + existingTrackedChange.trackedChangeText = trackedChangeText || null; + + if (deletedText) { + existingTrackedChange.deletedText = deletedText; + } + + const emitData: CommentEventData = { + type: COMMENT_EVENTS.UPDATE, + comment: existingTrackedChange.getValues(), + }; + + // Type assertion needed as syncCommentsToClients expects SuperDocWithCollaboration + // which requires ydoc to be non-optional, but at runtime this is safe + syncCommentsToClients( + superdoc as unknown as Parameters[0], + emitData as unknown as Parameters[1], + ); + debounceEmit(changeId, emitData, superdoc); + } + }; + + /** + * Debounce emission of events to prevent excessive updates + * + * @param commentId - Comment ID for debouncing + * @param event - Event data to emit + * @param superdoc - SuperDoc instance + * @param delay - Delay in milliseconds (default: 1000) + */ + const debounceEmit = (commentId: string, event: CommentEventData, superdoc: SuperDoc, delay: number = 1000): void => { + if (debounceTimers[commentId]) { + clearTimeout(debounceTimers[commentId]); + } + + debounceTimers[commentId] = setTimeout(() => { + if (superdoc) { + // Emit event directly - it contains type, comment, and changes properties + superdoc.emit?.('comments-update', event as unknown as { type: string; data: object }); + } + delete debounceTimers[commentId]; + }, delay); + }; + + /** + * Show the add comment UI + * + * @param superdoc - The SuperDoc instance + */ + const showAddComment = (superdoc: SuperDoc): void => { + const superdocStore = useSuperdocStore(); // Access lazily + const event = { type: COMMENT_EVENTS.PENDING }; + // Emit event directly + superdoc.emit?.('comments-update', event as unknown as { type: string; data: object }); + + const activeSelection = superdocStore.activeSelection as { + documentId: string; + selectionBounds: SelectionBounds; + [key: string]: unknown; + } | null; + const selection = { ...(activeSelection || { documentId: '', selectionBounds: {} }) } as unknown as Selection; + selection.selectionBounds = { ...(selection.selectionBounds || {}) } as SelectionBounds; + + if (superdocStore.selectionPosition?.source) { + superdocStore.selectionPosition.source = null; + } + + pendingComment.value = getPendingComment({ + selection, + documentId: selection.documentId, + parentCommentId: undefined, + }); + if (!superdoc.config.isInternal) pendingComment.value.isInternal = false; + + const editor = superdoc.activeEditor as EditorWithCommentCommands | null; + if (editor?.commands?.insertComment) { + editor.commands.insertComment({ + ...pendingComment.value.getValues(), + commentId: 'pending', + skipEmit: true, + }); + } + + if (pendingComment.value.selection.source === 'super-editor' && superdocStore.selectionPosition) { + superdocStore.selectionPosition.source = 'super-editor'; + } + + activeComment.value = pendingComment.value.commentId; + }; + + /** + * Generate the comments list separating resolved and active + * We only return parent comments here, since CommentDialog.vue will handle threaded comments + */ + const getGroupedComments: ComputedRef = computed(() => { + const parentComments: UnwrapNestedRefs[] = []; + const resolvedComments: UnwrapNestedRefs[] = []; + const childCommentMap = new Map[]>(); + + commentsList.value.forEach((comment: UnwrapNestedRefs) => { + // Track resolved comments + if (comment.resolvedTime) { + resolvedComments.push(comment); + } + + // Track parent comments + else if (!comment.parentCommentId && !comment.resolvedTime) { + parentComments.push({ ...comment }); + } + + // Track child comments (threaded comments) + else if (comment.parentCommentId) { + if (!childCommentMap.has(comment.parentCommentId)) { + childCommentMap.set(comment.parentCommentId, []); + } + childCommentMap.get(comment.parentCommentId)!.push(comment); + } + }); + + // Return only parent comments + const sortedParentComments = parentComments.sort((a, b) => (a.createdTime || 0) - (b.createdTime || 0)); + const sortedResolvedComments = resolvedComments.sort((a, b) => (a.createdTime || 0) - (b.createdTime || 0)); + + return { + parentComments: sortedParentComments, + resolvedComments: sortedResolvedComments, + }; + }); + + /** + * Check if an ID exists in the overlapped IDs set + * + * @param id - The ID to check + * @returns True if the ID is in the overlapped set + */ + const hasOverlapId = (id: string): boolean => overlappedIds.has(id); + + /** + * Get all documents with conversations + */ + const documentsWithConverations = computed(() => { + const superdocStore = useSuperdocStore(); // Access lazily + return superdocStore.documents; + }); + + /** + * Get the comments configuration + */ + const getConfig: ComputedRef = computed(() => { + return commentsConfig; + }); + + /** + * Get the location of a comment relative to its parent + * + * @param selection - Selection object with position data + * @param parent - Parent element for relative positioning + * @returns Top and left coordinates + */ + const getCommentLocation = ( + selection: { + getContainerLocation: (parent: HTMLElement) => { top: number; left: number }; + selectionBounds: SelectionBounds; + }, + parent: HTMLElement, + ): { top: number; left: number } => { + const containerBounds = selection.getContainerLocation(parent); + const top = containerBounds.top + selection.selectionBounds.top; + const left = containerBounds.left + selection.selectionBounds.left; + return { + top: top, + left: left, + }; + }; + + /** + * Get a new pending comment + * + * @param params - Comment initialization parameters + * @returns New comment object + */ + const getPendingComment = (params: { + selection?: Selection; + documentId?: string; + parentCommentId?: string; + [key: string]: unknown; + }): UnwrapNestedRefs => { + return _getNewcomment(params); + }; + + /** + * Get the new comment object + * + * @param params - Comment initialization parameters + * @returns New comment object + */ + const _getNewcomment = (params: { + selection?: Selection; + documentId?: string; + parentCommentId?: string; + [key: string]: unknown; + }): UnwrapNestedRefs => { + const superdocStore = useSuperdocStore(); // Access lazily + const { selection, documentId, parentCommentId, ...options } = params; + let activeDocument: UseDocumentReturn | undefined; + if (documentId) activeDocument = superdocStore.getDocument(documentId); + else if (selection) activeDocument = superdocStore.getDocument(selection.documentId); + + if (!activeDocument) activeDocument = superdocStore.documents[0] as unknown as UseDocumentReturn; + + // At this point we should have an activeDocument, if not we have bigger problems + if (!activeDocument) { + throw new Error('No active document found when creating comment'); + } + + return useComment({ + fileId: activeDocument.id, + fileType: activeDocument.type || undefined, + parentCommentId, + creatorEmail: superdocStore.user.email, + creatorName: superdocStore.user.name, + creatorImage: superdocStore.user.image, + commentText: currentCommentText.value, + selection, + ...options, + } as UseCommentParams); + }; + + /** + * Remove the pending comment + * + * @param superdoc - The SuperDoc instance + */ + const removePendingComment = (superdoc: SuperDoc): void => { + const superdocStore = useSuperdocStore(); // Access lazily + currentCommentText.value = ''; + pendingComment.value = null; + activeComment.value = null; + superdocStore.selectionPosition = null; + + const editor = superdoc.activeEditor as EditorWithCommentCommands | null; + if (editor?.commands?.removeComment) { + editor.commands.removeComment({ commentId: 'pending' }); + } + }; + + /** + * Add a new comment to the document + * + * @param params - Parameters including superdoc, comment, and optional flags + */ + const addComment = ({ + superdoc, + comment, + skipEditorUpdate = false, + }: { + superdoc: SuperDoc; + comment: UnwrapNestedRefs; + skipEditorUpdate?: boolean; + }): void => { + let parentComment = commentsList.value.find( + (c: UnwrapNestedRefs) => c.commentId === activeComment.value, + ); + if (!parentComment) parentComment = comment; + + // Type assertion needed - getValues() returns compatible structure for useComment + const newComment = useComment(comment.getValues() as UseCommentParams); + + if (pendingComment.value) newComment.setText({ text: currentCommentText.value, superdoc, suppressUpdate: true }); + else newComment.setText({ text: comment.commentText || '', superdoc, suppressUpdate: true }); + newComment.selection.source = pendingComment.value?.selection?.source; + + // Set isInternal flag + if (parentComment) { + const isParentInternal = parentComment.isInternal; + newComment.isInternal = isParentInternal; + } + + // If the current user is not internal, set the comment to external + if (!superdoc.config.isInternal) newComment.isInternal = false; + + // Add the new comments to our global list + commentsList.value.push(newComment); + + // Clean up the pending comment + removePendingComment(superdoc); + + // If this is not a tracked change, and it belongs to a Super Editor, and its not a child comment + // We need to let the editor know about the new comment + if (!skipEditorUpdate && !comment.trackedChange && superdoc.activeEditor && !comment.parentCommentId) { + // Add the comment to the active editor + const editor = superdoc.activeEditor as EditorWithCommentCommands; + if (editor.commands?.insertComment) { + editor.commands.insertComment({ ...newComment.getValues(), commentId: newComment.commentId, skipEmit: true }); + } + } + + const event: CommentEventData = { type: COMMENT_EVENTS.ADD, comment: newComment.getValues() }; + + // If collaboration is enabled, sync the comments to all clients + // Type assertion needed as syncCommentsToClients expects SuperDocWithCollaboration + syncCommentsToClients( + superdoc as unknown as Parameters[0], + event as unknown as Parameters[1], + ); + + // Emit event for end users + superdoc.emit?.('comments-update', event as unknown as { type: string; data: object }); + }; + + /** + * Delete a comment and its child comments + * + * @param params - Parameters including commentId and superdoc + */ + const deleteComment = ({ + commentId: commentIdToDelete, + superdoc, + }: { + commentId: string; + superdoc: SuperDoc; + }): void => { + const commentIndex = commentsList.value.findIndex((c) => c.commentId === commentIdToDelete); + const comment = commentsList.value[commentIndex]; + const { commentId, importedId } = comment; + const { fileId } = comment; + + const editor = superdoc.activeEditor as EditorWithCommentCommands | null; + if (editor?.commands?.removeComment) { + editor.commands.removeComment({ commentId, importedId }); + } + + // Remove the current comment + commentsList.value.splice(commentIndex, 1); + + // Remove any child comments of the removed comment + const childCommentIds = commentsList.value + .filter((c: UnwrapNestedRefs) => c.parentCommentId === commentId) + .map((c: UnwrapNestedRefs) => c.commentId || c.importedId); + commentsList.value = commentsList.value.filter( + (c: UnwrapNestedRefs) => !childCommentIds.includes(c.commentId), + ); + + const event: CommentEventData = { + type: COMMENT_EVENTS.DELETED, + comment: comment.getValues(), + changes: [{ key: 'deleted', commentId, fileId }], + }; + + // Emit event for end users + superdoc.emit?.('comments-update', event as unknown as { type: string; data: object }); + // Type assertion needed as syncCommentsToClients expects SuperDocWithCollaboration + syncCommentsToClients( + superdoc as unknown as Parameters[0], + event as unknown as Parameters[1], + ); + }; + + /** + * Cancel the pending comment + * + * @param superdoc - The SuperDoc instance + */ + const cancelComment = (superdoc: SuperDoc): void => { + removePendingComment(superdoc); + }; + + /** + * Initialize loaded comments into SuperDoc by mapping the imported + * comment data to SuperDoc useComment objects. + * + * Updates the commentsList ref with the new comments. + * + * @param params - Parameters including comments array, editor, and documentId + */ + const processLoadedDocxComments = async ({ + superdoc, + editor, + comments, + documentId, + }: { + superdoc: SuperDoc; + editor: Editor; + comments: LoadedDocxComment[]; + documentId: string; + }): Promise => { + const superdocStore = useSuperdocStore(); // Access lazily + const document = superdocStore.getDocument(documentId); + if (!document) return; + + comments.forEach((comment: LoadedDocxComment) => { + const htmlContent = getHtmlFromComment(comment.textJson); + + if (!htmlContent && !comment.trackedChange) { + return; + } + + const creatorName = comment.creatorName.replace('(imported)', ''); + const importedName = `${creatorName} (imported)`; + const newComment = useComment({ + fileId: documentId, + fileType: document.type || undefined, + commentId: comment.commentId, + isInternal: false, + parentCommentId: comment.parentCommentId, + creatorName, + createdTime: comment.createdTime, + creatorEmail: comment.creatorEmail, + importedAuthor: { + name: importedName, + email: comment.creatorEmail, + }, + commentText: getHtmlFromComment(comment.textJson), + resolvedTime: comment.isDone ? Date.now() : null, + resolvedByEmail: comment.isDone ? comment.creatorEmail : null, + resolvedByName: comment.isDone ? importedName : null, + trackedChange: comment.trackedChange || false, + trackedChangeText: comment.trackedChangeText, + trackedChangeType: comment.trackedChangeType, + deletedText: comment.trackedDeletedText, + }); + + addComment({ superdoc, comment: newComment }); + }); + + setTimeout(() => { + // do not block the first rendering of the doc + // and create comments asynchronously. + createCommentForTrackChanges(editor); + }, 0); + }; + + /** + * Create comments for tracked changes that don't have comments + * + * @param editor - The Super Editor instance + */ + const createCommentForTrackChanges = (editor: Editor): void => { + const editorWithView = editor as EditorWithView; + const trackedChanges = trackChangesHelpers.getTrackChanges(editorWithView.state); + + const groupedChanges: GroupedChange[] = groupChanges(trackedChanges); + + // Create comments for tracked changes + // that do not have a corresponding comment (created in Word). + const { tr } = editorWithView.view.state; + const { dispatch } = editorWithView.view; + + groupedChanges.forEach(({ insertedMark, deletionMark, formatMark }, index) => { + console.debug(`Create comment for track change: ${index}`); + const foundComment = commentsList.value.find( + (i: UnwrapNestedRefs) => + i.commentId === insertedMark?.mark.attrs.id || + i.commentId === deletionMark?.mark.attrs.id || + i.commentId === formatMark?.mark.attrs.id, + ); + const isLastIteration = trackedChanges.length === index + 1; + + if (foundComment) { + if (isLastIteration) { + tr.setMeta(CommentsPluginKey, { type: 'force' }); + } + return; + } + + if (insertedMark || deletionMark || formatMark) { + const trackChangesPayload: Record = { + ...(insertedMark && { insertedMark: insertedMark.mark }), + ...(deletionMark && { deletionMark: deletionMark.mark }), + ...(formatMark && { formatMark: formatMark.mark }), + }; + + if (isLastIteration) tr.setMeta(CommentsPluginKey, { type: 'force' }); + tr.setMeta(CommentsPluginKey, { type: 'forceTrackChanges' }); + tr.setMeta(TrackChangesBasePluginKey, trackChangesPayload); + } + dispatch(tr); + }); + }; + + /** + * Translate comments for export to DOCX format + * + * @returns Array of comments with JSON schema + */ + const translateCommentsForExport = (): Array< + ReturnType & { commentJSON: unknown } + > => { + const processedComments: Array & { commentJSON: unknown }> = []; + commentsList.value.forEach((comment: UnwrapNestedRefs) => { + const values = comment.getValues(); + const richText = values.commentText; + const schema = convertHtmlToSchema(richText); + processedComments.push({ + ...values, + commentJSON: schema, + }); + }); + return processedComments; + }; + + /** + * Convert HTML to ProseMirror schema + * + * @param commentHTML - HTML string to convert + * @returns ProseMirror JSON node + */ + const convertHtmlToSchema = (commentHTML: string): unknown => { + const div = document.createElement('div'); + div.innerHTML = commentHTML; + const editor = new EditorClass({ + mode: 'text', + isHeadless: true, + content: div, + extensions: getRichTextExtensions(), + }); + return editor.getJSON().content[0]; + }; + + /** + * Triggered when the editor locations are updated + * Updates floating comment locations from the editor + * + * @param allCommentPositions - All comment positions from the editor + */ + const handleEditorLocationsUpdate = (allCommentPositions: EditorCommentPositions | null): void => { + editorCommentPositions.value = allCommentPositions || {}; + }; + + /** + * Get floating comments (comments that should be displayed in the sidebar) + */ + const getFloatingComments: ComputedRef[]> = computed(() => { + const comments = getGroupedComments.value?.parentComments + .filter((c: UnwrapNestedRefs) => !c.resolvedTime) + .filter((c: UnwrapNestedRefs) => { + const keys = Object.keys(editorCommentPositions.value); + const isPdfComment = c.selection?.source !== 'super-editor'; + if (isPdfComment) return true; + const commentKey = c.commentId || c.importedId; + return commentKey && keys.includes(commentKey); + }); + return comments; + }); + + /** + * Get HTML content from the comment text JSON (which uses DOCX schema) + * + * @param commentTextJson - The comment text JSON + * @returns The HTML content or undefined + */ + const normalizeCommentForEditor = (node: CommentTextJson | unknown): unknown => { + if (!node || typeof node !== 'object') return node; + + const typedNode = node as CommentTextJson; + + const cloneMarks = (marks: unknown[] | undefined): unknown[] | undefined => + Array.isArray(marks) + ? marks.filter(Boolean).map((mark: unknown) => { + const typedMark = mark as Record; + return { + ...typedMark, + attrs: typedMark?.attrs ? { ...(typedMark.attrs as Record) } : undefined, + }; + }) + : undefined; + + const cloneAttrs = (attrs: Record | undefined) => + attrs && typeof attrs === 'object' ? { ...attrs } : undefined; + + if (!Array.isArray(typedNode.content)) { + return { + type: typedNode.type, + ...(typedNode.text !== undefined ? { text: typedNode.text } : {}), + ...(typedNode.attrs ? { attrs: cloneAttrs(typedNode.attrs) } : {}), + ...(typedNode.marks ? { marks: cloneMarks(typedNode.marks) } : {}), + }; + } + + const normalizedChildren = typedNode.content + .map((child: CommentTextJson) => normalizeCommentForEditor(child)) + .flat() + .filter(Boolean); + + if (typedNode.type === 'run') { + return normalizedChildren; + } + + return { + type: typedNode.type, + ...(typedNode.attrs ? { attrs: cloneAttrs(typedNode.attrs) } : {}), + ...(typedNode.marks ? { marks: cloneMarks(typedNode.marks) } : {}), + content: normalizedChildren, + }; + }; + + /** + * Get HTML from comment JSON + * + * @param commentTextJson - Comment text JSON structure + * @returns HTML string or undefined + */ + const getHtmlFromComment = (commentTextJson: CommentTextJson): string | undefined => { + // If no content, we can't convert and its not a valid comment + if (!commentTextJson.content?.length) return; + + try { + const normalizedContent = normalizeCommentForEditor(commentTextJson); + const schemaContent = Array.isArray(normalizedContent) ? normalizedContent[0] : normalizedContent; + if (!schemaContent || !(schemaContent as CommentTextJson).content?.length) return undefined; + const editor = new EditorClass({ + mode: 'text', + isHeadless: true, + content: schemaContent, + loadFromSchema: true, + extensions: getRichTextExtensions(), + }); + return editor.getHTML(); + } catch (error) { + console.warn('Failed to convert comment', error); + return; + } + }; + + return { + COMMENT_EVENTS, + isDebugging, + hasInitializedComments, + hasSyncedCollaborationComments, + editingCommentId, + activeComment, + commentDialogs, + overlappingComments, + overlappedIds, + suppressInternalExternal, + pendingComment, + currentCommentText, + commentsList, + isCommentsListVisible, + generalCommentIds, + editorCommentIds, + commentsParentElement, + editorCommentPositions, + hasInitializedLocations, + isCommentHighlighted, + + // Floating comments + floatingCommentsOffset, + sortedConversations, + visibleConversations, + skipSelectionUpdate, + isFloatingCommentsReady, + + // Getters + getConfig, + documentsWithConverations, + getGroupedComments, + getFloatingComments, + + // Actions + init, + getComment, + setActiveComment, + getCommentLocation, + hasOverlapId, + getPendingComment, + showAddComment, + addComment, + cancelComment, + deleteComment, + removePendingComment, + processLoadedDocxComments, + translateCommentsForExport, + handleEditorLocationsUpdate, + handleTrackedChangeUpdate, + }; +}); diff --git a/packages/superdoc/src/stores/hrbr-fields-store.js b/packages/superdoc/src/stores/hrbr-fields-store.js deleted file mode 100644 index 54901a918..000000000 --- a/packages/superdoc/src/stores/hrbr-fields-store.js +++ /dev/null @@ -1,118 +0,0 @@ -import { defineStore, storeToRefs } from 'pinia'; -import { computed, reactive, markRaw } from 'vue'; -import { useSuperdocStore } from './superdoc-store'; -import TextField from '@superdoc/components/HrbrFieldsLayer/TextField.vue'; -import ParagraphField from '@superdoc/components/HrbrFieldsLayer/ParagraphField.vue'; -import ImageField from '@superdoc/components/HrbrFieldsLayer/ImageField.vue'; -import CheckboxField from '@superdoc/components/HrbrFieldsLayer/CheckboxField.vue'; -import SelectField from '@superdoc/components/HrbrFieldsLayer/SelectField.vue'; -import { floor } from '../helpers/floor.js'; - -export const useHrbrFieldsStore = defineStore('hrbr-fields', () => { - const superdocStore = useSuperdocStore(); - const { documents } = storeToRefs(superdocStore); - const hrbrFieldsConfig = reactive({ - name: 'hrbr-fields', - }); - - const fieldComponentsMap = Object.freeze({ - TEXTINPUT: markRaw(TextField), - HTMLINPUT: markRaw(ParagraphField), - SELECT: markRaw(SelectField), - CHECKBOXINPUT: markRaw(CheckboxField), - SIGNATUREINPUT: markRaw(ImageField), - IMAGEINPUT: markRaw(ImageField), - }); - - const getField = (documentId, fieldId) => { - const doc = documents.value.find((d) => d.id === documentId); - if (!doc) return; - - const field = doc.fields.find((f) => f.id === fieldId); - if (field) return field; - }; - - const getAnnotations = computed(() => { - const mappedAnnotations = []; - documents.value.forEach((doc) => { - const { id, annotations } = doc; - - const docContainer = doc.container; - if (!docContainer) return; - - const bounds = docContainer.getBoundingClientRect(); - const pageBoundsMap = doc.pageContainers; - if (!bounds || !pageBoundsMap) return; - - annotations.forEach((annotation) => { - const { itemid: fieldId, page, nostyle } = annotation; - - let annotationId = annotation.pageannotation; - - if (annotation.itemfieldtype === 'CHECKBOXINPUT') { - annotationId = annotation.annotationid; - } - - const { x1, y1, x2, y2 } = annotation; - const coordinates = { x1, y1, x2, y2 }; - - const pageContainer = document.getElementById(`${id}-page-${page + 1}`); - if (!pageContainer) return; - const pageBounds = pageContainer.getBoundingClientRect(); - - const pageInfo = doc.pageContainers.find((p) => p.page === page); - const scale = pageBounds.height / pageInfo.containerBounds.originalHeight; - const pageBottom = pageBounds.bottom - bounds.top; - const pageLeft = pageBounds.left - bounds.left; - - const mappedCoordinates = _mapAnnotation(coordinates, scale, pageBottom, pageLeft); - // scale ~1.333 - for 100% scale in pdf.js (it doesn't change). - const annotationStyle = { - fontSize: floor(annotation.original_font_size * scale, 2) + 'pt', - fontFamily: annotation.fontfamily || 'Arial', - originalFontSize: floor(annotation.original_font_size * scale, 2), - coordinates: mappedCoordinates, - }; - - const field = { - documentId: id, - fieldId, - page, - annotationId, - originalAnnotationId: annotation.originalannotationid, - coordinates: mappedCoordinates, - style: annotationStyle, - nostyle: nostyle ?? false, - }; - - mappedAnnotations.push(field); - }); - }); - - return mappedAnnotations; - }); - - const _mapAnnotation = (coordinates, scale, pageBottom, boundsLeft) => { - const { x1, y1, x2, y2 } = coordinates; - const mappedX1 = x1 * scale; - const mappedY1 = y1 * scale; - const mappedX2 = x2 * scale; - const mappedY2 = y2 * scale; - - return { - top: `${pageBottom - mappedY2}px`, - left: `${mappedX1 + boundsLeft}px`, - minWidth: `${mappedX2 - mappedX1}px`, - minHeight: `${mappedY2 - mappedY1}px`, - }; - }; - - return { - hrbrFieldsConfig, - fieldComponentsMap, - - // Getters - getAnnotations, - getField, - }; -}); diff --git a/packages/superdoc/src/stores/hrbr-fields-store.test.js b/packages/superdoc/src/stores/hrbr-fields-store.test.ts similarity index 82% rename from packages/superdoc/src/stores/hrbr-fields-store.test.js rename to packages/superdoc/src/stores/hrbr-fields-store.test.ts index 0e0dc18b8..947c395f0 100644 --- a/packages/superdoc/src/stores/hrbr-fields-store.test.js +++ b/packages/superdoc/src/stores/hrbr-fields-store.test.ts @@ -3,11 +3,11 @@ import { createPinia, setActivePinia, defineStore } from 'pinia'; import { ref, reactive } from 'vue'; vi.mock('./superdoc-store.js', () => { - const documents = ref([]); + const documents = ref([]); const user = reactive({ name: 'Tester', email: 'tester@example.com' }); - const activeSelection = reactive({ documentId: null, selectionBounds: {} }); - const selectionPosition = reactive({ source: null }); - const getDocument = (id) => documents.value.find((doc) => doc.id === id); + const activeSelection = reactive({ documentId: null as string | null, selectionBounds: {} }); + const selectionPosition = reactive({ source: null as string | null }); + const getDocument = (id: string) => documents.value.find((doc: { id: string }) => doc.id === id); const useMockStore = defineStore('superdoc', () => ({ documents, @@ -28,7 +28,7 @@ vi.mock('./superdoc-store.js', () => { }; }); -function componentStub(name) { +function componentStub(name: string) { return { default: { name } }; } vi.mock('@superdoc/components/HrbrFieldsLayer/TextField.vue', () => componentStub('TextField')); @@ -41,7 +41,7 @@ import { useHrbrFieldsStore } from './hrbr-fields-store.js'; import { __mockSuperdoc } from './superdoc-store.js'; describe('hrbr-fields-store', () => { - let store; + let store: ReturnType; beforeEach(() => { setActivePinia(createPinia()); @@ -58,13 +58,13 @@ describe('hrbr-fields-store', () => { { id: 'doc-1', fields: [ - { id: 'field-1', label: 'Field One' }, - { id: 'field-2', label: 'Field Two' }, + { itemid: 'field-1', label: 'Field One' }, + { itemid: 'field-2', label: 'Field Two' }, ], }, ]; - expect(store.getField('doc-1', 'field-2')).toEqual({ id: 'field-2', label: 'Field Two' }); + expect(store.getField('doc-1', 'field-2')).toEqual({ itemid: 'field-2', label: 'Field Two' }); expect(store.getField('doc-1', 'missing')).toBeUndefined(); expect(store.getField('missing', 'field-1')).toBeUndefined(); }); @@ -72,12 +72,12 @@ describe('hrbr-fields-store', () => { it('maps annotations to positioned fields', () => { const pageElementBounds = { height: 300, bottom: 310, left: 42 }; const pageElement = { getBoundingClientRect: () => pageElementBounds }; - const getElementSpy = vi.spyOn(document, 'getElementById').mockReturnValue(pageElement); + const getElementSpy = vi.spyOn(document, 'getElementById').mockReturnValue(pageElement as HTMLElement); __mockSuperdoc.documents.value = [ { id: 'doc-1', - fields: [{ id: 'field-1' }], + fields: [{ itemid: 'field-1' }], annotations: [ { itemid: 'field-1', diff --git a/packages/superdoc/src/stores/hrbr-fields-store.ts b/packages/superdoc/src/stores/hrbr-fields-store.ts new file mode 100644 index 000000000..1d5b0ad57 --- /dev/null +++ b/packages/superdoc/src/stores/hrbr-fields-store.ts @@ -0,0 +1,305 @@ +import { defineStore, storeToRefs } from 'pinia'; +import { computed, reactive, markRaw, type Component } from 'vue'; +import { useSuperdocStore } from './superdoc-store'; +import TextField from '@superdoc/components/HrbrFieldsLayer/TextField.vue'; +import ParagraphField from '@superdoc/components/HrbrFieldsLayer/ParagraphField.vue'; +import ImageField from '@superdoc/components/HrbrFieldsLayer/ImageField.vue'; +import CheckboxField from '@superdoc/components/HrbrFieldsLayer/CheckboxField.vue'; +import SelectField from '@superdoc/components/HrbrFieldsLayer/SelectField.vue'; +import { floor } from '../helpers/floor'; +import type { RawField } from '../composables/use-field'; + +/** + * Configuration for the HRBR fields module + */ +interface HrbrFieldsConfig { + /** Name of the module */ + name: string; +} + +/** + * Raw coordinates from an annotation + */ +interface AnnotationCoordinates { + /** Left position */ + x1: number; + /** Top position */ + y1: number; + /** Right position */ + x2: number; + /** Bottom position */ + y2: number; +} + +/** + * Mapped coordinates with CSS values + */ +interface MappedCoordinates { + /** Top position in CSS format */ + top: string; + /** Left position in CSS format */ + left: string; + /** Minimum width in CSS format */ + minWidth: string; + /** Minimum height in CSS format */ + minHeight: string; +} + +/** + * Style properties for an annotation field + */ +interface AnnotationStyle { + /** Font size in points */ + fontSize: string; + /** Font family */ + fontFamily: string; + /** Original font size value */ + originalFontSize: number; + /** Mapped coordinates for positioning */ + coordinates: MappedCoordinates; +} + +/** + * Raw annotation data from a document + */ +interface DocumentAnnotation { + /** ID of the field this annotation belongs to */ + itemid: string; + /** Page number (0-indexed) */ + page: number; + /** Whether to suppress default styling */ + nostyle?: boolean; + /** Page annotation ID */ + pageannotation: string; + /** Annotation ID (used for checkboxes) */ + annotationid: string; + /** Field type */ + itemfieldtype: string; + /** Left position */ + x1: number; + /** Top position */ + y1: number; + /** Right position */ + x2: number; + /** Bottom position */ + y2: number; + /** Original font size */ + original_font_size: number; + /** Font family */ + fontfamily?: string; + /** Original annotation ID */ + originalannotationid?: string; +} + +/** + * Mapped annotation field for rendering + */ +interface MappedAnnotationField { + /** Document ID */ + documentId: string; + /** Field ID */ + fieldId: string; + /** Page number */ + page: number; + /** Annotation ID */ + annotationId: string; + /** Original annotation ID */ + originalAnnotationId?: string; + /** Positioned coordinates */ + coordinates: MappedCoordinates; + /** Style properties */ + style: AnnotationStyle; + /** Whether to suppress default styling */ + nostyle: boolean; +} + +/** + * Page container information + */ +interface PageContainer { + /** Page number */ + page: number; + /** Container bounds */ + containerBounds: { + /** Original height of the container */ + originalHeight: number; + }; +} + +/** + * Document with fields and annotations + */ +interface DocumentWithFields { + /** Document ID */ + id: string; + /** Array of fields */ + fields: RawField[]; + /** Array of annotations */ + annotations: DocumentAnnotation[]; + /** Container DOM element */ + container: HTMLElement | null; + /** Page container information */ + pageContainers: PageContainer[]; +} + +/** + * Component map for field types + */ +type FieldComponentsMap = { + readonly TEXTINPUT: Component; + readonly HTMLINPUT: Component; + readonly SELECT: Component; + readonly CHECKBOXINPUT: Component; + readonly SIGNATUREINPUT: Component; + readonly IMAGEINPUT: Component; +}; + +/** + * Pinia store for managing HRBR fields + * + * This store handles the rendering and positioning of form fields + * within document annotations. It manages field components, calculates + * proper positioning based on scale and page bounds, and provides + * getters for field data. + */ +export const useHrbrFieldsStore = defineStore('hrbr-fields', () => { + const superdocStore = useSuperdocStore(); + const { documents } = storeToRefs(superdocStore); + + const hrbrFieldsConfig = reactive({ + name: 'hrbr-fields', + }); + + const fieldComponentsMap: FieldComponentsMap = Object.freeze({ + TEXTINPUT: markRaw(TextField), + HTMLINPUT: markRaw(ParagraphField), + SELECT: markRaw(SelectField), + CHECKBOXINPUT: markRaw(CheckboxField), + SIGNATUREINPUT: markRaw(ImageField), + IMAGEINPUT: markRaw(ImageField), + }); + + /** + * Get a field by document ID and field ID + * + * @param documentId - The ID of the document + * @param fieldId - The ID of the field + * @returns The field object or undefined if not found + */ + const getField = (documentId: string, fieldId: string): RawField | undefined => { + const doc = documents.value.find((d) => d.id === documentId) as unknown as DocumentWithFields | undefined; + if (!doc) return; + + const field = doc.fields.find((f: RawField) => f.itemid === fieldId); + if (field) return field; + }; + + /** + * Computed getter for all mapped annotations with proper positioning + * + * This getter processes all document annotations, calculates their positions + * based on page scale and bounds, and returns fully mapped annotation objects + * ready for rendering. + * + * @returns Array of mapped annotation fields + */ + const getAnnotations = computed(() => { + const mappedAnnotations: MappedAnnotationField[] = []; + documents.value.forEach((doc) => { + const typedDoc = doc as unknown as DocumentWithFields; + const { id, annotations } = typedDoc; + + const docContainer = typedDoc.container; + if (!docContainer) return; + + const bounds = docContainer.getBoundingClientRect(); + const pageBoundsMap = typedDoc.pageContainers; + if (!bounds || !pageBoundsMap) return; + + annotations.forEach((annotation) => { + const { itemid: fieldId, page, nostyle } = annotation; + + let annotationId = annotation.pageannotation; + + if (annotation.itemfieldtype === 'CHECKBOXINPUT') { + annotationId = annotation.annotationid; + } + + const { x1, y1, x2, y2 } = annotation; + const coordinates: AnnotationCoordinates = { x1, y1, x2, y2 }; + + const pageContainer = document.getElementById(`${id}-page-${page + 1}`); + if (!pageContainer) return; + const pageBounds = pageContainer.getBoundingClientRect(); + + const pageInfo = typedDoc.pageContainers.find((p) => p.page === page); + if (!pageInfo) return; + const scale = pageBounds.height / pageInfo.containerBounds.originalHeight; + const pageBottom = pageBounds.bottom - bounds.top; + const pageLeft = pageBounds.left - bounds.left; + + const mappedCoordinates = _mapAnnotation(coordinates, scale, pageBottom, pageLeft); + // scale ~1.333 - for 100% scale in pdf.js (it doesn't change). + const annotationStyle: AnnotationStyle = { + fontSize: floor(annotation.original_font_size * scale, 2) + 'pt', + fontFamily: annotation.fontfamily || 'Arial', + originalFontSize: floor(annotation.original_font_size * scale, 2), + coordinates: mappedCoordinates, + }; + + const field: MappedAnnotationField = { + documentId: id, + fieldId, + page, + annotationId, + originalAnnotationId: annotation.originalannotationid, + coordinates: mappedCoordinates, + style: annotationStyle, + nostyle: nostyle ?? false, + }; + + mappedAnnotations.push(field); + }); + }); + + return mappedAnnotations; + }); + + /** + * Map annotation coordinates to CSS positioning + * + * @param coordinates - Raw annotation coordinates + * @param scale - Scale factor for the page + * @param pageBottom - Bottom position of the page + * @param boundsLeft - Left offset of the page bounds + * @returns Mapped coordinates with CSS values + */ + const _mapAnnotation = ( + coordinates: AnnotationCoordinates, + scale: number, + pageBottom: number, + boundsLeft: number, + ): MappedCoordinates => { + const { x1, y1, x2, y2 } = coordinates; + const mappedX1 = x1 * scale; + const mappedY1 = y1 * scale; + const mappedX2 = x2 * scale; + const mappedY2 = y2 * scale; + + return { + top: `${pageBottom - mappedY2}px`, + left: `${mappedX1 + boundsLeft}px`, + minWidth: `${mappedX2 - mappedX1}px`, + minHeight: `${mappedY2 - mappedY1}px`, + }; + }; + + return { + hrbrFieldsConfig, + fieldComponentsMap, + + // Getters + getAnnotations, + getField, + }; +}); diff --git a/packages/superdoc/src/stores/superdoc-store.js b/packages/superdoc/src/stores/superdoc-store.js deleted file mode 100644 index e983d35e3..000000000 --- a/packages/superdoc/src/stores/superdoc-store.js +++ /dev/null @@ -1,278 +0,0 @@ -import { defineStore } from 'pinia'; -import { ref, reactive, computed } from 'vue'; -import { useCommentsStore } from './comments-store'; -import { getFileObject } from '@superdoc/common'; -import { DOCX, PDF } from '@superdoc/common'; -import { normalizeDocumentEntry } from '@superdoc/core/helpers/file.js'; -import useDocument from '@superdoc/composables/use-document'; -import BlankDOCX from '@superdoc/common/data/blank.docx?url'; - -export const useSuperdocStore = defineStore('superdoc', () => { - const currentConfig = ref(null); - let exceptionHandler = null; - const commentsStore = useCommentsStore(); - const documents = ref([]); - const documentBounds = ref([]); - const pages = reactive({}); - const documentUsers = ref([]); - const activeZoom = ref(100); - const isReady = ref(false); - const isInternal = ref(false); - - const users = ref([]); - - const user = reactive({ name: null, email: null }); - const modules = reactive({}); - - const activeSelection = ref(null); - const selectionPosition = ref({ - left: 0, - top: 0, - width: 0, - height: 0, - source: null, - }); - - const reset = () => { - documents.value = []; - documentBounds.value = []; - Object.assign(pages, {}); - documentUsers.value = []; - isReady.value = false; - user.name = null; - user.email = null; - Object.assign(modules, {}); - activeSelection.value = null; - }; - - const documentScroll = reactive({ - scrollTop: 0, - scrollLeft: 0, - }); - - const setExceptionHandler = (handler) => { - exceptionHandler = typeof handler === 'function' ? handler : null; - }; - - const emitException = (payload) => { - const handler = exceptionHandler || currentConfig.value?.onException; - if (typeof handler === 'function') handler(payload); - }; - - const init = async (config) => { - reset(); - currentConfig.value = config; - const { documents: configDocs, modules: configModules, user: configUser, users: configUsers } = config; - - documentUsers.value = configUsers || []; - - // Init current user - Object.assign(user, configUser); - - // Set up module config - Object.assign(modules, configModules); - if (!Object.prototype.hasOwnProperty.call(modules, 'comments')) { - modules.comments = {}; - } - - // For shorthand 'format' key, we can initialize a blank docx - if (!configDocs?.length && !config.modules.collaboration) { - const newDoc = await getFileObject(BlankDOCX, 'blank.docx', DOCX); - const newDocConfig = { - type: DOCX, - data: newDoc, - name: 'blank.docx', - isNewFile: true, - }; - - if (config.html) newDocConfig.html = config.html; - if (config.markdown) newDocConfig.markdown = config.markdown; - configDocs.push(newDocConfig); - } - - // Initialize documents - await initializeDocuments(configDocs); - isReady.value = true; - }; - - /** - * Initialize the documents for this SuperDoc. Changes the store's documents array ref directly. - * @param {Array[Object]} docsToProcess The documents to process from the config - * @returns {Promise} - */ - const initializeDocuments = async (docsToProcess = []) => { - if (!docsToProcess) return []; - - for (let doc of docsToProcess) { - if (!doc) { - emitException({ - error: new Error('Received empty document entry during initialization.'), - stage: 'document-init', - document: doc, - }); - console.warn('[superdoc] Skipping empty document entry.'); - continue; - } - - try { - // Ensure the document object has data (ie: if loading from URL) - let docWithData = await _initializeDocumentData(doc); - - if (!docWithData) { - emitException({ - error: new Error('Document could not be initialized with the provided configuration.'), - stage: 'document-init', - document: doc, - }); - console.warn('[superdoc] Skipping document due to invalid configuration:', doc); - continue; - } - - // Create composable and append to our documents - const smartDoc = useDocument(docWithData, currentConfig.value); - documents.value.push(smartDoc); - } catch (e) { - emitException({ error: e, stage: 'document-init', document: doc }); - console.warn('[superdoc] Error initializing document:', doc, 'with error:', e, 'Skipping document.'); - } - } - }; - - /** - * Convert a Blob to a File object when a filename is required - * @param {Blob} blob The blob to convert - * @param {string} name The filename to assign - * @param {string} type The mime type - * @returns {File} The file object - */ - const _blobToFile = (blob, name, type) => { - return new File([blob], name, { type }); - }; - - /** - * Initialize the document data by fetching the file if necessary - * @param {Object} doc The document config - * @returns {Promise} The document object with data - */ - const _initializeDocumentData = async (doc) => { - // Normalize any uploader-specific wrapper to a native File/Blob upfront - doc = normalizeDocumentEntry(doc); - if (currentConfig.value?.html) doc.html = currentConfig.value.html; - - // Use docx as default if no type provided - if (!doc.data && doc.url && !doc.type) doc.type = DOCX; - - // If in collaboration mode, return the document as is - if (currentConfig.value?.modules.collaboration && !doc.isNewFile) { - return { ...doc, data: null, url: null }; - } - - // If we already have data (File/Blob), ensure it has the expected metadata - if (doc.data instanceof File) { - let fileName = doc.name; - const extension = doc.type === DOCX ? '.docx' : doc.type === PDF ? '.pdf' : '.bin'; - if (!fileName) { - fileName = `document${extension}`; - } else if (!fileName.includes('.')) { - fileName = `${fileName}${extension}`; - } - - if (doc.data.name !== fileName) { - const fileObject = _blobToFile(doc.data, fileName, doc.data.type || doc.type); - return { ...doc, name: fileName, data: fileObject }; - } - - if (!doc.name) return { ...doc, name: fileName }; - - return doc; - } - // If we have a Blob object, convert it to a File with appropriate name - else if (doc.data instanceof Blob) { - // Use provided name or generate a default name based on type - let fileName = doc.name; - if (!fileName) { - const extension = doc.type === DOCX ? '.docx' : doc.type === PDF ? '.pdf' : '.bin'; - fileName = `document${extension}`; - } - const fileObject = _blobToFile(doc.data, fileName, doc.data.type || doc.type); - return { ...doc, data: fileObject }; - } - // If we have any other data object, return it as is (for backward compatibility) - else if (doc.data) return doc; - // If we have a URL, fetch the file and return it - else if (doc.url && doc.type) { - if (doc.type.toLowerCase() === 'docx') doc.type = DOCX; - else if (doc.type.toLowerCase() === 'pdf') doc.type = PDF; - const fileObject = await getFileObject(doc.url, doc.name || 'document', doc.type); - return { ...doc, data: fileObject }; - } - // Invalid configuration - return null; - }; - - const areDocumentsReady = computed(() => { - for (let obj of documents.value.filter((doc) => doc.type === 'pdf')) { - if (!obj.isReady) return false; - } - return true; - }); - - const getDocument = (documentId) => documents.value.find((doc) => doc.id === documentId); - const getPageBounds = (documentId, page) => { - const matchedPage = pages[documentId]; - if (!matchedPage) return; - const pageInfo = matchedPage.find((p) => p.page == page); - if (!pageInfo || !pageInfo.container) return; - - const containerBounds = pageInfo.container.getBoundingClientRect(); - const { height } = containerBounds; - const totalHeight = height * (page - 1); - return { - top: totalHeight, - }; - }; - - const handlePageReady = (documentId, index, containerBounds) => { - if (!pages[documentId]) pages[documentId] = []; - pages[documentId].push({ page: index, containerBounds }); - - const doc = getDocument(documentId); - if (!doc) return; - - doc.pageContainers.push({ - page: index, - containerBounds, - }); - }; - - return { - commentsStore, - documents, - documentBounds, - pages, - documentUsers, - users, - activeZoom, - documentScroll, - isInternal, - - selectionPosition, - activeSelection, - - isReady, - - user, - modules, - - // Getters - areDocumentsReady, - - // Actions - init, - setExceptionHandler, - reset, - handlePageReady, - getDocument, - getPageBounds, - }; -}); diff --git a/packages/superdoc/src/stores/superdoc-store.test.js b/packages/superdoc/src/stores/superdoc-store.test.ts similarity index 79% rename from packages/superdoc/src/stores/superdoc-store.test.js rename to packages/superdoc/src/stores/superdoc-store.test.ts index b57db88c7..bcf5ad71b 100644 --- a/packages/superdoc/src/stores/superdoc-store.test.js +++ b/packages/superdoc/src/stores/superdoc-store.test.ts @@ -1,11 +1,11 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { createPinia, setActivePinia } from 'pinia'; import { useSuperdocStore } from './superdoc-store.js'; import { DOCX, PDF } from '@superdoc/common'; // Mock getFileObject while keeping the rest of the module's exports intact vi.mock('@superdoc/common', async (importOriginal) => { - const actual = await importOriginal(); + const actual = await importOriginal(); return { ...actual, getFileObject: vi.fn(), @@ -14,7 +14,11 @@ vi.mock('@superdoc/common', async (importOriginal) => { // Mock Blob/File constructors for the Node.js environment class BlobMock { - constructor(blobParts = [], options = {}) { + parts: unknown[]; + type: string; + size: number; + + constructor(blobParts: unknown[] = [], options: { type?: string } = {}) { this.parts = blobParts; this.type = options.type || ''; this.size = 0; @@ -26,24 +30,35 @@ class BlobMock { this.size += part.byteLength; } else if (ArrayBuffer.isView(part)) { this.size += part.byteLength; - } else if (part?.size) { - this.size += part.size; + } else if ((part as { size?: number })?.size) { + this.size += (part as { size: number }).size; } } } } -globalThis.Blob = BlobMock; +globalThis.Blob = BlobMock as unknown as typeof Blob; globalThis.File = class FileMock extends BlobMock { - constructor(fileBits, fileName, options = {}) { + name: string; + lastModified: number; + + constructor(fileBits: unknown[], fileName: string, options: { type?: string; lastModified?: number } = {}) { super(fileBits, options); this.name = fileName; this.lastModified = options.lastModified ?? Date.now(); } -}; +} as unknown as typeof File; + +interface TestConfig { + documents: unknown[]; + modules?: { collaboration?: boolean }; + user?: unknown; + users?: unknown[]; + [key: string]: unknown; +} -const createTestConfig = (documents = [], overrides = {}) => { +const createTestConfig = (documents: unknown[] = [], overrides: Partial = {}): TestConfig => { const { modules = {}, user = {}, users = [], ...rest } = overrides; return { @@ -56,7 +71,7 @@ const createTestConfig = (documents = [], overrides = {}) => { }; describe('SuperDoc Store - Blob Support', () => { - let store; + let store: ReturnType; beforeEach(() => { setActivePinia(createPinia()); @@ -88,8 +103,8 @@ describe('SuperDoc Store - Blob Support', () => { expect(store.documents.length).toBe(1); // The blob should be converted to a File internally const document = store.documents[0]; - expect(document.data).toBeInstanceOf(File); - expect(document.data.name).toBe('test.docx'); + expect(document.data as File).toBeInstanceOf(File); + expect((document.data as File).name).toBe('test.docx'); }); it('should handle Blob objects without name', async () => { @@ -102,8 +117,8 @@ describe('SuperDoc Store - Blob Support', () => { expect(store.documents.length).toBe(1); // The blob should be converted to a File with generated name const document = store.documents[0]; - expect(document.data).toBeInstanceOf(File); - expect(document.data.name).toBe('document.docx'); + expect(document.data as File).toBeInstanceOf(File); + expect((document.data as File).name).toBe('document.docx'); }); it('should generate appropriate file extensions based on type', async () => { @@ -121,7 +136,7 @@ describe('SuperDoc Store - Blob Support', () => { await store.init(config); const document = store.documents[0]; - expect(document.data.name).toBe(testCase.expectedName); + expect((document.data as File).name).toBe(testCase.expectedName); // Reset for next test store.reset(); @@ -137,7 +152,7 @@ describe('SuperDoc Store - Blob Support', () => { await store.init(config); const document = store.documents[0]; - expect(document.data.type).toBe(blobType); + expect((document.data as File).type).toBe(blobType); }); it('should use document type if blob type is not available', async () => { @@ -148,7 +163,7 @@ describe('SuperDoc Store - Blob Support', () => { await store.init(config); const document = store.documents[0]; - expect(document.data.type).toBe(DOCX); + expect((document.data as File).type).toBe(DOCX); }); it('should handle mixed File and Blob objects', async () => { @@ -165,12 +180,12 @@ describe('SuperDoc Store - Blob Support', () => { expect(store.documents.length).toBe(2); // First document should remain as File - expect(store.documents[0].data).toBeInstanceOf(File); - expect(store.documents[0].data.name).toBe('file.docx'); + expect(store.documents[0].data as File).toBeInstanceOf(File); + expect((store.documents[0].data as File).name).toBe('file.docx'); // Second document should be converted from Blob to File - expect(store.documents[1].data).toBeInstanceOf(File); - expect(store.documents[1].data.name).toBe('blob.docx'); + expect(store.documents[1].data as File).toBeInstanceOf(File); + expect((store.documents[1].data as File).name).toBe('blob.docx'); }); it('should handle blob instanceof checks correctly', () => { @@ -190,7 +205,7 @@ describe('SuperDoc Store - Blob Support', () => { }); describe('error handling', () => { - let consoleWarnSpy; + let consoleWarnSpy: ReturnType; beforeEach(() => { consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/packages/superdoc/src/stores/superdoc-store.ts b/packages/superdoc/src/stores/superdoc-store.ts new file mode 100644 index 000000000..fd64c00fe --- /dev/null +++ b/packages/superdoc/src/stores/superdoc-store.ts @@ -0,0 +1,504 @@ +import { defineStore } from 'pinia'; +import { ref, shallowRef, reactive, computed, type Ref, type ShallowRef, type ComputedRef } from 'vue'; +import { getFileObject } from '@superdoc/common'; +import { DOCX, PDF } from '@superdoc/common'; +import { normalizeDocumentEntry } from '../core/helpers/file'; +import useDocument, { type UseDocumentReturn } from '../composables/use-document'; +import BlankDOCX from '@superdoc/common/data/blank.docx?url'; +import type { Config, Document as DocumentConfig, User, Document } from '../core/types'; + +/** + * Extended document configuration with additional properties + */ +interface ExtendedDocumentConfig extends DocumentConfig { + /** HTML content for initialization */ + html?: string; + /** Markdown content for initialization */ + markdown?: string; +} + +/** + * Document bounds information + */ +interface DocumentBounds { + [key: string]: unknown; +} + +/** + * Page information keyed by document ID + */ +interface Pages { + [documentId: string]: PageInfo[]; +} + +/** + * Page container information + */ +interface PageInfo { + /** Page number */ + page: number; + /** Container DOM element */ + container?: HTMLElement; + /** Container bounds information */ + containerBounds: DOMRect | { originalHeight: number }; +} + +/** + * Selection position information + */ +interface SelectionPosition { + /** Left position */ + left: number; + /** Top position */ + top: number; + /** Width of the selection */ + width: number; + /** Height of the selection */ + height: number; + /** Source of the selection */ + source: string | null; +} + +/** + * Active selection object + */ +interface ActiveSelection { + /** Document ID */ + documentId: string; + /** Selection bounds */ + selectionBounds: SelectionBounds; + [key: string]: unknown; +} + +/** + * Selection bounds + */ +interface SelectionBounds { + /** Top position */ + top: number; + /** Left position */ + left: number; + /** Width */ + width: number; + /** Height */ + height: number; + [key: string]: unknown; +} + +/** + * Document scroll position + */ +interface DocumentScroll { + /** Vertical scroll position */ + scrollTop: number; + /** Horizontal scroll position */ + scrollLeft: number; +} + +/** + * Exception payload for error handling + */ +interface ExceptionPayload { + /** The error object */ + error: Error; + /** Stage where the error occurred */ + stage?: string; + /** Document related to the error */ + document?: Document; + [key: string]: unknown; +} + +/** + * Page bounds return type + */ +interface PageBounds { + /** Top position of the page */ + top: number; +} + +/** + * Modules configuration + */ +interface Modules { + /** Comments module config */ + comments?: Record; + /** Collaboration module flag */ + collaboration?: boolean; + [key: string]: unknown; +} + +/** + * SuperDoc store return type + */ +interface SuperdocStoreReturn { + // State refs + documents: ShallowRef; + documentBounds: Ref; + pages: Pages; + documentUsers: Ref; + users: Ref; + activeZoom: Ref; + documentScroll: DocumentScroll; + isInternal: Ref; + selectionPosition: Ref; + activeSelection: Ref; + isReady: Ref; + user: User; + modules: Modules; + + // Computed getters + areDocumentsReady: ComputedRef; + + // Actions + init: (config: Config) => Promise; + setExceptionHandler: (handler: ((payload: ExceptionPayload) => void) | null) => void; + reset: () => void; + handlePageReady: (documentId: string, index: number, containerBounds: DOMRect) => void; + getDocument: (documentId: string) => UseDocumentReturn | undefined; + getPageBounds: (documentId: string, page: number) => PageBounds | undefined; +} + +/** + * Pinia store for managing SuperDoc state + * + * This is the main store for SuperDoc that handles: + * - Document management and initialization + * - User state and permissions + * - Module configuration (comments, collaboration, etc.) + * - Selection and scroll state + * - Page bounds and zoom levels + * - Exception handling + * + * It coordinates with other stores (like comments-store) and manages + * the lifecycle of document composables. + */ +export const useSuperdocStore = defineStore('superdoc', (): SuperdocStoreReturn => { + const currentConfig = ref(null); + let exceptionHandler: ((payload: ExceptionPayload) => void) | null = null; + // Lazy load commentsStore to avoid circular dependency + const documents = shallowRef([]); + const documentBounds = ref([]); + const pages = reactive({}); + const documentUsers = ref([]); + const activeZoom = ref(100); + const isReady = ref(false); + const isInternal = ref(false); + + const users = ref([]); + + const user = reactive({ name: '', email: '' }); + const modules = reactive({}); + + const activeSelection = ref(null); + const selectionPosition: Ref = ref({ + left: 0, + top: 0, + width: 0, + height: 0, + source: null, + }); + + /** + * Reset the store to initial state + * + * Clears all documents, pages, users, and resets configuration. + * Called when initializing a new SuperDoc instance. + */ + const reset = (): void => { + documents.value = []; + documentBounds.value = []; + Object.assign(pages, {}); + documentUsers.value = []; + isReady.value = false; + user.name = ''; + user.email = ''; + Object.assign(modules, {}); + activeSelection.value = null; + }; + + const documentScroll = reactive({ + scrollTop: 0, + scrollLeft: 0, + }); + + /** + * Set a custom exception handler + * + * @param handler - Function to handle exceptions + */ + const setExceptionHandler = (handler: ((payload: ExceptionPayload) => void) | null): void => { + exceptionHandler = typeof handler === 'function' ? handler : null; + }; + + /** + * Emit an exception to the configured handler + * + * @param payload - Exception information including error and context + */ + const emitException = (payload: ExceptionPayload): void => { + const handler = exceptionHandler || currentConfig.value?.onException; + if (typeof handler === 'function') handler(payload); + }; + + /** + * Initialize SuperDoc with configuration + * + * @param config - SuperDoc configuration object + */ + const init = async (config: Config): Promise => { + reset(); + currentConfig.value = config; + const { documents: configDocs, modules: configModules, user: configUser, users: configUsers } = config; + + documentUsers.value = configUsers || []; + + // Init current user + Object.assign(user, configUser); + + // Set up module config + Object.assign(modules, configModules); + if (!Object.prototype.hasOwnProperty.call(modules, 'comments')) { + modules.comments = {}; + } + + // For shorthand 'format' key, we can initialize a blank docx + if (!configDocs?.length && !config.modules?.collaboration) { + const newDoc = await getFileObject(BlankDOCX, 'blank.docx', DOCX); + const newDocConfig: ExtendedDocumentConfig = { + type: DOCX, + data: newDoc, + name: 'blank.docx', + isNewFile: true, + }; + + if (config.html) newDocConfig.html = config.html; + if (config.markdown) newDocConfig.markdown = config.markdown; + configDocs!.push(newDocConfig); + } + + // Initialize documents + await initializeDocuments(configDocs); + isReady.value = true; + }; + + /** + * Initialize the documents for this SuperDoc. Changes the store's documents array ref directly. + * + * @param docsToProcess - The documents to process from the config + */ + const initializeDocuments = async (docsToProcess: ExtendedDocumentConfig[] = []): Promise => { + if (!docsToProcess) return; + + for (const doc of docsToProcess) { + if (!doc) { + emitException({ + error: new Error('Received empty document entry during initialization.'), + stage: 'document-init', + document: doc, + }); + console.warn('[superdoc] Skipping empty document entry.'); + continue; + } + + try { + // Ensure the document object has data (ie: if loading from URL) + const docWithData = await _initializeDocumentData(doc); + + if (!docWithData) { + emitException({ + error: new Error('Document could not be initialized with the provided configuration.'), + stage: 'document-init', + document: doc, + }); + console.warn('[superdoc] Skipping document due to invalid configuration:', doc); + continue; + } + + // Create composable and append to our documents + const smartDoc = useDocument( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + docWithData as any, + currentConfig.value! as Config, + ); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + documents.value.push(smartDoc as any); + } catch (e) { + emitException({ error: e as Error, stage: 'document-init', document: doc }); + console.warn('[superdoc] Error initializing document:', doc, 'with error:', e, 'Skipping document.'); + } + } + }; + + /** + * Convert a Blob to a File object when a filename is required + * + * @param blob - The blob to convert + * @param name - The filename to assign + * @param type - The mime type + * @returns The file object + */ + const _blobToFile = (blob: Blob, name: string, type: string): File => { + return new File([blob], name, { type }); + }; + + /** + * Initialize the document data by fetching the file if necessary + * + * @param doc - The document config + * @returns The document object with data + */ + const _initializeDocumentData = async (doc: ExtendedDocumentConfig): Promise => { + // Normalize any uploader-specific wrapper to a native File/Blob upfront + doc = normalizeDocumentEntry(doc) as ExtendedDocumentConfig; + if (currentConfig.value?.html) doc.html = currentConfig.value.html; + + // Use docx as default if no type provided + if (!doc.data && doc.url && !doc.type) doc.type = DOCX; + + // If in collaboration mode, return the document as is + if (currentConfig.value?.modules?.collaboration && !doc.isNewFile) { + return { ...doc, data: null, url: undefined }; + } + + // If we already have data (File/Blob), ensure it has the expected metadata + if (doc.data instanceof File) { + let fileName = doc.name; + const extension = doc.type === DOCX ? '.docx' : doc.type === PDF ? '.pdf' : '.bin'; + if (!fileName) { + fileName = `document${extension}`; + } else if (!fileName.includes('.')) { + fileName = `${fileName}${extension}`; + } + + if (doc.data.name !== fileName) { + const fileObject = _blobToFile(doc.data, fileName, doc.data.type || doc.type); + return { ...doc, name: fileName, data: fileObject }; + } + + if (!doc.name) return { ...doc, name: fileName }; + + return doc; + } + // If we have a Blob object, convert it to a File with appropriate name + else if (doc.data instanceof Blob) { + // Use provided name or generate a default name based on type + let fileName = doc.name; + if (!fileName) { + const extension = doc.type === DOCX ? '.docx' : doc.type === PDF ? '.pdf' : '.bin'; + fileName = `document${extension}`; + } + const fileObject = _blobToFile(doc.data, fileName, doc.data.type || doc.type); + return { ...doc, data: fileObject }; + } + // If we have any other data object, return it as is (for backward compatibility) + else if (doc.data) return doc; + // If we have a URL, fetch the file and return it + else if (doc.url && doc.type) { + if (doc.type.toLowerCase() === 'docx') doc.type = DOCX; + else if (doc.type.toLowerCase() === 'pdf') doc.type = PDF; + const fileObject = await getFileObject( + doc.url, + doc.name || 'document', + doc.type as 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' | 'application/pdf', + ); + return { ...doc, data: fileObject }; + } + // Invalid configuration + return null; + }; + + /** + * Computed getter for checking if all documents are ready + * + * @returns True if all PDF documents have finished loading + */ + const areDocumentsReady = computed(() => { + const pdfDocs = documents.value.filter((doc) => doc.type === 'pdf'); + for (const obj of pdfDocs) { + const isReadyValue = obj.isReady as Ref | boolean; + if (!isReadyValue || (typeof isReadyValue === 'object' && 'value' in isReadyValue && !isReadyValue.value)) + return false; + } + return true; + }); + + /** + * Get a document by ID + * + * @param documentId - The document ID to find + * @returns The document or undefined if not found + */ + const getDocument = (documentId: string): UseDocumentReturn | undefined => + documents.value.find((doc) => doc.id === documentId) as UseDocumentReturn | undefined; + + /** + * Get the bounds of a specific page + * + * @param documentId - The document ID + * @param page - The page number + * @returns Page bounds or undefined if not found + */ + const getPageBounds = (documentId: string, page: number): PageBounds | undefined => { + const matchedPage = pages[documentId]; + if (!matchedPage) return; + const pageInfo = matchedPage.find((p) => p.page == page); + if (!pageInfo || !pageInfo.container) return; + + const containerBounds = pageInfo.container.getBoundingClientRect(); + const { height } = containerBounds; + const totalHeight = height * (page - 1); + return { + top: totalHeight, + }; + }; + + /** + * Handle when a page is ready and register its bounds + * + * @param documentId - The document ID + * @param index - The page index/number + * @param containerBounds - The container's bounding rect + */ + const handlePageReady = (documentId: string, index: number, containerBounds: DOMRect): void => { + if (!pages[documentId]) pages[documentId] = []; + pages[documentId].push({ page: index, containerBounds }); + + const doc = getDocument(documentId); + if (!doc) return; + + doc.pageContainers.value.push({ + page: index, + containerBounds, + } as unknown as HTMLElement); + }; + + return { + documents, + documentBounds, + pages, + documentUsers, + users, + activeZoom, + documentScroll, + isInternal, + + selectionPosition, + activeSelection, + + isReady, + + user, + modules, + + // Getters + areDocumentsReady, + + // Actions + init, + setExceptionHandler, + reset, + handlePageReady, + getDocument, + getPageBounds, + }; +}); diff --git a/packages/superdoc/src/stores/types.ts b/packages/superdoc/src/stores/types.ts new file mode 100644 index 000000000..1a54f83ae --- /dev/null +++ b/packages/superdoc/src/stores/types.ts @@ -0,0 +1,40 @@ +/** + * Shared type definitions for Pinia stores + * + * This module provides type exports for store instances to avoid + * circular dependencies and the ReturnType pattern. + */ + +import type { useSuperdocStore } from './superdoc-store'; +import type { useCommentsStore } from './comments-store'; + +/** + * Selection bounds for positioning comments and annotations + */ +export interface SelectionBounds { + /** Top position */ + top: number; + /** Left position */ + left: number; + /** Width of the selection */ + width?: number; + /** Height of the selection */ + height?: number; + /** Right position */ + right?: number; + /** Bottom position */ + bottom?: number; + [key: string]: unknown; +} + +/** + * SuperDoc store instance type + * Use this instead of ReturnType + */ +export type SuperdocStoreInstance = ReturnType; + +/** + * Comments store instance type + * Use this instead of ReturnType + */ +export type CommentsStoreInstance = ReturnType; diff --git a/packages/superdoc/src/super-editor.js b/packages/superdoc/src/super-editor.ts similarity index 100% rename from packages/superdoc/src/super-editor.js rename to packages/superdoc/src/super-editor.ts diff --git a/packages/superdoc/src/tests/helpers/group-changes.test.js b/packages/superdoc/src/tests/helpers/group-changes.test.ts similarity index 100% rename from packages/superdoc/src/tests/helpers/group-changes.test.js rename to packages/superdoc/src/tests/helpers/group-changes.test.ts diff --git a/packages/superdoc/src/types.d.ts b/packages/superdoc/src/types.d.ts new file mode 100644 index 000000000..42aab49c0 --- /dev/null +++ b/packages/superdoc/src/types.d.ts @@ -0,0 +1,25 @@ +// Global type declarations for module resolution + +// Vite asset imports +declare module '*.svg?raw' { + const content: string; + export default content; +} + +declare module '*.docx?url' { + const content: string; + export default content; +} + +// Super-editor module augmentations +declare module '@harbour-enterprises/super-editor/docx-zipper' { + export * from '@harbour-enterprises/super-editor'; +} + +declare module '@harbour-enterprises/super-editor/toolbar' { + export * from '@harbour-enterprises/super-editor'; +} + +declare module '@harbour-enterprises/super-editor/file-zipper' { + export * from '@harbour-enterprises/super-editor'; +} diff --git a/packages/superdoc/src/vite-env.d.ts b/packages/superdoc/src/vite-env.d.ts new file mode 100644 index 000000000..2db610127 --- /dev/null +++ b/packages/superdoc/src/vite-env.d.ts @@ -0,0 +1,31 @@ +/// + +// Global constants injected at build time +declare const __APP_VERSION__: string; +declare const __IS_DEBUG__: boolean; + +declare module '*.vue' { + import type { DefineComponent } from 'vue'; + const component: DefineComponent; + export default component; +} + +declare module '*.svg?raw' { + const content: string; + export default content; +} + +declare module '*.docx?url' { + const content: string; + export default content; +} + +declare module '@superdoc/common/icons/*.svg?raw' { + const content: string; + export default content; +} + +declare module '@superdoc/common/data/*.docx?url' { + const content: string; + export default content; +} diff --git a/packages/superdoc/tsconfig.build.json b/packages/superdoc/tsconfig.build.json new file mode 100644 index 000000000..eaed339c4 --- /dev/null +++ b/packages/superdoc/tsconfig.build.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "emitDeclarationOnly": true + }, + "exclude": ["node_modules", "dist", "**/*.test.js", "**/*.test.ts"] +} diff --git a/packages/superdoc/tsconfig.json b/packages/superdoc/tsconfig.json index f1c5126c4..8fc41711c 100644 --- a/packages/superdoc/tsconfig.json +++ b/packages/superdoc/tsconfig.json @@ -10,6 +10,7 @@ "outDir": "dist", "declarationMap": true, "skipLibCheck": true, + "noErrorTruncation": true, "paths": { "@superdoc/common": ["../../shared/common/index.ts"], "@superdoc/common/*": ["../../shared/common/*"] diff --git a/packages/superdoc/vite.config.js b/packages/superdoc/vite.config.ts similarity index 82% rename from packages/superdoc/vite.config.js rename to packages/superdoc/vite.config.ts index cec869540..1798021b9 100644 --- a/packages/superdoc/vite.config.js +++ b/packages/superdoc/vite.config.ts @@ -1,23 +1,26 @@ -import path from 'path'; -import copy from 'rollup-plugin-copy' -import { defineConfig } from 'vite' +import path from 'node:path'; import { fileURLToPath, URL } from 'node:url'; -import { nodePolyfills } from 'vite-plugin-node-polyfills'; + +import vue from '@vitejs/plugin-vue'; +import copy from 'rollup-plugin-copy'; import { visualizer } from 'rollup-plugin-visualizer'; -import vue from '@vitejs/plugin-vue' +import { defineConfig } from 'vite'; +import { nodePolyfills } from 'vite-plugin-node-polyfills'; import { version } from './package.json'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + const visualizerConfig = { filename: './dist/bundle-analysis.html', template: 'treemap', gzipSize: true, brotliSize: true, - open: true -} + open: true, +}; -export const getAliases = (isDev) => { - const aliases = { +export const getAliases = (isDev: boolean) => { + const aliases: Record = { // IMPORTANT: Specific @superdoc/* package aliases must come BEFORE the generic '@superdoc' // to avoid partial matches swallowing them. '@superdoc/common': path.resolve(__dirname, '../../shared/common'), @@ -50,7 +53,9 @@ export const getAliases = (isDev) => { '@helpers': fileURLToPath(new URL('../super-editor/src/core/helpers', import.meta.url)), '@converter': fileURLToPath(new URL('../super-editor/src/core/super-converter', import.meta.url)), '@tests': fileURLToPath(new URL('../super-editor/src/tests', import.meta.url)), - '@translator': fileURLToPath(new URL('../super-editor/src/core/super-converter/v3/node-translator/index.js', import.meta.url)), + '@translator': fileURLToPath( + new URL('../super-editor/src/core/super-converter/v3/node-translator/index.js', import.meta.url), + ), }; if (isDev) { @@ -60,9 +65,8 @@ export const getAliases = (isDev) => { return aliases; }; - // https://vitejs.dev/config/ -export default defineConfig(({ mode, command}) => { +export default defineConfig(({ mode, command }) => { const plugins = [ vue(), copy({ @@ -71,15 +75,19 @@ export default defineConfig(({ mode, command}) => { src: path.resolve(__dirname, '../super-editor/dist/*'), dest: 'dist/super-editor', }, - { - src: path.resolve(__dirname, '../../node_modules/pdfjs-dist/web/images/*'), + { + src: path.resolve(__dirname, '../../node_modules/pdfjs-dist/web/images/*'), dest: 'dist/images', }, ], - hook: 'writeBundle' + hook: 'writeBundle', }), - // visualizer(visualizerConfig) ]; + + if (process.env.BUNDLE_ANALYZE === 'true') { + plugins.push(visualizer(visualizerConfig)); + } + if (mode !== 'test') plugins.push(nodePolyfills()); const isDev = command === 'serve'; @@ -99,24 +107,22 @@ export default defineConfig(({ mode, command}) => { retry: 2, testTimeout: 20000, hookTimeout: 10000, - exclude: [ - '**/*.spec.js', - ], + exclude: ['**/*.spec.js'], }, build: { target: 'es2022', cssCodeSplit: false, lib: { - entry: "src/index.js", - name: "SuperDoc", + entry: 'src/index.ts', + name: 'SuperDoc', cssFileName: 'style', }, minify: false, sourcemap: false, rollupOptions: { input: { - 'superdoc': 'src/index.js', - 'super-editor': 'src/super-editor.js', + superdoc: 'src/index.ts', + 'super-editor': 'src/super-editor.ts', }, external: [ 'yjs', @@ -133,29 +139,29 @@ export default defineConfig(({ mode, command}) => { entryFileNames: '[name].es.js', chunkFileNames: 'chunks/[name]-[hash].es.js', manualChunks: { - 'vue': ['vue'], + vue: ['vue'], 'blank-docx': ['@superdoc/common/data/blank.docx?url'], - 'jszip': ['jszip'], - 'eventemitter3': ['eventemitter3'], - 'uuid': ['uuid'], + jszip: ['jszip'], + eventemitter3: ['eventemitter3'], + uuid: ['uuid'], 'xml-js': ['xml-js'], - } + }, }, { format: 'cjs', entryFileNames: '[name].cjs', chunkFileNames: 'chunks/[name]-[hash].cjs', manualChunks: { - 'vue': ['vue'], + vue: ['vue'], 'blank-docx': ['@superdoc/common/data/blank.docx?url'], - 'jszip': ['jszip'], - 'eventemitter3': ['eventemitter3'], - 'uuid': ['uuid'], + jszip: ['jszip'], + eventemitter3: ['eventemitter3'], + uuid: ['uuid'], 'xml-js': ['xml-js'], - } - } - ], - } + }, + }, + ], + }, }, optimizeDeps: { include: ['yjs', '@hocuspocus/provider'], @@ -192,5 +198,5 @@ export default defineConfig(({ mode, command}) => { ], }, }, - } + }; }); diff --git a/packages/superdoc/vite.config.umd.js b/packages/superdoc/vite.config.umd.ts similarity index 94% rename from packages/superdoc/vite.config.umd.js rename to packages/superdoc/vite.config.umd.ts index 8b39f2bdd..3ae6b8c29 100644 --- a/packages/superdoc/vite.config.umd.js +++ b/packages/superdoc/vite.config.umd.ts @@ -1,7 +1,8 @@ -import vue from '@vitejs/plugin-vue'; import { defineConfig } from 'vite'; +import vue from '@vitejs/plugin-vue'; + import { version } from './package.json'; -import { getAliases } from './vite.config.js'; +import { getAliases } from './vite.config'; export default defineConfig(({ command }) => { const plugins = [vue()]; @@ -22,7 +23,7 @@ export default defineConfig(({ command }) => { target: 'es2022', cssCodeSplit: false, lib: { - entry: 'src/index.js', + entry: 'src/index.ts', formats: ['umd'], name: 'SuperDocLibrary', cssFileName: 'style',