diff --git a/examples/collaboration/production/README.md b/examples/collaboration/production/README.md index cbd1646ef..8865f0ff1 100644 --- a/examples/collaboration/production/README.md +++ b/examples/collaboration/production/README.md @@ -24,7 +24,7 @@ This example demonstrates: **Client**: Vue 3 application with SuperDoc editor and Y.js WebSocket provider **Server**: Fastify server with WebSocket support for Y.js synchronization -**Note**: This example does not persist documents. All collaboration is in-memory and ephemeral. Documents reset when the server restarts. +**Note**: This example uses in-memory storage. Documents persist during the server session but reset when the server restarts. See [server/storage.ts](server/storage.ts) for the storage interface. ## Quick Start @@ -89,6 +89,32 @@ collaboration-in-production/ 3. Y.js handles CRDT-based conflict resolution automatically 4. Changes propagate in real-time to all connected clients +### First-Time Document Initialization + +When collaboration is enabled, SuperDoc ignores the `documents[].data` property by default. This prevents sync conflicts where multiple users might broadcast their local file and overwrite each other. + +For new documents (no existing Yjs state), use `isNewFile: true`: + +```javascript +// Check if document exists on server +const { exists } = await fetch(`/doc/${documentId}/exists`).then(r => r.json()); + +const config = { + document: { + id: documentId, + data: exists ? null : docxArrayBuffer, // Provide DOCX for new documents + isNewFile: !exists, // Enables DOCX → Yjs conversion + }, + modules: { collaboration: { url: wsUrl } } +}; +``` + +The flow: +1. Server's `onLoad` returns `null` for new documents +2. Client detects this and sets `isNewFile: true` with DOCX data +3. SuperDoc converts the DOCX to Yjs state +4. The state syncs to all connected clients and persists on auto-save + ## Extending This Example To add persistence, you could: diff --git a/examples/collaboration/production/client/src/DocumentEditor.vue b/examples/collaboration/production/client/src/DocumentEditor.vue index d5cbfa4da..722b07d4a 100644 --- a/examples/collaboration/production/client/src/DocumentEditor.vue +++ b/examples/collaboration/production/client/src/DocumentEditor.vue @@ -115,12 +115,27 @@ const init = async () => { const hideToolbar = route.query['hide-toolbar'] === 'true'; showToolbar.value = !hideToolbar; + // Check if this is a new document (no existing Yjs state on server) + const existsResponse = await fetch(`${apiUrl}/doc/${documentId}/exists`); + const { exists } = await existsResponse.json(); + + // For new documents, load the default DOCX to initialize collaboration state + let docxData = null; + if (!exists) { + console.log('New document - loading default DOCX for initialization'); + const response = await fetch(defaultDocument); + docxData = await response.arrayBuffer(); + } + const config = { selector: '#superdoc', document: { id: documentId, type: 'docx', - isNewFile: false, + // isNewFile: true tells SuperDoc to use the provided data to initialize Yjs state + // This is required for new documents because collaboration ignores data by default + isNewFile: !exists, + data: docxData, }, pagination: true, colors: ['#a11134', '#2a7e34', '#b29d11', '#2f4597', '#ab5b22'], @@ -137,35 +152,13 @@ const init = async () => { console.log('SuperDoc is ready', event); const editor = event.superdoc.activeEditor; console.log('Active editor:', editor); - + // Set up media observer for collaboration const ydoc = event.superdoc.ydoc; if (ydoc && editor) { setupMediaObserver(ydoc, editor); } }, - onEditorCreate: async (event) => { - // load default doc if current doc is blank - const { editor } = event; - - if (!editor?.state) return; - const textContent = editor.state.doc.textContent; - - // Check if document is empty (no content or only whitespace) - const isEmpty = !textContent || textContent.trim().length === 0; - if (!isEmpty) return; - - try { - // Fetch and load default.docx - const response = await fetch(defaultDocument); - const blob = await response.blob(); - const file = new File([blob], 'default.docx', { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' }); - - await editor.replaceFile(file); - } catch (error) { - console.error('Error loading default content:', error); - } - }, onContentError: ({ error, documentId, file }) => { console.error('Content loading error:', error); console.log('Failed document:', documentId, file); diff --git a/examples/collaboration/production/server/index.ts b/examples/collaboration/production/server/index.ts index 9c0c75721..2f8306a3d 100644 --- a/examples/collaboration/production/server/index.ts +++ b/examples/collaboration/production/server/index.ts @@ -36,6 +36,7 @@ const SuperDocCollaboration = new CollaborationBuilder() .onLoad((async (params) => { try { const state = await loadDocument(params.documentId); + // Returns null for new documents - client should use isNewFile: true with DOCX data return state; } catch(error) { const err = new Error('Failed to load document: ' + error); @@ -65,6 +66,13 @@ fastify.get('/health', async (request, reply) => { return { status: 'ok', timestamp: new Date().toISOString() }; }); +/** Check if a document exists (for first-time initialization) */ +fastify.get('/doc/:documentId/exists', async (request, reply) => { + const { documentId } = request.params as { documentId: string }; + const state = await loadDocument(documentId); + return { exists: state !== null }; +}); + /** Generate user info endpoint */ fastify.get('/user', async (request, reply) => { return generateUser(); diff --git a/examples/collaboration/production/server/storage.ts b/examples/collaboration/production/server/storage.ts index 9103f0fbd..2c1f18ed7 100644 --- a/examples/collaboration/production/server/storage.ts +++ b/examples/collaboration/production/server/storage.ts @@ -1,18 +1,27 @@ import type { StorageFunction } from './storage-types.js'; -import { Doc as YDoc, encodeStateAsUpdate } from 'yjs'; -const blankDocxYdoc = new YDoc(); -const metaMap = blankDocxYdoc.getMap('meta'); - -// Add minimal DOCX structure that the client expects -metaMap.set('docx', []); +// In-memory storage for demonstration +// In production, replace with your database (PostgreSQL, Redis, etc.) +const documents = new Map(); export const loadDocument: StorageFunction = async (id: string) => { - // Return an empty Y.js document with minimal DOCX structure - return encodeStateAsUpdate(blankDocxYdoc); + const state = documents.get(id); + + // Return null if document doesn't exist yet + // This signals to the client that it should initialize with isNewFile: true + if (!state) { + console.log(`[storage] Document "${id}" not found - returning null for first-time initialization`); + return null; + } + + console.log(`[storage] Document "${id}" loaded (${state.byteLength} bytes)`); + return state; }; -export const saveDocument: StorageFunction = async (id: string, file?: Uint8Array) => { - // No-op - just return success +export const saveDocument: StorageFunction = async (id: string, state?: Uint8Array) => { + if (!state) return false; + + documents.set(id, state); + console.log(`[storage] Document "${id}" saved (${state.byteLength} bytes)`); return true; }; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 65ed45b79..d9a8bfee3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4083,8 +4083,8 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", - "devOptional": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/abort-controller": { "version": "3.0.0", @@ -5218,8 +5218,8 @@ "version": "2.0.0", "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "devOptional": true, "license": "ISC", + "optional": true, "engines": { "node": ">=10" } @@ -8407,8 +8407,8 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "devOptional": true, "license": "ISC", + "optional": true, "dependencies": { "minipass": "^3.0.0" }, @@ -8420,8 +8420,8 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "devOptional": true, "license": "ISC", + "optional": true, "dependencies": { "yallist": "^4.0.0" }, @@ -12223,8 +12223,8 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", - "devOptional": true, "license": "MIT", + "optional": true, "dependencies": { "minipass": "^3.0.0", "yallist": "^4.0.0" @@ -12237,8 +12237,8 @@ "version": "3.3.6", "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "devOptional": true, "license": "ISC", + "optional": true, "dependencies": { "yallist": "^4.0.0" }, @@ -12601,8 +12601,8 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", - "devOptional": true, "license": "ISC", + "optional": true, "dependencies": { "abbrev": "1" }, @@ -19274,8 +19274,8 @@ "version": "6.2.1", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "devOptional": true, "license": "ISC", + "optional": true, "dependencies": { "chownr": "^2.0.0", "fs-minipass": "^2.0.0", @@ -19304,8 +19304,8 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "devOptional": true, "license": "ISC", + "optional": true, "engines": { "node": ">=8" } @@ -21911,8 +21911,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", - "devOptional": true, - "license": "ISC" + "license": "ISC", + "optional": true }, "node_modules/yaml": { "version": "2.8.2", @@ -24832,7 +24832,7 @@ } }, "packages/superdoc": { - "version": "1.0.0-beta.17", + "version": "1.0.2", "license": "AGPL-3.0", "dependencies": { "buffer-crc32": "^1.0.0",