Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
37 changes: 33 additions & 4 deletions packages/super-editor/src/components/SuperEditor.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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 =
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"' +
' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
'<w:body><w:p><w:r><w:t></w:t></w:r></w:p></w:body></w:document>';

/**
* 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<string, string>} 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<void>}
*/
Expand All @@ -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 <w:sectPr> 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 <w:sectPr>...</w:sectPr> substring, or null
*/
export const extractBodySectPr = (documentXml) => {
if (!documentXml) return null;
const lastIdx = documentXml.lastIndexOf('<w:sectPr');
if (lastIdx === -1) return null;
const endIdx = documentXml.indexOf('</w:sectPr>', lastIdx);
if (endIdx === -1) return null;
return documentXml.substring(lastIdx, endIdx + '</w:sectPr>'.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 <w:sectPr> XML string
* @returns {string} A minimal document.xml with the section properties included
*/
export const buildDocumentXmlPlaceholder = (bodySectPr) => {
if (!bodySectPr) return PLACEHOLDER_DOCUMENT_XML;
return (
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' +
'<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"' +
' xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' +
'<w:body><w:p><w:r><w:t></w:t></w:r></w:p>' +
bodySectPr +
'</w:body></w:document>'
);
};

// 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand All @@ -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);
}
Expand Down
Loading