From 028b9db5e65c288d3aff31e8986cdf11a898005c Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sun, 15 Feb 2026 07:58:35 -0300 Subject: [PATCH 1/6] fix(collaboration): per-file Y.Map storage with excludeSyncedContent option --- .../src/components/SuperEditor.vue | 34 +++- .../src/core/types/EditorConfig.ts | 3 + .../collaboration/collaboration-helpers.js | 127 ++++++++---- .../extensions/collaboration/collaboration.js | 32 ++- packages/superdoc/package.json | 2 + packages/superdoc/src/SuperDoc.vue | 1 + .../superdoc/src/composables/use-document.js | 2 + packages/superdoc/src/core/SuperDoc.js | 3 +- .../src/dev/components/SuperdocDev.vue | 182 +++++++++++++++--- pnpm-lock.yaml | 62 ++++++ 10 files changed, 378 insertions(+), 70 deletions(-) diff --git a/packages/super-editor/src/components/SuperEditor.vue b/packages/super-editor/src/components/SuperEditor.vue index d41db8a5a6..9ce88bc109 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 { PLACEHOLDER_DOCUMENT_XML } 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,37 @@ 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 may be excluded from Y.Map when excludeSyncedContent + // is enabled (its content is synced via y-prosemirror XmlFragment instead). + // Provide a minimal placeholder so the converter can initialize its schema. + if (!docx.some((f) => f.name === 'word/document.xml')) { + docx.push({ name: 'word/document.xml', content: PLACEHOLDER_DOCUMENT_XML }); + } + 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 +785,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/types/EditorConfig.ts b/packages/super-editor/src/core/types/EditorConfig.ts index 50bbbb56d3..3d3b0648fe 100644 --- a/packages/super-editor/src/core/types/EditorConfig.ts +++ b/packages/super-editor/src/core/types/EditorConfig.ts @@ -328,6 +328,9 @@ export interface EditorOptions { /** Collaboration provider */ collaborationProvider?: CollaborationProvider | null; + /** Skip syncing files already handled by y-prosemirror XmlFragment (e.g. word/document.xml) to reduce WebSocket message sizes */ + excludeSyncedContent?: boolean; + /** Whether the collaboration provider finished syncing */ collaborationIsReady?: boolean; diff --git a/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js b/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js index 084a56bace..bbe6fa6472 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js @@ -1,6 +1,81 @@ +/** + * Files whose content is already synced via y-prosemirror XmlFragment. + * When `excludeSyncedContent` is enabled, these are skipped in Y.Map storage + * 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. + * When `excludeSyncedContent` is enabled, files already synced via + * y-prosemirror XmlFragment (e.g. word/document.xml) are excluded. + * + * @param {string} fileName + * @param {boolean} excludeSyncedContent + * @returns {boolean} + */ +export const shouldSyncFile = (fileName, excludeSyncedContent) => { + if (excludeSyncedContent && 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,54 +85,30 @@ 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); - } + const existingFiles = readExistingDocxFiles(ydoc); - if (!docx.length && Array.isArray(editor.options.content)) { - docx = [...editor.options.content]; + // Seed from editor content if nothing stored yet + 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; + }); } 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). + const excludeSynced = !!editor.options.excludeSyncedContent; 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, excludeSynced)) 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); diff --git a/packages/super-editor/src/extensions/collaboration/collaboration.js b/packages/super-editor/src/extensions/collaboration/collaboration.js index cd3d79a9c3..df1dc6b86e 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration.js @@ -2,7 +2,11 @@ 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, + shouldSyncFile, +} from '@extensions/collaboration/collaboration-helpers.js'; export const CollaborationPluginKey = new PluginKey('collaboration'); const headlessBindingStateByEditor = new WeakMap(); @@ -112,18 +116,36 @@ 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 excludeSynced = !!editor.options.excludeSyncedContent; + 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, excludeSynced)) { + 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); }); }; -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 +160,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/superdoc/package.json b/packages/superdoc/package.json index 25a70390d8..03c917bdfe 100644 --- a/packages/superdoc/package.json +++ b/packages/superdoc/package.json @@ -95,6 +95,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/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 379f415302..499c1f4fc5 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -499,6 +499,7 @@ const editorOptions = (doc) => { onTransaction: onEditorTransaction, ydoc: doc.ydoc, collaborationProvider: doc.provider || null, + excludeSyncedContent: doc.excludeSyncedContent || false, isNewFile, handleImageUpload: proxy.$superdoc.config.handleImageUpload, externalExtensions: proxy.$superdoc.config.editorExtensions || [], diff --git a/packages/superdoc/src/composables/use-document.js b/packages/superdoc/src/composables/use-document.js index b2c274f319..89f922625e 100644 --- a/packages/superdoc/src/composables/use-document.js +++ b/packages/superdoc/src/composables/use-document.js @@ -24,6 +24,7 @@ export default function useDocument(params, superdocConfig) { const ydoc = shallowRef(params.ydoc); const provider = shallowRef(params.provider); const socket = shallowRef(params.socket); + const excludeSyncedContent = ref(params.excludeSyncedContent || false); const isNewFile = ref(params.isNewFile); // For docx @@ -96,6 +97,7 @@ export default function useDocument(params, superdocConfig) { ydoc, provider, socket, + excludeSyncedContent, isNewFile, // Placement diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index 21ef32cbf9..b8f96f38b7 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -455,7 +455,7 @@ export class SuperDoc extends EventEmitter { this.isCollaborative = true; // Check for external ydoc/provider (provider-agnostic mode) - const { ydoc: externalYdoc, provider: externalProvider } = collaborationModuleConfig; + const { ydoc: externalYdoc, provider: externalProvider, excludeSyncedContent } = collaborationModuleConfig; if (externalYdoc && externalProvider) { // Use external provider - wire up awareness for SuperDoc events @@ -478,6 +478,7 @@ export class SuperDoc extends EventEmitter { this.config.documents.forEach((doc) => { doc.ydoc = externalYdoc; doc.provider = externalProvider; + doc.excludeSyncedContent = excludeSyncedContent || false; doc.role = this.config.role; }); diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue index a4943ae46b..f145d08630 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); @@ -354,6 +393,7 @@ const init = async () => { collaboration: { ydoc: ydocRef.value, provider: providerRef.value, + excludeSyncedContent: collabProvider === 'liveblocks', }, } : {}), @@ -511,33 +551,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 +635,10 @@ onBeforeUnmount(() => { providerRef.value.destroy(); providerRef.value = null; } + if (liveblocksLeave) { + liveblocksLeave(); + liveblocksLeave = null; + } ydocRef.value = null; }); @@ -572,6 +656,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 +754,7 @@ if (scrollTestMode.value) { Layout Engine: {{ useLayoutEngine && !useWebLayout ? 'ON' : 'OFF' }} Web Layout: ON Scroll Test: ON - Collab: ON + Collab: {{ collabProvider }}

SuperDoc Dev

@@ -767,6 +869,18 @@ if (scrollTestMode.value) { + +
@@ -1182,6 +1296,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/pnpm-lock.yaml b/pnpm-lock.yaml index 3423983ad2..a5b69f03a7 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 From c4a7c5122ea916259511be6e0696ee3aafa58de7 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Sun, 15 Feb 2026 21:26:21 -0300 Subject: [PATCH 2/6] refactor(collaboration): make sync optimization automatic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the `excludeSyncedContent` flag from the public API. Files already synced via y-prosemirror XmlFragment (word/document.xml) are now always excluded from Y.Map storage during collaboration — no configuration needed. --- .../super-editor/src/components/SuperEditor.vue | 6 +++--- .../super-editor/src/core/types/EditorConfig.ts | 3 --- .../collaboration/collaboration-helpers.js | 14 ++++++-------- .../src/extensions/collaboration/collaboration.js | 3 +-- packages/superdoc/src/SuperDoc.vue | 1 - packages/superdoc/src/composables/use-document.js | 2 -- packages/superdoc/src/core/SuperDoc.js | 3 +-- .../superdoc/src/dev/components/SuperdocDev.vue | 1 - 8 files changed, 11 insertions(+), 22 deletions(-) diff --git a/packages/super-editor/src/components/SuperEditor.vue b/packages/super-editor/src/components/SuperEditor.vue index 9ce88bc109..1bd8740f89 100644 --- a/packages/super-editor/src/components/SuperEditor.vue +++ b/packages/super-editor/src/components/SuperEditor.vue @@ -695,9 +695,9 @@ const pollForMetaMapData = (ydoc, retries = 10, interval = 500) => { docx.push({ name, content }); }); - // word/document.xml may be excluded from Y.Map when excludeSyncedContent - // is enabled (its content is synced via y-prosemirror XmlFragment instead). - // Provide a minimal placeholder so the converter can initialize its schema. + // word/document.xml is not stored in Y.Map — its content is synced via + // y-prosemirror XmlFragment instead. Provide a minimal placeholder so the + // converter can initialize its schema. if (!docx.some((f) => f.name === 'word/document.xml')) { docx.push({ name: 'word/document.xml', content: PLACEHOLDER_DOCUMENT_XML }); } diff --git a/packages/super-editor/src/core/types/EditorConfig.ts b/packages/super-editor/src/core/types/EditorConfig.ts index 3d3b0648fe..50bbbb56d3 100644 --- a/packages/super-editor/src/core/types/EditorConfig.ts +++ b/packages/super-editor/src/core/types/EditorConfig.ts @@ -328,9 +328,6 @@ export interface EditorOptions { /** Collaboration provider */ collaborationProvider?: CollaborationProvider | null; - /** Skip syncing files already handled by y-prosemirror XmlFragment (e.g. word/document.xml) to reduce WebSocket message sizes */ - excludeSyncedContent?: boolean; - /** Whether the collaboration provider finished syncing */ collaborationIsReady?: boolean; diff --git a/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js b/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js index bbe6fa6472..a6b890cbd4 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js @@ -1,6 +1,6 @@ /** * Files whose content is already synced via y-prosemirror XmlFragment. - * When `excludeSyncedContent` is enabled, these are skipped in Y.Map storage + * 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']); @@ -18,15 +18,14 @@ export const PLACEHOLDER_DOCUMENT_XML = /** * Returns true if a DOCX file should be synced to the Y.Map. - * When `excludeSyncedContent` is enabled, files already synced via - * y-prosemirror XmlFragment (e.g. word/document.xml) are excluded. + * Files already synced via y-prosemirror XmlFragment (e.g. word/document.xml) + * are automatically excluded during collaboration. * * @param {string} fileName - * @param {boolean} excludeSyncedContent * @returns {boolean} */ -export const shouldSyncFile = (fileName, excludeSyncedContent) => { - if (excludeSyncedContent && CRDT_SYNCED_FILES.has(fileName)) return false; +export const shouldSyncFile = (fileName) => { + if (CRDT_SYNCED_FILES.has(fileName)) return false; return true; }; @@ -100,9 +99,8 @@ export const updateYdocDocxData = async (editor, ydoc) => { if (!newXml || typeof newXml !== 'object') return; // Write each changed file as its own Y.Map entry (separate WS messages). - const excludeSynced = !!editor.options.excludeSyncedContent; Object.keys(newXml).forEach((key) => { - if (!shouldSyncFile(key, excludeSynced)) return; + if (!shouldSyncFile(key)) return; if (existingFiles[key] === newXml[key]) return; docxFilesMap.set(key, newXml[key]); }); diff --git a/packages/super-editor/src/extensions/collaboration/collaboration.js b/packages/super-editor/src/extensions/collaboration/collaboration.js index df1dc6b86e..9a8d0cfe89 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration.js @@ -119,12 +119,11 @@ export const initializeMetaMap = (ydoc, editor) => { metaMap.set('fonts', editor.options.fonts); // Store each docx file as a separate Y.Map entry (smaller Yjs messages). - const excludeSynced = !!editor.options.excludeSyncedContent; 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, excludeSynced)) { + if (file?.name && file?.content && shouldSyncFile(file.name)) { docxFilesMap.set(file.name, file.content); } }); diff --git a/packages/superdoc/src/SuperDoc.vue b/packages/superdoc/src/SuperDoc.vue index 499c1f4fc5..379f415302 100644 --- a/packages/superdoc/src/SuperDoc.vue +++ b/packages/superdoc/src/SuperDoc.vue @@ -499,7 +499,6 @@ const editorOptions = (doc) => { onTransaction: onEditorTransaction, ydoc: doc.ydoc, collaborationProvider: doc.provider || null, - excludeSyncedContent: doc.excludeSyncedContent || false, isNewFile, handleImageUpload: proxy.$superdoc.config.handleImageUpload, externalExtensions: proxy.$superdoc.config.editorExtensions || [], diff --git a/packages/superdoc/src/composables/use-document.js b/packages/superdoc/src/composables/use-document.js index 89f922625e..b2c274f319 100644 --- a/packages/superdoc/src/composables/use-document.js +++ b/packages/superdoc/src/composables/use-document.js @@ -24,7 +24,6 @@ export default function useDocument(params, superdocConfig) { const ydoc = shallowRef(params.ydoc); const provider = shallowRef(params.provider); const socket = shallowRef(params.socket); - const excludeSyncedContent = ref(params.excludeSyncedContent || false); const isNewFile = ref(params.isNewFile); // For docx @@ -97,7 +96,6 @@ export default function useDocument(params, superdocConfig) { ydoc, provider, socket, - excludeSyncedContent, isNewFile, // Placement diff --git a/packages/superdoc/src/core/SuperDoc.js b/packages/superdoc/src/core/SuperDoc.js index b8f96f38b7..21ef32cbf9 100644 --- a/packages/superdoc/src/core/SuperDoc.js +++ b/packages/superdoc/src/core/SuperDoc.js @@ -455,7 +455,7 @@ export class SuperDoc extends EventEmitter { this.isCollaborative = true; // Check for external ydoc/provider (provider-agnostic mode) - const { ydoc: externalYdoc, provider: externalProvider, excludeSyncedContent } = collaborationModuleConfig; + const { ydoc: externalYdoc, provider: externalProvider } = collaborationModuleConfig; if (externalYdoc && externalProvider) { // Use external provider - wire up awareness for SuperDoc events @@ -478,7 +478,6 @@ export class SuperDoc extends EventEmitter { this.config.documents.forEach((doc) => { doc.ydoc = externalYdoc; doc.provider = externalProvider; - doc.excludeSyncedContent = excludeSyncedContent || false; doc.role = this.config.role; }); diff --git a/packages/superdoc/src/dev/components/SuperdocDev.vue b/packages/superdoc/src/dev/components/SuperdocDev.vue index f145d08630..667acba318 100644 --- a/packages/superdoc/src/dev/components/SuperdocDev.vue +++ b/packages/superdoc/src/dev/components/SuperdocDev.vue @@ -393,7 +393,6 @@ const init = async () => { collaboration: { ydoc: ydocRef.value, provider: providerRef.value, - excludeSyncedContent: collabProvider === 'liveblocks', }, } : {}), From 017ef56d8e644cbabec415fec3f876df3cc7544e Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 16 Feb 2026 06:08:20 -0300 Subject: [PATCH 3/6] fix(collaboration): migrate all legacy files before setting docxReady MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, updateYdocDocxData only wrote files returned by exportDocx({ getUpdatedDocs: true }) — which excludes static assets like themes, fontTable, and docProps. Joining clients would read from the incomplete docxFiles map and lose those files on export. Now seeds docxFiles with ALL legacy entries before writing changed files, and deletes the old meta.docx to free up space in the Y.Doc. --- .../collaboration/collaboration-helpers.js | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js b/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js index a6b890cbd4..4af7bbeb98 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js @@ -86,6 +86,7 @@ export const updateYdocDocxData = async (editor, ydoc) => { const docxFilesMap = ydoc.getMap('docxFiles'); const metaMap = ydoc.getMap('meta'); + const isNewFormat = docxFilesMap.size > 0; const existingFiles = readExistingDocxFiles(ydoc); // Seed from editor content if nothing stored yet @@ -95,6 +96,21 @@ export const updateYdocDocxData = async (editor, ydoc) => { }); } + // 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; From a27e6ced08533760231f08e2464bdc02bb20a934 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 16 Feb 2026 06:23:16 -0300 Subject: [PATCH 4/6] test(collaboration): update tests for per-file Y.Map storage format Update collaboration test suite to match the new per-file docxFilesMap format. Tests now verify: per-file writes, CRDT-synced file exclusion, docxReady flag, legacy migration with meta.docx deletion, and Y.Array/iterable normalization. --- .../collaboration/collaboration.test.js | 470 ++++++++++-------- 1 file changed, 258 insertions(+), 212 deletions(-) diff --git a/packages/super-editor/src/extensions/collaboration/collaboration.test.js b/packages/super-editor/src/extensions/collaboration/collaboration.test.js index f7b2e3f34f..5e4838b481 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration.test.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration.test.js @@ -45,12 +45,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 +66,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 +81,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 +95,272 @@ 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/document.xml': '', - 'word/styles.xml': '', - }), - }; + const editor = { + options: { ydoc, user: { id: 'user-1' } }, + exportDocx: vi.fn().mockResolvedValue({ 'word/styles.xml': '' }), + }; - await updateYdocDocxData(editor); + await updateYdocDocxData(editor); - expect(ydoc.transact).toHaveBeenCalled(); - expect(metas.set).toHaveBeenCalledWith( - 'docx', - expect.arrayContaining([ - { name: 'word/styles.xml', content: '' }, - { name: 'word/document.xml', content: '' }, - ]), - ); - }); + // All syncable files should be migrated + expect(docxFiles.set).toHaveBeenCalledWith('word/styles.xml', ''); + expect(docxFiles.set).toHaveBeenCalledWith('word/theme/theme1.xml', ''); + }); - it('triggers transaction when new file is added', async () => { - const existingDocx = [{ name: 'word/document.xml', content: '' }]; - const ydoc = createYDocStub({ docxValue: existingDocx }); + 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/document.xml': '', - 'word/numbering.xml': '', - }), - }; + const editor = { + options: { ydoc, user: { id: 'user-1' } }, + exportDocx: vi.fn().mockResolvedValue({ 'word/styles.xml': '' }), + }; - await updateYdocDocxData(editor); + await updateYdocDocxData(editor); - expect(ydoc.transact).toHaveBeenCalled(); - }); + const docXmlCalls = docxFiles.set.mock.calls.filter(([key]) => key === 'word/document.xml'); + expect(docXmlCalls).toHaveLength(0); + }); - 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('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/document.xml': '', - 'word/styles.xml': '', - 'word/numbering.xml': '', - }), - }; + const editor = { + options: { ydoc, user: { id: 'user-1' } }, + exportDocx: vi.fn().mockResolvedValue({ 'word/styles.xml': '' }), + }; - await updateYdocDocxData(editor); + await updateYdocDocxData(editor); - expect(ydoc.transact).not.toHaveBeenCalled(); - }); + 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; - 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; + const editor = { + options: { ydoc, user: { id: 'user-2' }, content: [] }, + exportDocx: vi.fn().mockResolvedValue({ 'word/styles.xml': '' }), + }; - 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': '', - }), - }; + await updateYdocDocxData(editor); - await updateYdocDocxData(editor); + expect(docxSource.toArray).toHaveBeenCalled(); + expect(docxFiles.set).toHaveBeenCalledWith('word/styles.xml', ''); + }); - // Transaction should still happen to initialize the docx metadata for collaborators - expect(ydoc.transact).toHaveBeenCalled(); - expect(metas.set).toHaveBeenCalledWith('docx', initialContent); - }); + 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', ''); - 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; + 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: 'new-user' }, content: initialContent }, - exportDocx: vi.fn().mockResolvedValue({ - 'word/document.xml': '', - }), + options: { ydoc: null, user: { id: 'user-1' }, content: [] }, + exportDocx: vi.fn(), }; await updateYdocDocxData(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); + expect(editor.exportDocx).not.toHaveBeenCalled(); }); }); @@ -455,7 +476,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 +491,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 +517,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 +534,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 +1029,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 +1040,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])); }); }); From c4ffb3eb6aacdd142b10dc7b6c88d90ed5a815d5 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 16 Feb 2026 06:41:09 -0300 Subject: [PATCH 5/6] chore(dev): rename collab server files and add liveblocks dev script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename collab-server.js → hocuspocus-server.js and liveblocks-proxy.js → liveblocks-server.js for consistency. Add dev:collab:hocuspocus and dev:collab:liveblocks scripts to both root and superdoc package.json. Remove standalone collab-server script. --- package.json | 2 + packages/superdoc/package.json | 5 +- ...{collab-server.js => hocuspocus-server.js} | 0 .../superdoc/src/dev/liveblocks-server.js | 81 +++++++++++++++++++ 4 files changed, 86 insertions(+), 2 deletions(-) rename packages/superdoc/src/dev/{collab-server.js => hocuspocus-server.js} (100%) create mode 100644 packages/superdoc/src/dev/liveblocks-server.js diff --git a/package.json b/package.json index 8d23241f7e..96a6bd747c 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/superdoc/package.json b/packages/superdoc/package.json index 03c917bdfe..9e32b53ef8 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", 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 0000000000..78c0a67bcd --- /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}`); +}); From 56b473da836f70c380ccd87bc48de53dd84be8c5 Mon Sep 17 00:00:00 2001 From: Caio Pizzol Date: Mon, 16 Feb 2026 20:56:19 -0300 Subject: [PATCH 6/6] fix(collaboration): sync all DOCX files and sectPr on replaceFile for header/footer support --- .../src/components/SuperEditor.vue | 11 +- .../presentation-editor/PresentationEditor.ts | 3 + .../collaboration/collaboration-helpers.js | 64 ++++++++++- .../extensions/collaboration/collaboration.js | 9 ++ .../collaboration/collaboration.test.js | 106 +++++++++++++++++- 5 files changed, 187 insertions(+), 6 deletions(-) diff --git a/packages/super-editor/src/components/SuperEditor.vue b/packages/super-editor/src/components/SuperEditor.vue index 1bd8740f89..a64019ed8b 100644 --- a/packages/super-editor/src/components/SuperEditor.vue +++ b/packages/super-editor/src/components/SuperEditor.vue @@ -19,7 +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 { PLACEHOLDER_DOCUMENT_XML } from '@extensions/collaboration/collaboration-helpers.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'; @@ -696,10 +696,13 @@ const pollForMetaMapData = (ydoc, retries = 10, interval = 500) => { }); // word/document.xml is not stored in Y.Map — its content is synced via - // y-prosemirror XmlFragment instead. Provide a minimal placeholder so the - // converter can initialize its schema. + // 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')) { - docx.push({ name: 'word/document.xml', content: PLACEHOLDER_DOCUMENT_XML }); + const bodySectPr = metaMap.get('bodySectPr'); + const placeholder = buildDocumentXmlPlaceholder(bodySectPr); + docx.push({ name: 'word/document.xml', content: placeholder }); } stopPolling(); diff --git a/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts b/packages/super-editor/src/core/presentation-editor/PresentationEditor.ts index 91a6b7a638..bfe6393c2f 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 4af7bbeb98..71f7481343 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration-helpers.js @@ -86,10 +86,35 @@ export const updateYdocDocxData = async (editor, ydoc) => { const docxFilesMap = ydoc.getMap('docxFiles'); const metaMap = ydoc.getMap('meta'); + + // 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); + } + }); + } + const isNewFormat = docxFilesMap.size > 0; const existingFiles = readExistingDocxFiles(ydoc); - // Seed from editor content if nothing stored yet + // 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; @@ -129,6 +154,43 @@ export const updateYdocDocxData = async (editor, ydoc) => { } }; +/** + * 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 9a8d0cfe89..517c22a04a 100644 --- a/packages/super-editor/src/extensions/collaboration/collaboration.js +++ b/packages/super-editor/src/extensions/collaboration/collaboration.js @@ -5,6 +5,7 @@ import { ySyncPlugin, ySyncPluginKey, yUndoPluginKey, prosemirrorToYDoc } from ' import { updateYdocDocxData, applyRemoteHeaderFooterChanges, + extractBodySectPr, shouldSyncFile, } from '@extensions/collaboration/collaboration-helpers.js'; @@ -134,6 +135,14 @@ export const initializeMetaMap = (ydoc, editor) => { 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, docxFilesMap) => { diff --git a/packages/super-editor/src/extensions/collaboration/collaboration.test.js b/packages/super-editor/src/extensions/collaboration/collaboration.test.js index 5e4838b481..9a88b37e93 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)); @@ -364,6 +365,109 @@ describe('collaboration helpers', () => { }); }); +describe('extractBodySectPr', () => { + it('extracts sectPr from a typical document.xml', () => { + const xml = + '' + + '' + + '' + + ''; + const result = extractBodySectPr(xml); + expect(result).toBe( + '' + + '', + ); + }); + + it('returns null for null/undefined input', () => { + expect(extractBodySectPr(null)).toBeNull(); + expect(extractBodySectPr(undefined)).toBeNull(); + }); + + it('returns null when no sectPr exists', () => { + const xml = ''; + expect(extractBodySectPr(xml)).toBeNull(); + }); + + it('extracts the last sectPr when multiple exist', () => { + const xml = + '' + + '' + + '' + + ''; + expect(extractBodySectPr(xml)).toBe(''); + }); +}); + +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('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: { fonts: {}, content, mediaFiles: {}, user: {} }, + }; + + initializeMetaMap(ydoc, editor); + + const metaMap = ydoc._maps.metas; + expect(metaMap.get('bodySectPr')).toBe(''); + }); + + it('does not store bodySectPr when document.xml has no sectPr', () => { + const content = [{ name: 'word/document.xml', content: '' }]; + const ydoc = createYDocStub(); + const editor = { + options: { fonts: {}, content, mediaFiles: {}, user: {} }, + }; + + initializeMetaMap(ydoc, editor); + + const metaMap = ydoc._maps.metas; + expect(metaMap.get('bodySectPr')).toBeUndefined(); + }); + + it('does not store bodySectPr when content is not an array', () => { + const ydoc = createYDocStub(); + const editor = { + options: { fonts: {}, content: 'not-an-array', mediaFiles: {}, user: {} }, + }; + + initializeMetaMap(ydoc, editor); + + const metaMap = ydoc._maps.metas; + expect(metaMap.get('bodySectPr')).toBeUndefined(); + }); +}); + describe('collaboration extension', () => { it('skips plugin registration when no ydoc present', () => { const result = Collaboration.config.addPmPlugins.call({ editor: { options: {} } });