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
@@ -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