diff --git a/package.json b/package.json index 8d23241f7..96a6bd747 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,8 @@ "dev:superdoc": "pnpm --prefix packages/superdoc run dev", "dev:super-editor": "pnpm --prefix packages/super-editor run dev", "dev:collab": "pnpm --prefix packages/superdoc run dev:collab", + "dev:collab:hocuspocus": "pnpm --prefix packages/superdoc run dev:collab:hocuspocus", + "dev:collab:liveblocks": "pnpm --prefix packages/superdoc run dev:collab:liveblocks", "dev:docs": "pnpm --prefix apps/docs run dev", "build:superdoc": "pnpm --prefix packages/superdoc run build", "build:super-editor": "pnpm --prefix packages/super-editor run build", diff --git a/packages/super-editor/src/components/SuperEditor.vue b/packages/super-editor/src/components/SuperEditor.vue index d41db8a5a..a64019ed8 100644 --- a/packages/super-editor/src/components/SuperEditor.vue +++ b/packages/super-editor/src/components/SuperEditor.vue @@ -19,6 +19,7 @@ import { getFileObject } from '@superdoc/common'; import BlankDOCX from '@superdoc/common/data/blank.docx?url'; import { isHeadless } from '@utils/headless-helpers.js'; import { isMacOS } from '@core/utilities/isMacOS.js'; +import { buildDocumentXmlPlaceholder } from '@extensions/collaboration/collaboration-helpers.js'; const emit = defineEmits(['editor-ready', 'editor-click', 'editor-keydown', 'comments-loaded', 'selection-update']); const DOCX = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'; @@ -684,13 +685,40 @@ const stopPolling = () => { const pollForMetaMapData = (ydoc, retries = 10, interval = 500) => { const metaMap = ydoc.getMap('meta'); + const docxFilesMap = ydoc.getMap('docxFiles'); const checkData = () => { - const docx = metaMap.get('docx'); - if (docx) { + // New format: per-file Y.Map + if (docxFilesMap.size > 0 && metaMap.get('docxReady')) { + const docx = []; + docxFilesMap.forEach((content, name) => { + docx.push({ name, content }); + }); + + // word/document.xml is not stored in Y.Map — its content is synced via + // y-prosemirror XmlFragment instead. Provide a placeholder that includes + // the real body sectPr so the converter can resolve header/footer variant + // mappings, page size, margins, etc. + if (!docx.some((f) => f.name === 'word/document.xml')) { + const bodySectPr = metaMap.get('bodySectPr'); + const placeholder = buildDocumentXmlPlaceholder(bodySectPr); + docx.push({ name: 'word/document.xml', content: placeholder }); + } + stopPolling(); initEditor({ content: docx }); - } else if (retries > 0) { + return; + } + + // Legacy format: monolithic array in metaMap + const docxLegacy = metaMap.get('docx'); + if (docxLegacy) { + stopPolling(); + initEditor({ content: docxLegacy }); + return; + } + + if (retries > 0) { dataPollTimeout = setTimeout(checkData, interval); retries--; } else { @@ -760,8 +788,9 @@ const initializeData = async () => { waitForSync().then(async () => { const metaMap = ydoc.getMap('meta'); + const docxFilesMap = ydoc.getMap('docxFiles'); - if (metaMap.has('docx')) { + if (metaMap.has('docxReady') || docxFilesMap.size > 0 || metaMap.has('docx')) { // Existing content - poll for it pollForMetaMapData(ydoc); } else { diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 91a6b7a63..bfe6393c2 100644 --- a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts +++ b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts @@ -2978,6 +2978,9 @@ export class PresentationEditor extends EventEmitter { let footerLayouts: HeaderFooterLayoutResult[] | undefined; let extraBlocks: FlowBlock[] | undefined; let extraMeasures: Measure[] | undefined; + // Refresh header/footer descriptors so the manager picks up any new + // headers/footers after replaceFile recreates the converter. + this.#headerFooterSession?.manager?.refresh(); const headerFooterInput = this.#buildHeaderFooterInput(); try { const incrementalLayoutStart = perfNow(); diff --git a/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js b/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js index 084a56bac..71f748134 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js @@ -1,6 +1,80 @@ +/** + * Files whose content is already synced via y-prosemirror XmlFragment. + * These are automatically skipped in Y.Map storage during collaboration + * to avoid exceeding WebSocket message size limits. + */ +const CRDT_SYNCED_FILES = new Set(['word/document.xml']); + +/** + * Minimal placeholder for word/document.xml used when the file is excluded + * from Y.Map storage (synced via XmlFragment instead). The converter needs + * this file present to initialize its schema on joining clients. + */ +export const PLACEHOLDER_DOCUMENT_XML = + '' + + '' + + ''; + +/** + * Returns true if a DOCX file should be synced to the Y.Map. + * Files already synced via y-prosemirror XmlFragment (e.g. word/document.xml) + * are automatically excluded during collaboration. + * + * @param {string} fileName + * @returns {boolean} + */ +export const shouldSyncFile = (fileName) => { + if (CRDT_SYNCED_FILES.has(fileName)) return false; + return true; +}; + +/** + * Read existing docx file contents from Yjs. + * Reads from the per-file Y.Map ('docxFiles') first, falling back to + * the legacy monolithic array in metaMap ('docx'). + * + * @param {Y.Doc} ydoc + * @returns {Record} Map of filename → XML content + */ +const readExistingDocxFiles = (ydoc) => { + const existing = {}; + const docxFilesMap = ydoc.getMap('docxFiles'); + + if (docxFilesMap.size > 0) { + docxFilesMap.forEach((content, name) => { + existing[name] = content; + }); + return existing; + } + + // Legacy fallback: monolithic array in metaMap + const metaMap = ydoc.getMap('meta'); + const docxValue = metaMap.get('docx'); + if (!docxValue) return existing; + + let docx = []; + if (Array.isArray(docxValue)) { + docx = docxValue; + } else if (docxValue && typeof docxValue.toArray === 'function') { + docx = docxValue.toArray(); + } else if (docxValue && typeof docxValue[Symbol.iterator] === 'function') { + docx = Array.from(docxValue); + } + + docx.forEach((file) => { + if (file?.name && file?.content) existing[file.name] = file.content; + }); + return existing; +}; + /** * Update the Ydoc document data with the latest Docx XML. * + * Each DOCX file is stored as a separate entry in a Y.Map ('docxFiles') + * so that each Yjs update message stays small. This avoids exceeding + * WebSocket message size limits (e.g. Liveblocks' ~1 MB cap). + * * @param {Editor} editor The editor instance * @returns {Promise} */ @@ -10,60 +84,113 @@ export const updateYdocDocxData = async (editor, ydoc) => { if (!ydoc) return; if (!editor || editor.isDestroyed) return; + const docxFilesMap = ydoc.getMap('docxFiles'); const metaMap = ydoc.getMap('meta'); - const docxValue = metaMap.get('docx'); - - let docx = []; - if (Array.isArray(docxValue)) { - docx = [...docxValue]; - } else if (docxValue && typeof docxValue.toArray === 'function') { - docx = docxValue.toArray(); - } else if (docxValue && typeof docxValue[Symbol.iterator] === 'function') { - docx = Array.from(docxValue); + + // When the file owner uploads/replaces a file, write ALL files from the + // new content to docxFilesMap synchronously (before any await). This is + // critical because: + // 1. replaceFile does NOT await this function — only synchronous code runs + // before Y.encodeStateAsUpdate captures the state + // 2. exportDocx({ getUpdatedDocs: true }) only returns editor-changed files, + // NOT static XML like headers, footers, rels, themes, etc. + // 3. The old seeding logic only triggered when docxFilesMap was empty, but + // after replaceFile the blank doc's files already populate it + if (editor.options.isNewFile && Array.isArray(editor.options.content)) { + const documentXml = editor.options.content.find((f) => f.name === 'word/document.xml')?.content; + const sectPrXml = extractBodySectPr(documentXml); + if (sectPrXml && sectPrXml !== metaMap.get('bodySectPr')) { + metaMap.set('bodySectPr', sectPrXml); + } + + // Write every file from the new content to docxFilesMap + editor.options.content.forEach((file) => { + if (file?.name && file?.content && shouldSyncFile(file.name)) { + docxFilesMap.set(file.name, file.content); + } + }); } - if (!docx.length && Array.isArray(editor.options.content)) { - docx = [...editor.options.content]; + const isNewFormat = docxFilesMap.size > 0; + const existingFiles = readExistingDocxFiles(ydoc); + + // Seed from editor content if nothing stored yet (first load, no replaceFile) + if (!Object.keys(existingFiles).length && Array.isArray(editor.options.content)) { + editor.options.content.forEach((file) => { + if (file?.name && file?.content) existingFiles[file.name] = file.content; + }); + } + + // Migrate legacy format: copy ALL files to per-file Y.Map before updating. + // Without this, static assets (themes, fontTable, docProps) would be lost + // because exportDocx({ getUpdatedDocs: true }) only returns changed files. + if (!isNewFormat && Object.keys(existingFiles).length > 0) { + Object.entries(existingFiles).forEach(([name, content]) => { + if (shouldSyncFile(name)) { + docxFilesMap.set(name, content); + } + }); + // Delete the legacy monolithic array to free up space in the Y.Doc. + if (metaMap.has('docx')) { + metaMap.delete('docx'); + } } const newXml = await editor.exportDocx({ getUpdatedDocs: true }); if (!newXml || typeof newXml !== 'object') return; - let hasChanges = false; - + // Write each changed file as its own Y.Map entry (separate WS messages). Object.keys(newXml).forEach((key) => { - const fileIndex = docx.findIndex((item) => item.name === key); - const existingContent = fileIndex > -1 ? docx[fileIndex].content : null; - - // Skip if content hasn't changed - if (existingContent === newXml[key]) { - return; - } - - hasChanges = true; - if (fileIndex > -1) { - docx.splice(fileIndex, 1); - } - docx.push({ - name: key, - content: newXml[key], - }); + if (!shouldSyncFile(key)) return; + if (existingFiles[key] === newXml[key]) return; + docxFilesMap.set(key, newXml[key]); }); - // Only transact if there were actual changes OR this is initial setup - if (hasChanges || !docxValue) { - ydoc.transact( - () => { - metaMap.set('docx', docx); - }, - { event: 'docx-update', user: editor.options.user }, - ); + if (!metaMap.get('docxReady')) { + metaMap.set('docxReady', true); } } catch (error) { console.warn('[collaboration] Failed to update Ydoc docx data', error); } }; +/** + * Extract the body-level element from a document.xml string. + * This contains header/footer references, page size, margins, and other + * section properties needed by joining collaboration clients. + * + * @param {string} documentXml The raw XML content of word/document.xml + * @returns {string|null} The raw ... substring, or null + */ +export const extractBodySectPr = (documentXml) => { + if (!documentXml) return null; + const lastIdx = documentXml.lastIndexOf('', lastIdx); + if (endIdx === -1) return null; + return documentXml.substring(lastIdx, endIdx + ''.length); +}; + +/** + * Build a placeholder document.xml that includes the real body sectPr. + * Used by joining clients so the converter can resolve header/footer + * variant mappings, page size, margins, etc. from the section properties. + * + * @param {string|null} bodySectPr The raw XML string + * @returns {string} A minimal document.xml with the section properties included + */ +export const buildDocumentXmlPlaceholder = (bodySectPr) => { + if (!bodySectPr) return PLACEHOLDER_DOCUMENT_XML; + return ( + '' + + '' + + '' + + bodySectPr + + '' + ); +}; + // Header/footer real-time sync // Current approach: last-writer-wins with full JSON replacement. // Future: CRDT-based sync (like y-prosemirror) for character-level merging. diff --git a/packages/super-editor/src/extensions/collaboration/collaboration.js b/packages/super-editor/src/extensions/collaboration/collaboration.js index cd3d79a9c..517c22a04 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration.js @@ -2,7 +2,12 @@ import { Extension } from '@core/index.js'; import { PluginKey } from 'prosemirror-state'; import { encodeStateAsUpdate } from 'yjs'; import { ySyncPlugin, ySyncPluginKey, yUndoPluginKey, prosemirrorToYDoc } from 'y-prosemirror'; -import { updateYdocDocxData, applyRemoteHeaderFooterChanges } from '@extensions/collaboration/collaboration-helpers.js'; +import { + updateYdocDocxData, + applyRemoteHeaderFooterChanges, + extractBodySectPr, + shouldSyncFile, +} from '@extensions/collaboration/collaboration-helpers.js'; export const CollaborationPluginKey = new PluginKey('collaboration'); const headlessBindingStateByEditor = new WeakMap(); @@ -112,18 +117,43 @@ export const createSyncPlugin = (ydoc, editor) => { export const initializeMetaMap = (ydoc, editor) => { const metaMap = ydoc.getMap('meta'); - metaMap.set('docx', editor.options.content); metaMap.set('fonts', editor.options.fonts); + // Store each docx file as a separate Y.Map entry (smaller Yjs messages). + const docxFilesMap = ydoc.getMap('docxFiles'); + const content = editor.options.content; + if (Array.isArray(content)) { + content.forEach((file) => { + if (file?.name && file?.content && shouldSyncFile(file.name)) { + docxFilesMap.set(file.name, file.content); + } + }); + } + metaMap.set('docxReady', true); + const mediaMap = ydoc.getMap('media'); Object.entries(editor.options.mediaFiles).forEach(([key, value]) => { mediaMap.set(key, value); }); + + // Store body sectPr so joining clients can resolve header/footer variant + // mappings, page size, margins, etc. from the placeholder document.xml. + const documentXml = Array.isArray(content) ? content.find((f) => f.name === 'word/document.xml')?.content : null; + const sectPrXml = extractBodySectPr(documentXml); + if (sectPrXml) { + metaMap.set('bodySectPr', sectPrXml); + } }; -const checkDocxChanged = (transaction) => { +const checkDocxChanged = (transaction, docxFilesMap) => { if (!transaction.changed) return false; + // New format: per-file Y.Map + if (docxFilesMap && transaction.changed.has(docxFilesMap)) { + return true; + } + + // Legacy format: monolithic array in metaMap for (const [, value] of transaction.changed.entries()) { if (value instanceof Set && value.has('docx')) { return true; @@ -138,10 +168,12 @@ const initDocumentListener = ({ ydoc, editor }) => { updateYdocDocxData(editor); }, 1000); + const docxFilesMap = ydoc.getMap('docxFiles'); + ydoc.on('afterTransaction', (transaction) => { const { local } = transaction; - const hasChangedDocx = checkDocxChanged(transaction); + const hasChangedDocx = checkDocxChanged(transaction, docxFilesMap); if (!hasChangedDocx && transaction.changed?.size && local) { debouncedUpdate(editor); } diff --git a/packages/super-editor/src/extensions/collaboration/collaboration.test.js b/packages/super-editor/src/extensions/collaboration/collaboration.test.js index f7b2e3f34..9a88b37e9 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration.test.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration.test.js @@ -35,7 +35,8 @@ import * as CollaborationHelpers from './collaboration-helpers.js'; const { Collaboration, CollaborationPluginKey, createSyncPlugin, initializeMetaMap, generateCollaborationData } = CollaborationModule; -const { updateYdocDocxData } = CollaborationHelpers; +const { updateYdocDocxData, extractBodySectPr, buildDocumentXmlPlaceholder, PLACEHOLDER_DOCUMENT_XML } = + CollaborationHelpers; const createYMap = (initial = {}) => { const store = new Map(Object.entries(initial)); @@ -45,12 +46,18 @@ const createYMap = (initial = {}) => { store.set(key, value); }), get: vi.fn((key) => store.get(key)), + has: vi.fn((key) => store.has(key)), + delete: vi.fn((key) => store.delete(key)), + forEach: vi.fn((fn) => store.forEach((value, key) => fn(value, key))), observe: vi.fn((fn) => { observer = fn; }), _trigger(keys) { observer?.({ changes: { keys } }); }, + get size() { + return store.size; + }, store, }; }; @@ -60,12 +67,14 @@ const createYDocStub = ({ docxValue, hasDocx = true } = {}) => { const metas = createYMap(initialMetaEntries); if (!hasDocx) metas.store.delete('docx'); const media = createYMap(); + const docxFiles = createYMap(); const headerFooterJson = createYMap(); const listeners = {}; return { getXmlFragment: vi.fn(() => ({ fragment: true })), getMap: vi.fn((name) => { if (name === 'meta') return metas; + if (name === 'docxFiles') return docxFiles; if (name === 'headerFooterJson') return headerFooterJson; return media; }), @@ -73,7 +82,7 @@ const createYDocStub = ({ docxValue, hasDocx = true } = {}) => { listeners[event] = handler; }), transact: vi.fn((fn, meta) => fn(meta)), - _maps: { metas, media, headerFooterJson }, + _maps: { metas, media, docxFiles, headerFooterJson }, _listeners: listeners, }; }; @@ -87,259 +96,375 @@ afterEach(() => { }); describe('collaboration helpers', () => { - it('updates docx payloads inside the ydoc meta map', async () => { - const ydoc = createYDocStub(); - const metas = ydoc._maps.metas; - metas.store.set('docx', [{ name: 'word/document.xml', content: '' }]); + describe('per-file format', () => { + it('writes changed files to docxFilesMap', async () => { + const ydoc = createYDocStub({ hasDocx: false }); + // Pre-populate docxFiles to simulate existing per-file format + const docxFiles = ydoc._maps.docxFiles; + docxFiles.store.set('word/styles.xml', ''); - const editor = { - options: { ydoc, user: { id: 'user-1' } }, - exportDocx: vi.fn().mockResolvedValue({ 'word/document.xml': '', 'word/styles.xml': '' }), - }; + const editor = { + options: { ydoc, user: { id: 'user-1' } }, + exportDocx: vi.fn().mockResolvedValue({ + 'word/styles.xml': '', + 'word/numbering.xml': '', + }), + }; - await updateYdocDocxData(editor); + await updateYdocDocxData(editor); - expect(editor.exportDocx).toHaveBeenCalledWith({ getUpdatedDocs: true }); - expect(metas.set).toHaveBeenCalledWith('docx', [ - { name: 'word/document.xml', content: '' }, - { name: 'word/styles.xml', content: '' }, - ]); - expect(ydoc.transact).toHaveBeenCalledWith(expect.any(Function), { - event: 'docx-update', - user: editor.options.user, + expect(editor.exportDocx).toHaveBeenCalledWith({ getUpdatedDocs: true }); + expect(docxFiles.set).toHaveBeenCalledWith('word/styles.xml', ''); + expect(docxFiles.set).toHaveBeenCalledWith('word/numbering.xml', ''); }); - }); - it('returns early when neither explicit ydoc nor editor.options.ydoc exist', async () => { - const editor = { - options: { ydoc: null, user: { id: 'user-1' }, content: [] }, - exportDocx: vi.fn(), - }; + it('skips unchanged files', async () => { + const ydoc = createYDocStub({ hasDocx: false }); + const docxFiles = ydoc._maps.docxFiles; + docxFiles.store.set('word/styles.xml', ''); - await updateYdocDocxData(editor); + const editor = { + options: { ydoc, user: { id: 'user-1' } }, + exportDocx: vi.fn().mockResolvedValue({ + 'word/styles.xml': '', + }), + }; - expect(editor.exportDocx).not.toHaveBeenCalled(); - }); + await updateYdocDocxData(editor); - it('normalizes docx arrays via toArray when meta map stores a Y.Array-like structure', async () => { - const docxSource = { - toArray: vi.fn(() => [{ name: 'word/document.xml', content: '' }]), - }; - const ydoc = createYDocStub({ docxValue: docxSource }); - const metas = ydoc._maps.metas; + // set is called during migration seeding but not for unchanged content + // Since docxFiles already has entries (size > 0), no migration happens. + // The only set call would be for changed files — none here. + const setCallsAfterExport = docxFiles.set.mock.calls.filter( + ([key, value]) => key === 'word/styles.xml' && value === '', + ); + expect(setCallsAfterExport).toHaveLength(0); + }); - const editor = { - options: { ydoc, user: { id: 'user-2' }, content: [] }, - exportDocx: vi.fn().mockResolvedValue({ - 'word/document.xml': '', - 'word/styles.xml': '', - }), - }; + it('skips CRDT-synced files (word/document.xml)', async () => { + const ydoc = createYDocStub({ hasDocx: false }); + const docxFiles = ydoc._maps.docxFiles; + docxFiles.store.set('word/styles.xml', ''); - await updateYdocDocxData(editor); + const editor = { + options: { ydoc, user: { id: 'user-1' } }, + exportDocx: vi.fn().mockResolvedValue({ + 'word/document.xml': '', + 'word/styles.xml': '', + }), + }; - expect(docxSource.toArray).toHaveBeenCalled(); - expect(metas.set).toHaveBeenCalledWith('docx', [ - { name: 'word/document.xml', content: '' }, - { name: 'word/styles.xml', content: '' }, - ]); - }); + await updateYdocDocxData(editor); - it('normalizes docx payloads when meta map stores an iterable collection', async () => { - const docxSet = new Set([ - { name: 'word/document.xml', content: '' }, - { name: 'word/numbering.xml', content: '' }, - ]); - const ydoc = createYDocStub({ docxValue: docxSet }); - const metas = ydoc._maps.metas; + const docXmlCalls = docxFiles.set.mock.calls.filter(([key]) => key === 'word/document.xml'); + expect(docXmlCalls).toHaveLength(0); + expect(docxFiles.set).toHaveBeenCalledWith('word/styles.xml', ''); + }); - const editor = { - options: { ydoc, user: { id: 'user-3' }, content: [] }, - exportDocx: vi.fn().mockResolvedValue({ 'word/document.xml': '' }), - }; + it('sets docxReady flag', async () => { + const ydoc = createYDocStub({ hasDocx: false }); + const metas = ydoc._maps.metas; - await updateYdocDocxData(editor); + const editor = { + options: { ydoc, user: { id: 'user-1' }, content: [{ name: 'word/styles.xml', content: '' }] }, + exportDocx: vi.fn().mockResolvedValue({ 'word/styles.xml': '' }), + }; - expect(metas.set).toHaveBeenCalledWith('docx', [ - { name: 'word/numbering.xml', content: '' }, - { name: 'word/document.xml', content: '' }, - ]); - }); + await updateYdocDocxData(editor); - it('falls back to editor options content when no docx entry exists in the meta map', async () => { - const initialContent = [ - { name: 'word/document.xml', content: '' }, - { name: 'word/footnotes.xml', content: '' }, - ]; - const ydoc = createYDocStub({ hasDocx: false }); - const metas = ydoc._maps.metas; + expect(metas.set).toHaveBeenCalledWith('docxReady', true); + }); - const editor = { - options: { ydoc, user: { id: 'user-4' }, content: initialContent }, - exportDocx: vi.fn().mockResolvedValue({ 'word/document.xml': '' }), - }; + it('does not overwrite docxReady if already set', async () => { + const ydoc = createYDocStub({ hasDocx: false }); + const metas = ydoc._maps.metas; + metas.store.set('docxReady', true); - await updateYdocDocxData(editor); + const docxFiles = ydoc._maps.docxFiles; + docxFiles.store.set('word/styles.xml', ''); - expect(metas.set).toHaveBeenCalledWith('docx', [ - { name: 'word/footnotes.xml', content: '' }, - { name: 'word/document.xml', content: '' }, - ]); - const originalDocEntry = initialContent.find((entry) => entry.name === 'word/document.xml'); - expect(originalDocEntry.content).toBe(''); - }); + const editor = { + options: { ydoc, user: { id: 'user-1' } }, + exportDocx: vi.fn().mockResolvedValue({ 'word/styles.xml': '' }), + }; - it('prefers the explicit ydoc argument over editor options', async () => { - const optionsYdoc = createYDocStub(); - const explicitYdoc = createYDocStub(); - explicitYdoc._maps.metas.store.set('docx', [{ name: 'word/document.xml', content: '' }]); + await updateYdocDocxData(editor); - const editor = { - options: { ydoc: optionsYdoc, user: { id: 'user-5' } }, - exportDocx: vi.fn().mockResolvedValue({ 'word/document.xml': '' }), - }; + const docxReadyCalls = metas.set.mock.calls.filter(([key]) => key === 'docxReady'); + expect(docxReadyCalls).toHaveLength(0); + }); - await updateYdocDocxData(editor, explicitYdoc); + it('seeds from editor.options.content when nothing stored', async () => { + const ydoc = createYDocStub({ hasDocx: false }); + const docxFiles = ydoc._maps.docxFiles; - expect(explicitYdoc._maps.metas.set).toHaveBeenCalledWith('docx', [ - { name: 'word/document.xml', content: '' }, - ]); - expect(optionsYdoc._maps.metas.set).not.toHaveBeenCalled(); - }); + const editor = { + options: { + ydoc, + user: { id: 'user-1' }, + content: [ + { name: 'word/styles.xml', content: '' }, + { name: 'word/document.xml', content: '' }, + ], + }, + exportDocx: vi.fn().mockResolvedValue({ 'word/styles.xml': '' }), + }; - it('skips transaction when docx content has not changed', async () => { - const existingDocx = [ - { name: 'word/document.xml', content: '' }, - { name: 'word/styles.xml', content: '' }, - ]; - const ydoc = createYDocStub({ docxValue: existingDocx }); + await updateYdocDocxData(editor); - const editor = { - options: { ydoc, user: { id: 'user-1' } }, - exportDocx: vi.fn().mockResolvedValue({ - 'word/document.xml': '', - 'word/styles.xml': '', - }), - }; + // Seeded content triggers migration (writes all syncable files to docxFilesMap) + expect(docxFiles.set).toHaveBeenCalledWith('word/styles.xml', ''); + // word/document.xml is CRDT-synced, so it should NOT be in docxFilesMap + const docXmlCalls = docxFiles.set.mock.calls.filter(([key]) => key === 'word/document.xml'); + expect(docXmlCalls).toHaveLength(0); + }); - await updateYdocDocxData(editor); + it('prefers the explicit ydoc argument over editor options', async () => { + const optionsYdoc = createYDocStub({ hasDocx: false }); + const explicitYdoc = createYDocStub({ hasDocx: false }); + const explicitDocxFiles = explicitYdoc._maps.docxFiles; + explicitDocxFiles.store.set('word/styles.xml', ''); + + const editor = { + options: { ydoc: optionsYdoc, user: { id: 'user-5' } }, + exportDocx: vi.fn().mockResolvedValue({ 'word/styles.xml': '' }), + }; + + await updateYdocDocxData(editor, explicitYdoc); - expect(editor.exportDocx).toHaveBeenCalledWith({ getUpdatedDocs: true }); - expect(ydoc.transact).not.toHaveBeenCalled(); + expect(explicitDocxFiles.set).toHaveBeenCalledWith('word/styles.xml', ''); + expect(optionsYdoc._maps.docxFiles.set).not.toHaveBeenCalled(); + }); }); - it('updates only changed files and triggers transaction', async () => { - const existingDocx = [ - { name: 'word/document.xml', content: '' }, - { name: 'word/styles.xml', content: '' }, - ]; - const ydoc = createYDocStub({ docxValue: existingDocx }); - const metas = ydoc._maps.metas; + describe('legacy migration', () => { + it('copies all legacy files to docxFilesMap', async () => { + const ydoc = createYDocStub({ + docxValue: [ + { name: 'word/document.xml', content: '' }, + { name: 'word/styles.xml', content: '' }, + { name: 'word/theme/theme1.xml', content: '' }, + ], + }); + const docxFiles = ydoc._maps.docxFiles; + + const editor = { + options: { ydoc, user: { id: 'user-1' } }, + exportDocx: vi.fn().mockResolvedValue({ 'word/styles.xml': '' }), + }; + + await updateYdocDocxData(editor); + + // All syncable files should be migrated + expect(docxFiles.set).toHaveBeenCalledWith('word/styles.xml', ''); + expect(docxFiles.set).toHaveBeenCalledWith('word/theme/theme1.xml', ''); + }); + it('skips CRDT-synced files during migration', async () => { + const ydoc = createYDocStub({ + docxValue: [ + { name: 'word/document.xml', content: '' }, + { name: 'word/styles.xml', content: '' }, + ], + }); + const docxFiles = ydoc._maps.docxFiles; + + const editor = { + options: { ydoc, user: { id: 'user-1' } }, + exportDocx: vi.fn().mockResolvedValue({ 'word/styles.xml': '' }), + }; + + await updateYdocDocxData(editor); + + const docXmlCalls = docxFiles.set.mock.calls.filter(([key]) => key === 'word/document.xml'); + expect(docXmlCalls).toHaveLength(0); + }); + + it('deletes legacy meta.docx after migration', async () => { + const ydoc = createYDocStub({ + docxValue: [{ name: 'word/styles.xml', content: '' }], + }); + const metas = ydoc._maps.metas; + + const editor = { + options: { ydoc, user: { id: 'user-1' } }, + exportDocx: vi.fn().mockResolvedValue({ 'word/styles.xml': '' }), + }; + + await updateYdocDocxData(editor); + + expect(metas.delete).toHaveBeenCalledWith('docx'); + }); + + it('handles Y.Array-like values via toArray', async () => { + const docxSource = { + toArray: vi.fn(() => [{ name: 'word/styles.xml', content: '' }]), + }; + const ydoc = createYDocStub({ docxValue: docxSource }); + const docxFiles = ydoc._maps.docxFiles; + + const editor = { + options: { ydoc, user: { id: 'user-2' }, content: [] }, + exportDocx: vi.fn().mockResolvedValue({ 'word/styles.xml': '' }), + }; + + await updateYdocDocxData(editor); + + expect(docxSource.toArray).toHaveBeenCalled(); + expect(docxFiles.set).toHaveBeenCalledWith('word/styles.xml', ''); + }); + + it('handles iterable collections', async () => { + const docxSet = new Set([ + { name: 'word/styles.xml', content: '' }, + { name: 'word/numbering.xml', content: '' }, + ]); + const ydoc = createYDocStub({ docxValue: docxSet }); + const docxFiles = ydoc._maps.docxFiles; + + const editor = { + options: { ydoc, user: { id: 'user-3' }, content: [] }, + exportDocx: vi.fn().mockResolvedValue({ 'word/styles.xml': '' }), + }; + + await updateYdocDocxData(editor); + + // Both files should be migrated to docxFilesMap + expect(docxFiles.set).toHaveBeenCalledWith('word/styles.xml', ''); + expect(docxFiles.set).toHaveBeenCalledWith('word/numbering.xml', ''); + }); + + it('skips migration when already in new format', async () => { + const ydoc = createYDocStub({ hasDocx: false }); + const docxFiles = ydoc._maps.docxFiles; + const metas = ydoc._maps.metas; + // Pre-populate docxFiles to indicate new format + docxFiles.store.set('word/styles.xml', ''); + + const editor = { + options: { ydoc, user: { id: 'user-1' } }, + exportDocx: vi.fn().mockResolvedValue({ 'word/styles.xml': '' }), + }; + + await updateYdocDocxData(editor); + + // meta.docx should not be deleted (it doesn't exist anyway) + expect(metas.delete).not.toHaveBeenCalled(); + }); + }); + + it('returns early when neither explicit ydoc nor editor.options.ydoc exist', async () => { const editor = { - options: { ydoc, user: { id: 'user-1' } }, - exportDocx: vi.fn().mockResolvedValue({ - 'word/document.xml': '', - 'word/styles.xml': '', - }), + options: { ydoc: null, user: { id: 'user-1' }, content: [] }, + exportDocx: vi.fn(), }; await updateYdocDocxData(editor); - expect(ydoc.transact).toHaveBeenCalled(); - expect(metas.set).toHaveBeenCalledWith( - 'docx', - expect.arrayContaining([ - { name: 'word/styles.xml', content: '' }, - { name: 'word/document.xml', content: '' }, - ]), + expect(editor.exportDocx).not.toHaveBeenCalled(); + }); +}); + +describe('extractBodySectPr', () => { + it('extracts sectPr from a typical document.xml', () => { + const xml = + '' + + '' + + '' + + ''; + const result = extractBodySectPr(xml); + expect(result).toBe( + '' + + '', ); }); - it('triggers transaction when new file is added', async () => { - const existingDocx = [{ name: 'word/document.xml', content: '' }]; - const ydoc = createYDocStub({ docxValue: existingDocx }); + it('returns null for null/undefined input', () => { + expect(extractBodySectPr(null)).toBeNull(); + expect(extractBodySectPr(undefined)).toBeNull(); + }); - const editor = { - options: { ydoc, user: { id: 'user-1' } }, - exportDocx: vi.fn().mockResolvedValue({ - 'word/document.xml': '', - 'word/numbering.xml': '', - }), - }; + it('returns null when no sectPr exists', () => { + const xml = ''; + expect(extractBodySectPr(xml)).toBeNull(); + }); - await updateYdocDocxData(editor); + it('extracts the last sectPr when multiple exist', () => { + const xml = + '' + + '' + + '' + + ''; + expect(extractBodySectPr(xml)).toBe(''); + }); +}); - expect(ydoc.transact).toHaveBeenCalled(); +describe('buildDocumentXmlPlaceholder', () => { + it('returns minimal placeholder when no sectPr provided', () => { + expect(buildDocumentXmlPlaceholder(null)).toBe(PLACEHOLDER_DOCUMENT_XML); + expect(buildDocumentXmlPlaceholder(undefined)).toBe(PLACEHOLDER_DOCUMENT_XML); }); - it('skips transaction when multiple files all remain unchanged', async () => { - const existingDocx = [ - { name: 'word/document.xml', content: '' }, - { name: 'word/styles.xml', content: '' }, - { name: 'word/numbering.xml', content: '' }, - ]; - const ydoc = createYDocStub({ docxValue: existingDocx }); + it('includes sectPr in the placeholder', () => { + const sectPr = ''; + const result = buildDocumentXmlPlaceholder(sectPr); + expect(result).toContain(sectPr); + expect(result).toContain(''); + expect(result).toContain(''); + }); + it('places sectPr after the paragraph and before body close', () => { + const sectPr = ''; + const result = buildDocumentXmlPlaceholder(sectPr); + const bodyContent = result.match(/(.*)<\/w:body>/)?.[1]; + expect(bodyContent).toMatch(/.*<\/w:p>.*/); + }); +}); + +describe('initializeMetaMap bodySectPr', () => { + it('stores bodySectPr in meta map when document.xml has sectPr', () => { + const documentXml = + '' + + '' + + ''; + const content = [ + { name: 'word/document.xml', content: documentXml }, + { name: 'word/styles.xml', content: '' }, + ]; + const ydoc = createYDocStub(); const editor = { - options: { ydoc, user: { id: 'user-1' } }, - exportDocx: vi.fn().mockResolvedValue({ - 'word/document.xml': '', - 'word/styles.xml': '', - 'word/numbering.xml': '', - }), + options: { fonts: {}, content, mediaFiles: {}, user: {} }, }; - await updateYdocDocxData(editor); + initializeMetaMap(ydoc, editor); - expect(ydoc.transact).not.toHaveBeenCalled(); + const metaMap = ydoc._maps.metas; + expect(metaMap.get('bodySectPr')).toBe(''); }); - it('initializes docx metadata even when exported content matches initial content', async () => { - const initialContent = [ - { name: 'word/document.xml', content: '' }, - { name: 'word/styles.xml', content: '' }, - ]; - // No docx entry exists in meta map (hasDocx: false) - const ydoc = createYDocStub({ hasDocx: false }); - const metas = ydoc._maps.metas; - + it('does not store bodySectPr when document.xml has no sectPr', () => { + const content = [{ name: 'word/document.xml', content: '' }]; + const ydoc = createYDocStub(); const editor = { - options: { ydoc, user: { id: 'user-1' }, content: initialContent }, - // Export returns identical content to initial - exportDocx: vi.fn().mockResolvedValue({ - 'word/document.xml': '', - 'word/styles.xml': '', - }), + options: { fonts: {}, content, mediaFiles: {}, user: {} }, }; - await updateYdocDocxData(editor); + initializeMetaMap(ydoc, editor); - // Transaction should still happen to initialize the docx metadata for collaborators - expect(ydoc.transact).toHaveBeenCalled(); - expect(metas.set).toHaveBeenCalledWith('docx', initialContent); + const metaMap = ydoc._maps.metas; + expect(metaMap.get('bodySectPr')).toBeUndefined(); }); - it('initializes docx metadata for new documents with no changes', async () => { - const initialContent = [{ name: 'word/document.xml', content: '' }]; - const ydoc = createYDocStub({ hasDocx: false }); - const metas = ydoc._maps.metas; - + it('does not store bodySectPr when content is not an array', () => { + const ydoc = createYDocStub(); const editor = { - options: { ydoc, user: { id: 'new-user' }, content: initialContent }, - exportDocx: vi.fn().mockResolvedValue({ - 'word/document.xml': '', - }), + options: { fonts: {}, content: 'not-an-array', mediaFiles: {}, user: {} }, }; - await updateYdocDocxData(editor); + initializeMetaMap(ydoc, editor); - // Even with no content changes, the metadata must be persisted for collaborators - expect(ydoc.transact).toHaveBeenCalledWith(expect.any(Function), { - event: 'docx-update', - user: editor.options.user, - }); - expect(metas.set).toHaveBeenCalledWith('docx', initialContent); + const metaMap = ydoc._maps.metas; + expect(metaMap.get('bodySectPr')).toBeUndefined(); }); }); @@ -455,7 +580,10 @@ describe('collaboration extension', () => { const editor = { options: { isNewFile: true, - content: { 'word/document.xml': '' }, + content: [ + { name: 'word/styles.xml', content: '' }, + { name: 'word/document.xml', content: '' }, + ], fonts: { font1: 'binary' }, mediaFiles: { 'word/media/img.png': new Uint8Array([1]) }, }, @@ -467,14 +595,24 @@ describe('collaboration extension', () => { const { onFirstRender } = YProsemirror.ySyncPlugin.mock.calls[0][1]; onFirstRender(); - expect(ydoc._maps.metas.set).toHaveBeenCalledWith('docx', editor.options.content); + + // initializeMetaMap writes each file to docxFilesMap (skipping CRDT-synced files) + const docxFiles = ydoc._maps.docxFiles; + expect(docxFiles.set).toHaveBeenCalledWith('word/styles.xml', ''); + // word/document.xml is CRDT-synced, should not be in docxFilesMap + const docXmlCalls = docxFiles.set.mock.calls.filter(([key]) => key === 'word/document.xml'); + expect(docXmlCalls).toHaveLength(0); + expect(ydoc._maps.metas.set).toHaveBeenCalledWith('docxReady', true); }); it('initializes meta map with content, fonts, and media', () => { const ydoc = createYDocStub(); const editor = { options: { - content: { 'word/document.xml': '' }, + content: [ + { name: 'word/styles.xml', content: '' }, + { name: 'word/document.xml', content: '' }, + ], fonts: { 'font1.ttf': new Uint8Array([1]) }, mediaFiles: { 'word/media/img.png': new Uint8Array([5]) }, }, @@ -483,8 +621,13 @@ describe('collaboration extension', () => { initializeMetaMap(ydoc, editor); const metaStore = ydoc._maps.metas.store; - expect(metaStore.get('docx')).toEqual(editor.options.content); + const docxFiles = ydoc._maps.docxFiles; expect(metaStore.get('fonts')).toEqual(editor.options.fonts); + expect(metaStore.get('docxReady')).toBe(true); + expect(docxFiles.set).toHaveBeenCalledWith('word/styles.xml', ''); + // word/document.xml is CRDT-synced + const docXmlCalls = docxFiles.set.mock.calls.filter(([key]) => key === 'word/document.xml'); + expect(docXmlCalls).toHaveLength(0); expect(ydoc._maps.media.set).toHaveBeenCalledWith('word/media/img.png', new Uint8Array([5])); }); @@ -495,12 +638,12 @@ describe('collaboration extension', () => { const editor = { state: { doc }, options: { - content: [{ name: 'word/document.xml', content: '' }], + content: [{ name: 'word/styles.xml', content: '' }], fonts: {}, mediaFiles: {}, user: { id: 'user' }, }, - exportDocx: vi.fn().mockResolvedValue({ 'word/document.xml': '' }), + exportDocx: vi.fn().mockResolvedValue({ 'word/styles.xml': '' }), }; const data = await generateCollaborationData(editor); @@ -990,7 +1133,10 @@ describe('collaboration extension', () => { ydoc, options: { isNewFile: true, - content: { 'word/document.xml': '' }, + content: [ + { name: 'word/styles.xml', content: '' }, + { name: 'word/document.xml', content: '' }, + ], fonts: { 'font1.ttf': new Uint8Array([1]) }, mediaFiles: { 'word/media/img.png': new Uint8Array([5]) }, }, @@ -998,10 +1144,14 @@ describe('collaboration extension', () => { Collaboration.config.addPmPlugins.call(context); Collaboration.config.onCreate.call(context); - // initializeMetaMap should have been called, writing to the meta map const metaStore = ydoc._maps.metas.store; - expect(metaStore.get('docx')).toEqual({ 'word/document.xml': '' }); + const docxFiles = ydoc._maps.docxFiles; expect(metaStore.get('fonts')).toEqual({ 'font1.ttf': new Uint8Array([1]) }); + expect(metaStore.get('docxReady')).toBe(true); + expect(docxFiles.set).toHaveBeenCalledWith('word/styles.xml', ''); + // word/document.xml is CRDT-synced + const docXmlCalls = docxFiles.set.mock.calls.filter(([key]) => key === 'word/document.xml'); + expect(docXmlCalls).toHaveLength(0); expect(ydoc._maps.media.set).toHaveBeenCalledWith('word/media/img.png', new Uint8Array([5])); }); }); diff --git a/packages/superdoc/package.json b/packages/superdoc/package.json index 25a70390d..9e32b53ef 100644 --- a/packages/superdoc/package.json +++ b/packages/superdoc/package.json @@ -60,8 +60,9 @@ "module": "./dist/superdoc.es.js", "scripts": { "dev": "vite", - "dev:collab": "concurrently -k -n VITE,COLLAB -c cyan,green \"vite\" \"node src/dev/collab-server.js\"", - "collab-server": "node src/dev/collab-server.js", + "dev:collab": "pnpm run dev:collab:hocuspocus", + "dev:collab:hocuspocus": "concurrently -k -n VITE,COLLAB -c cyan,green \"vite\" \"node src/dev/hocuspocus-server.js\"", + "dev:collab:liveblocks": "concurrently -k -n VITE,LIVEBLOCKS -c cyan,magenta \"vite\" \"node src/dev/liveblocks-server.js\"", "build": "vite build && pnpm run build:umd", "build:dev": "SUPERDOC_SKIP_DTS=1 vite build", "postbuild": "node ./scripts/ensure-types.cjs", @@ -95,6 +96,8 @@ "devDependencies": { "@hocuspocus/provider": "catalog:", "@hocuspocus/server": "catalog:", + "@liveblocks/client": "^3.11.0", + "@liveblocks/yjs": "^3.11.0", "@superdoc/common": "workspace:*", "@superdoc/super-editor": "workspace:*", "concurrently": "catalog:", diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue index a4943ae46..667acba31 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -17,6 +17,8 @@ import { getWorkerSrcFromCDN } from '../../components/PdfViewer/pdf/pdf-adapter. import SidebarSearch from './sidebar/SidebarSearch.vue'; import SidebarFieldAnnotations from './sidebar/SidebarFieldAnnotations.vue'; import { HocuspocusProvider } from '@hocuspocus/provider'; +import { createClient } from '@liveblocks/client'; +import { LiveblocksYjsProvider } from '@liveblocks/yjs'; import * as Y from 'yjs'; // note: @@ -44,6 +46,7 @@ const userRole = urlParams.get('role') || 'editor'; const useLayoutEngine = ref(urlParams.get('layout') !== '0'); const useWebLayout = ref(urlParams.get('view') === 'web'); const useCollaboration = urlParams.get('collab') === '1'; +const collabProvider = urlParams.get('provider') || 'hocuspocus'; // 'hocuspocus' or 'liveblocks' // Collaboration state const ydocRef = shallowRef(null); @@ -97,6 +100,42 @@ const commentPermissionResolver = ({ permission, comment, defaultDecision, curre const handleNewFile = async (file) => { uploadedFileName.value = file?.name || ''; + + // In collaboration mode, use replaceFile to broadcast changes via Yjs + if (useCollaboration && superdoc.value) { + const doc = superdoc.value.superdocStore?.documents?.[0]; + const editor = doc?.getEditor?.(); + if (editor) { + if (collabProvider === 'liveblocks' && providerRef.value) { + // Server-side init for Liveblocks: pause WS → replace locally → push via REST → unpause. + // The WS "too large" warning on unpause is harmless — data is already on the server. + providerRef.value.pause(); + await editor.replaceFile(file); + + const update = Y.encodeStateAsUpdate(ydocRef.value); + const roomId = import.meta.env.VITE_LIVEBLOCKS_ROOM_ID || 'superdoc-dev-room'; + try { + const resp = await fetch('http://localhost:3051/api/init-ydoc', { + method: 'PUT', + headers: { 'Content-Type': 'application/octet-stream', 'X-Room-Id': roomId }, + body: update, + }); + if (!resp.ok) { + console.error('[superdoc-dev] Server-side init failed:', resp.status, await resp.text()); + } + } catch (err) { + console.error('[superdoc-dev] Server-side init error:', err); + } + + providerRef.value.unpause(); + return; + } + + await editor.replaceFile(file); + return; + } + } + // Generate a file url const url = URL.createObjectURL(file); @@ -511,33 +550,73 @@ const toggleCommentsPanel = () => { } }; +// Liveblocks cleanup ref (for leave function) +let liveblocksLeave = null; + onMounted(async () => { // Initialize collaboration if enabled via ?collab=1 if (useCollaboration) { const ydoc = new Y.Doc(); - const provider = new HocuspocusProvider({ - url: 'ws://localhost:3050', - name: 'superdoc-dev-room', - document: ydoc, - }); - - ydocRef.value = ydoc; - providerRef.value = provider; - - // Wait for sync before loading document - await new Promise((resolve) => { - provider.on('synced', () => { - collabReady.value = true; - resolve(); + + if (collabProvider === 'liveblocks') { + // Liveblocks provider — requires VITE_LIVEBLOCKS_PUBLIC_KEY in .env + const publicKey = import.meta.env.VITE_LIVEBLOCKS_PUBLIC_KEY; + const roomId = import.meta.env.VITE_LIVEBLOCKS_ROOM_ID || 'superdoc-dev-room'; + + if (!publicKey) { + console.error('[collab] Missing VITE_LIVEBLOCKS_PUBLIC_KEY in .env'); + return; + } + + const client = createClient({ + publicApiKey: publicKey, + }); + const { room, leave } = client.enterRoom(roomId); + liveblocksLeave = leave; + const provider = new LiveblocksYjsProvider(room, ydoc, { + useV2Encoding_experimental: true, }); - // Fallback timeout in case sync doesn't fire - setTimeout(() => { - collabReady.value = true; - resolve(); - }, 3000); - }); - - console.log('[collab] Provider synced, initializing SuperDoc'); + + ydocRef.value = ydoc; + providerRef.value = provider; + + await new Promise((resolve) => { + provider.on('sync', (synced) => { + if (!synced) return; + collabReady.value = true; + resolve(); + }); + setTimeout(() => { + collabReady.value = true; + resolve(); + }, 5000); + }); + + console.log('[collab] Liveblocks provider synced'); + } else { + // Hocuspocus provider (default) + const provider = new HocuspocusProvider({ + url: 'ws://localhost:3050', + name: 'superdoc-dev-room', + document: ydoc, + }); + + ydocRef.value = ydoc; + providerRef.value = provider; + + await new Promise((resolve) => { + provider.on('synced', () => { + collabReady.value = true; + resolve(); + }); + setTimeout(() => { + collabReady.value = true; + resolve(); + }, 3000); + }); + + console.log('[collab] Hocuspocus provider synced'); + } } // Initialize SuperDoc - it will automatically create a blank document @@ -555,6 +634,10 @@ onBeforeUnmount(() => { providerRef.value.destroy(); providerRef.value = null; } + if (liveblocksLeave) { + liveblocksLeave(); + liveblocksLeave = null; + } ydocRef.value = null; }); @@ -572,6 +655,24 @@ const toggleViewLayout = () => { window.location.href = url.toString(); }; +const toggleCollaboration = () => { + const url = new URL(window.location.href); + if (useCollaboration) { + url.searchParams.delete('collab'); + url.searchParams.delete('provider'); + } else { + url.searchParams.set('collab', '1'); + url.searchParams.set('provider', collabProvider); + } + window.location.href = url.toString(); +}; + +const switchProvider = (value) => { + const url = new URL(window.location.href); + url.searchParams.set('provider', value); + window.location.href = url.toString(); +}; + const showExportMenu = ref(false); const closeExportMenu = () => { showExportMenu.value = false; @@ -652,7 +753,7 @@ if (scrollTestMode.value) { Layout Engine: {{ useLayoutEngine && !useWebLayout ? 'ON' : 'OFF' }} Web Layout: ON Scroll Test: ON - Collab: ON + Collab: {{ collabProvider }}

SuperDoc Dev

@@ -767,6 +868,18 @@ if (scrollTestMode.value) { + +
@@ -1182,6 +1295,28 @@ if (scrollTestMode.value) { background: rgba(148, 163, 184, 0.28); } +.dev-app__header-export-btn.is-active { + background: rgba(34, 197, 94, 0.2); + border-color: rgba(34, 197, 94, 0.4); + color: #86efac; +} + +.dev-app__header-export-btn--sm { + font-size: 12px; + padding: 6px 10px; +} + +.dev-app__provider-select { + background: rgba(148, 163, 184, 0.12); + color: #e2e8f0; + border: 1px solid rgba(148, 163, 184, 0.2); + padding: 6px 10px; + border-radius: 10px; + font-weight: 600; + cursor: pointer; + font-size: 12px; +} + .dev-app__dropdown { position: relative; display: inline-flex; diff --git a/packages/superdoc/src/dev/collab-server.js b/packages/superdoc/src/dev/hocuspocus-server.js similarity index 100% rename from packages/superdoc/src/dev/collab-server.js rename to packages/superdoc/src/dev/hocuspocus-server.js diff --git a/packages/superdoc/src/dev/liveblocks-server.js b/packages/superdoc/src/dev/liveblocks-server.js new file mode 100644 index 000000000..78c0a67bc --- /dev/null +++ b/packages/superdoc/src/dev/liveblocks-server.js @@ -0,0 +1,81 @@ +import { createServer } from 'node:http'; +import { config } from 'dotenv'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +config({ path: resolve(__dirname, '../../.env') }); + +const SECRET_KEY = process.env.LIVEBLOCKS_SECRET_KEY; +const PORT = 3051; + +if (!SECRET_KEY) { + console.error('Missing LIVEBLOCKS_SECRET_KEY in .env'); + process.exit(1); +} + +const server = createServer(async (req, res) => { + // CORS + res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Methods', 'PUT, OPTIONS'); + res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Room-Id'); + + if (req.method === 'OPTIONS') { + res.writeHead(204); + res.end(); + return; + } + + if (req.method === 'PUT' && req.url === '/api/init-ydoc') { + const roomId = req.headers['x-room-id']; + if (!roomId) { + res.writeHead(400, { 'Content-Type': 'text/plain' }); + res.end('Missing X-Room-Id header'); + return; + } + + // Read binary body + const chunks = []; + for await (const chunk of req) { + chunks.push(chunk); + } + const body = Buffer.concat(chunks); + + console.log(`[liveblocks-proxy] Pushing ${(body.length / 1024).toFixed(1)} KB to room "${roomId}"`); + + try { + const resp = await fetch(`https://api.liveblocks.io/v2/rooms/${roomId}/ydoc`, { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${SECRET_KEY}`, + 'Content-Type': 'application/octet-stream', + }, + body, + }); + + if (!resp.ok) { + const text = await resp.text(); + console.error(`[liveblocks-proxy] Liveblocks API error ${resp.status}:`, text); + res.writeHead(resp.status, { 'Content-Type': 'text/plain' }); + res.end(text); + return; + } + + console.log(`[liveblocks-proxy] Success — pushed to Liveblocks`); + res.writeHead(200); + res.end('OK'); + } catch (err) { + console.error('[liveblocks-proxy] Error:', err); + res.writeHead(500, { 'Content-Type': 'text/plain' }); + res.end(err.message); + } + return; + } + + res.writeHead(404); + res.end('Not found'); +}); + +server.listen(PORT, () => { + console.log(`[liveblocks-proxy] Listening on http://localhost:${PORT}`); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3423983ad..a5b69f03a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1175,6 +1175,12 @@ importers: '@hocuspocus/server': specifier: 'catalog:' version: 2.15.3(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19) + '@liveblocks/client': + specifier: ^3.11.0 + version: 3.13.5(@types/json-schema@7.0.15) + '@liveblocks/yjs': + specifier: ^3.11.0 + version: 3.13.5(@types/json-schema@7.0.15)(yjs@13.6.19) '@superdoc/common': specifier: workspace:* version: link:../../shared/common @@ -2807,6 +2813,19 @@ packages: resolution: {integrity: sha512-KWsNUDc8JkIGn4lqvy6EKXoMDY1QedBOVQWO8MhrbUFR8QuYtthVL0YknK9eCcW5j1sDQCvRuyesvDVgpwxEFw==} engines: {node: '>=12.x', yarn: 1.x} + '@liveblocks/client@3.13.5': + resolution: {integrity: sha512-Zodx6Ny1nk1w2VolWY3SnV6KLsUOGiPF6kbY9PMJPVjJRzL5u66W8l0mZQWHkWaTiF8JN3fehQ+ZoBv+AzqIAw==} + + '@liveblocks/core@3.13.5': + resolution: {integrity: sha512-horwHT8T6I7sqkDAtr+uKsq0cTjx4hChgk2gP0xHqAYfeasjTZUCvQOuSwUGpO/5YrwMnSP/jd2fbo2tRuUqUA==} + peerDependencies: + '@types/json-schema': ^7 + + '@liveblocks/yjs@3.13.5': + resolution: {integrity: sha512-EFguOGLbR2CB9sT+fUdiBhgSkF6cQZ9MX+gbVnEnAt4RtcRgEi7CL1HxaeyFmjOO7UbntROmswPKNdY8MVpR8A==} + peerDependencies: + yjs: ^13.6.1 + '@mdx-js/mdx@3.1.1': resolution: {integrity: sha512-f6ZO2ifpwAQIpzGWaBQT2TXxPv6z3RBzQKpVftEWN78Vl/YweF1uwussDx8ECAXVtr3Rs89fKyG9YlzUs9DyGQ==} @@ -2892,6 +2911,10 @@ packages: '@napi-rs/wasm-runtime@1.1.1': resolution: {integrity: sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==} + '@noble/hashes@1.8.0': + resolution: {integrity: sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A==} + engines: {node: ^14.21.3 || >=16} + '@nodelib/fs.scandir@2.1.5': resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} engines: {node: '>= 8'} @@ -7258,6 +7281,9 @@ packages: jpeg-js@0.4.4: resolution: {integrity: sha512-WZzeDOEtTOBK4Mdsar0IqEU5sMr3vSV2RqkAIzUEV2BHnUfKGyswWFPFwK5EeDo93K3FohSHbLAjj0s1Wzd+dg==} + js-base64@3.7.8: + resolution: {integrity: sha512-hNngCeKxIUQiEUN3GPJOkz4wF/YvdUdbNL9hsBcMQTkKzboD7T/q3OYOuuPZLUE6dBxSGpwhk5mwuDud7JVAow==} + js-beautify@1.15.4: resolution: {integrity: sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA==} engines: {node: '>=14'} @@ -11157,6 +11183,12 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y-indexeddb@9.0.12: + resolution: {integrity: sha512-9oCFRSPPzBK7/w5vOkJBaVCQZKHXB/v6SIT+WYhnJxlEC61juqG0hBrAf+y3gmSMLFLwICNH9nQ53uscuse6Hg==} + engines: {node: '>=16.0.0', npm: '>=8.0.0'} + peerDependencies: + yjs: ^13.0.0 + y-prosemirror@1.3.7: resolution: {integrity: sha512-NpM99WSdD4Fx4if5xOMDpPtU3oAmTSjlzh5U4353ABbRHl1HtAFUx6HlebLZfyFxXN9jzKMDkVbcRjqOZVkYQg==} engines: {node: '>=16.0.0', npm: '>=8.0.0'} @@ -13043,6 +13075,27 @@ snapshots: transitivePeerDependencies: - encoding + '@liveblocks/client@3.13.5(@types/json-schema@7.0.15)': + dependencies: + '@liveblocks/core': 3.13.5(@types/json-schema@7.0.15) + transitivePeerDependencies: + - '@types/json-schema' + + '@liveblocks/core@3.13.5(@types/json-schema@7.0.15)': + dependencies: + '@types/json-schema': 7.0.15 + + '@liveblocks/yjs@3.13.5(@types/json-schema@7.0.15)(yjs@13.6.19)': + dependencies: + '@liveblocks/client': 3.13.5(@types/json-schema@7.0.15) + '@liveblocks/core': 3.13.5(@types/json-schema@7.0.15) + '@noble/hashes': 1.8.0 + js-base64: 3.7.8 + y-indexeddb: 9.0.12(yjs@13.6.19) + yjs: 13.6.19 + transitivePeerDependencies: + - '@types/json-schema' + '@mdx-js/mdx@3.1.1': dependencies: '@types/estree': 1.0.8 @@ -13576,6 +13629,8 @@ snapshots: '@tybys/wasm-util': 0.10.1 optional: true + '@noble/hashes@1.8.0': {} + '@nodelib/fs.scandir@2.1.5': dependencies: '@nodelib/fs.stat': 2.0.5 @@ -19000,6 +19055,8 @@ snapshots: jpeg-js@0.4.4: {} + js-base64@3.7.8: {} + js-beautify@1.15.4: dependencies: config-chain: 1.1.13 @@ -24002,6 +24059,11 @@ snapshots: xtend@4.0.2: {} + y-indexeddb@9.0.12(yjs@13.6.19): + dependencies: + lib0: 0.2.117 + yjs: 13.6.19 + y-prosemirror@1.3.7(prosemirror-model@1.25.4)(prosemirror-state@1.4.4)(prosemirror-view@1.41.5)(y-protocols@1.0.7(yjs@13.6.19))(yjs@13.6.19): dependencies: lib0: 0.2.117