diff --git a/client/package-lock.json b/client/package-lock.json index 9e313305..6c0f6282 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -20,6 +20,7 @@ "dompurify": "3.3.1", "fuse.js": "7.1.0", "jquery": "3.7.1", + "lib0": "^0.2.117", "lodash": "4.17.23", "loglevel": "1.9.2", "marked": "11.2.0", @@ -31,7 +32,8 @@ "vue-toast-notification": "0.6.3", "vuelidate": "0.7.7", "vuex": "3.6.2", - "vuex-persistedstate": "3.2.1" + "vuex-persistedstate": "3.2.1", + "yjs": "^13.6.29" }, "devDependencies": { "@babel/core": "7.29.0", @@ -5245,6 +5247,16 @@ "dev": true, "license": "ISC" }, + "node_modules/isomorphic.js": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/isomorphic.js/-/isomorphic.js-0.2.5.tgz", + "integrity": "sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==", + "license": "MIT", + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/jackspeak": { "version": "3.4.3", "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.3.tgz", @@ -5440,6 +5452,27 @@ "node": ">= 0.8.0" } }, + "node_modules/lib0": { + "version": "0.2.117", + "resolved": "https://registry.npmjs.org/lib0/-/lib0-0.2.117.tgz", + "integrity": "sha512-DeXj9X5xDCjgKLU/7RR+/HQEVzuuEUiwldwOGsHK/sfAfELGWEyTcf0x+uOvCvK3O2zPmZePXWL85vtia6GyZw==", + "license": "MIT", + "dependencies": { + "isomorphic.js": "^0.2.4" + }, + "bin": { + "0ecdsa-generate-keypair": "bin/0ecdsa-generate-keypair.js", + "0gentesthtml": "bin/gentesthtml.js", + "0serve": "bin/0serve.js" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -15037,6 +15070,23 @@ "dev": true, "license": "ISC" }, + "node_modules/yjs": { + "version": "13.6.29", + "resolved": "https://registry.npmjs.org/yjs/-/yjs-13.6.29.tgz", + "integrity": "sha512-kHqDPdltoXH+X4w1lVmMtddE3Oeqq48nM40FD5ojTd8xYhQpzIDcfE2keMSU5bAgRPJBe225WTUdyUgj1DtbiQ==", + "license": "MIT", + "dependencies": { + "lib0": "^0.2.99" + }, + "engines": { + "node": ">=16.0.0", + "npm": ">=8.0.0" + }, + "funding": { + "type": "GitHub Sponsors ❤", + "url": "https://github.com/sponsors/dmonad" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/client/package.json b/client/package.json index 7557e2e8..131cd133 100644 --- a/client/package.json +++ b/client/package.json @@ -38,6 +38,7 @@ "dompurify": "3.3.1", "fuse.js": "7.1.0", "jquery": "3.7.1", + "lib0": "^0.2.117", "lodash": "4.17.23", "loglevel": "1.9.2", "marked": "11.2.0", @@ -49,7 +50,8 @@ "vue-toast-notification": "0.6.3", "vuelidate": "0.7.7", "vuex": "3.6.2", - "vuex-persistedstate": "3.2.1" + "vuex-persistedstate": "3.2.1", + "yjs": "^13.6.29" }, "devDependencies": { "@babel/core": "7.29.0", diff --git a/client/src/main.js b/client/src/main.js index 9463ed85..dc2a2802 100644 --- a/client/src/main.js +++ b/client/src/main.js @@ -88,6 +88,14 @@ function initializeWebSocket() { ); } } + // Route collaborative editing messages to the draft provider + if ( + ['YJS_SYNC', 'YJS_UPDATE', 'YJS_AWARENESS', 'COLLAB_ERROR', 'ROOM_MEMBERS'].includes( + msg.OP + ) + ) { + this.store.dispatch('HANDLE_DRAFT_MESSAGE', msg); + } return; } } diff --git a/client/src/store/modules/scriptDraft.js b/client/src/store/modules/scriptDraft.js new file mode 100644 index 00000000..8fab5a86 --- /dev/null +++ b/client/src/store/modules/scriptDraft.js @@ -0,0 +1,273 @@ +/** + * Vuex module for collaborative script editing draft state. + * + * Tracks the connection state to a collaborative editing room, + * the Yjs document and provider instances, and collaborator presence. + * + * IMPORTANT: The Y.Doc and ScriptDocProvider instances are stored outside + * of Vuex reactive state (as module-level variables). Vue 2's reactivity + * system deeply observes all objects in state, adding getters/setters to + * every property. For complex library objects like Y.Doc, this causes Vue + * to track internal Yjs properties as reactive dependencies — leading to + * infinite render loops when Y.Doc internals change during transactions. + */ + +import Vue from 'vue'; +import * as Y from 'yjs'; +import log from 'loglevel'; + +import ScriptDocProvider from '@/utils/yjs/ScriptDocProvider'; + +/** + * Non-reactive storage for Y.Doc and provider instances. + * These must NOT be stored in Vuex state because Vue 2 would make them + * deeply reactive, breaking Yjs internal state management. + * + * @type {import('yjs').Doc|null} + */ +let _ydoc = null; + +/** @type {ScriptDocProvider|null} */ +let _provider = null; + +export default { + state: { + /** @type {number|null} The revision ID of the active room */ + roomId: null, + + /** @type {boolean} Whether we are connected to a collab room */ + isConnected: false, + + /** @type {boolean} Whether the initial sync from the server is complete */ + isSynced: false, + + /** @type {boolean} Whether there are unsaved changes in the draft */ + isDraft: false, + + /** @type {string|null} ISO timestamp of last save */ + lastSavedAt: null, + + /** @type {Array<{user_id: number, username: string, role: string}>} */ + collaborators: [], + + /** @type {Object} */ + awarenessStates: {}, + }, + + mutations: { + SET_DRAFT_ROOM(state, { roomId }) { + state.roomId = roomId; + }, + + SET_DRAFT_CONNECTED(state, value) { + state.isConnected = value; + }, + + SET_DRAFT_SYNCED(state, value) { + state.isSynced = value; + }, + + SET_DRAFT_DIRTY(state, value) { + state.isDraft = value; + }, + + SET_DRAFT_LAST_SAVED(state, timestamp) { + state.lastSavedAt = timestamp; + }, + + SET_DRAFT_COLLABORATORS(state, collaborators) { + state.collaborators = collaborators; + }, + + SET_AWARENESS_STATE(state, { userId, awarenessState }) { + Vue.set(state.awarenessStates, userId, awarenessState); + }, + + REMOVE_AWARENESS_STATE(state, userId) { + Vue.delete(state.awarenessStates, userId); + }, + + CLEAR_DRAFT_STATE(state) { + state.roomId = null; + state.isConnected = false; + state.isSynced = false; + state.isDraft = false; + state.lastSavedAt = null; + state.collaborators = []; + state.awarenessStates = {}; + _ydoc = null; + _provider = null; + }, + }, + + actions: { + /** + * Join a collaborative editing room for a script revision. + * Creates a Y.Doc and ScriptDocProvider, connects to the server. + * + * @param {object} context - Vuex action context + * @param {object} params + * @param {number} params.revisionId - Script revision to edit + * @param {string} [params.role='editor'] - 'editor' or 'viewer' + */ + async JOIN_DRAFT_ROOM(context, { revisionId, role = 'editor' }) { + // Leave existing room first + if (_provider) { + await context.dispatch('LEAVE_DRAFT_ROOM'); + } + + const ydoc = new Y.Doc(); + const provider = new ScriptDocProvider(ydoc, revisionId, { role }); + + // Store instances outside reactive state + _ydoc = ydoc; + _provider = provider; + + context.commit('SET_DRAFT_ROOM', { roomId: revisionId }); + + // Listen for sync completion + const checkSynced = setInterval(() => { + if (provider.synced) { + context.commit('SET_DRAFT_SYNCED', true); + context.commit('SET_DRAFT_CONNECTED', true); + clearInterval(checkSynced); + } + }, 100); + + // Stop checking after 10 seconds (timeout) + setTimeout(() => { + clearInterval(checkSynced); + if (!provider.synced) { + log.error('ScriptDraft: Sync timeout after 10 seconds'); + } + }, 10000); + + provider.connect(); + log.info(`ScriptDraft: Joined room for revision ${revisionId} as ${role}`); + }, + + /** + * Leave the current collaborative editing room. + */ + async LEAVE_DRAFT_ROOM(context) { + if (_provider) { + _provider.destroy(); + } + + context.commit('CLEAR_DRAFT_STATE'); + log.info('ScriptDraft: Left draft room'); + }, + + /** + * Handle an incoming WebSocket message that might be for the draft provider. + * Called from the SOCKET_ONMESSAGE mutation or action. + * + * @param {object} context + * @param {object} message - The WebSocket message + * @returns {boolean} Whether the message was handled + */ + HANDLE_DRAFT_MESSAGE(context, message) { + if (!_provider) return false; + + const handled = _provider.handleMessage(message); + + // Check if sync status changed + if (handled && _provider.synced && !context.state.isSynced) { + context.commit('SET_DRAFT_SYNCED', true); + context.commit('SET_DRAFT_CONNECTED', true); + } + + // Handle structured responses from the provider + if (handled && typeof handled === 'object') { + if (handled.type === 'ROOM_MEMBERS') { + context.commit('SET_DRAFT_COLLABORATORS', handled.members); + } else if (handled.type === 'AWARENESS') { + const state = handled.state; + if (state && state.userId != null) { + if (state.page === null && state.lineIndex === null) { + context.commit('REMOVE_AWARENESS_STATE', state.userId); + } else { + context.commit('SET_AWARENESS_STATE', { + userId: state.userId, + awarenessState: state, + }); + } + } + } + } + + return handled; + }, + }, + + getters: { + /** @returns {boolean} Whether a collaborative editing session is active */ + IS_DRAFT_ACTIVE(state) { + return state.roomId !== null && state.isConnected; + }, + + /** @returns {import('yjs').Doc|null} The Y.Doc instance (non-reactive) */ + DRAFT_YDOC() { + return _ydoc; + }, + + /** @returns {ScriptDocProvider|null} The provider instance (non-reactive) */ + DRAFT_PROVIDER() { + return _provider; + }, + + /** @returns {import('yjs').Map|null} The Y.Doc pages map */ + DRAFT_PAGES() { + if (!_ydoc) return null; + return _ydoc.getMap('pages'); + }, + + /** @returns {import('yjs').Map|null} The Y.Doc meta map */ + DRAFT_META() { + if (!_ydoc) return null; + return _ydoc.getMap('meta'); + }, + + /** @returns {import('yjs').Array|null} The deleted line IDs array */ + DRAFT_DELETED_LINE_IDS() { + if (!_ydoc) return null; + return _ydoc.getArray('deleted_line_ids'); + }, + + /** @returns {boolean} Whether initial sync is complete */ + IS_DRAFT_SYNCED(state) { + return state.isSynced; + }, + + /** @returns {Array} List of collaborators in the room */ + DRAFT_COLLABORATORS(state) { + return state.collaborators; + }, + + /** @returns {Object} Awareness states keyed by userId */ + DRAFT_AWARENESS_STATES(state) { + return state.awarenessStates; + }, + + /** + * Map of "page:lineIndex" → array of users editing that line. + * Used by ScriptLineViewer to show editing indicators. + * + * @returns {Object>} + */ + DRAFT_LINE_EDITORS(state) { + const result = {}; + for (const [userId, awareness] of Object.entries(state.awarenessStates)) { + if (awareness.page != null && awareness.lineIndex != null) { + const key = `${awareness.page}:${awareness.lineIndex}`; + if (!result[key]) result[key] = []; + result[key].push({ + userId: Number(userId), + username: awareness.username || 'Unknown', + }); + } + } + return result; + }, + }, +}; diff --git a/client/src/store/modules/websocket.js b/client/src/store/modules/websocket.js index 2cb17370..be7ca205 100644 --- a/client/src/store/modules/websocket.js +++ b/client/src/store/modules/websocket.js @@ -100,6 +100,13 @@ export default { case 'RELOAD_CLIENT': window.location.reload(); break; + // Collaborative editing messages — handled by HANDLE_DRAFT_MESSAGE action + case 'YJS_SYNC': + case 'YJS_UPDATE': + case 'YJS_AWARENESS': + case 'COLLAB_ERROR': + case 'ROOM_MEMBERS': + break; default: log.error(`Unknown OP received from websocket: ${message.OP}`); } diff --git a/client/src/store/store.js b/client/src/store/store.js index 06c40d90..a9224752 100644 --- a/client/src/store/store.js +++ b/client/src/store/store.js @@ -11,6 +11,7 @@ import system from './modules/system'; import show from './modules/show'; import script from './modules/script'; import scriptConfig from './modules/scriptConfig'; +import scriptDraft from './modules/scriptDraft'; import help from './modules/help'; import stage from './modules/stage'; @@ -203,6 +204,7 @@ export default new Vuex.Store({ stage, script, scriptConfig, + scriptDraft, user, help, }, diff --git a/client/src/utils/yjs/ScriptDocProvider.js b/client/src/utils/yjs/ScriptDocProvider.js new file mode 100644 index 00000000..42043c21 --- /dev/null +++ b/client/src/utils/yjs/ScriptDocProvider.js @@ -0,0 +1,303 @@ +/** + * Custom Yjs provider that uses DigiScript's existing WebSocket connection. + * + * Instead of opening a separate WebSocket (like y-websocket would), + * this provider sends Yjs sync messages via the existing managed + * connection using custom OP codes. + * + * Message flow: + * JOIN_SCRIPT_ROOM → server creates/loads room → YJS_SYNC step 0 (full state) + * YJS_UPDATE ←→ incremental document updates + * YJS_AWARENESS ←→ presence/cursor state + * LEAVE_SCRIPT_ROOM → server removes client from room + */ + +import Vue from 'vue'; +import * as Y from 'yjs'; +import log from 'loglevel'; + +/** + * Encode a Uint8Array to base64 string for JSON transport. + * @param {Uint8Array} uint8Array + * @returns {string} + */ +function encodeBase64(uint8Array) { + let binary = ''; + for (let i = 0; i < uint8Array.length; i++) { + binary += String.fromCharCode(uint8Array[i]); + } + return btoa(binary); +} + +/** + * Decode a base64 string to Uint8Array. + * @param {string} base64 + * @returns {Uint8Array} + */ +function decodeBase64(base64) { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +export default class ScriptDocProvider { + /** + * @param {Y.Doc} doc - The Yjs document to sync + * @param {number} revisionId - The script revision ID for the room + * @param {object} options + * @param {string} [options.role='editor'] - 'editor' or 'viewer' + */ + constructor(doc, revisionId, options = {}) { + this.doc = doc; + this.revisionId = revisionId; + this.roomId = `draft_${revisionId}`; + this.role = options.role || 'editor'; + + this._connected = false; + this._synced = false; + this._destroyed = false; + this._updateHandler = null; + + // Bind the update handler + this._onDocUpdate = this._onDocUpdate.bind(this); + } + + /** + * Get the WebSocket instance. + * @returns {WebSocket|null} + */ + get _socket() { + return Vue.prototype.$socket || null; + } + + /** + * Connect to the collaborative editing room. + * Sends JOIN_SCRIPT_ROOM and starts listening for updates. + */ + connect() { + if (this._destroyed) return; + + const socket = this._socket; + if (!socket || socket.readyState !== WebSocket.OPEN) { + log.warn('ScriptDocProvider: WebSocket not ready, deferring connect'); + return; + } + + // Join the room + socket.sendObj({ + OP: 'JOIN_SCRIPT_ROOM', + DATA: { + revision_id: this.revisionId, + role: this.role, + }, + }); + + // Listen for local doc changes to broadcast + this.doc.on('update', this._onDocUpdate); + + this._connected = true; + log.info(`ScriptDocProvider: Joining room ${this.roomId} as ${this.role}`); + } + + /** + * Disconnect from the collaborative editing room. + */ + disconnect() { + if (!this._connected) return; + + // Clear local awareness before leaving + this.setLocalAwareness({ page: null, lineIndex: null }); + + const socket = this._socket; + if (socket && socket.readyState === WebSocket.OPEN) { + socket.sendObj({ + OP: 'LEAVE_SCRIPT_ROOM', + DATA: { room_id: this.roomId }, + }); + } + + this.doc.off('update', this._onDocUpdate); + this._connected = false; + this._synced = false; + log.info(`ScriptDocProvider: Left room ${this.roomId}`); + } + + /** + * Permanently destroy this provider. Cannot be reconnected after. + */ + destroy() { + this.disconnect(); + this._destroyed = true; + } + + /** + * Handle an incoming WebSocket message from the server. + * Should be called from the Vuex SOCKET_ONMESSAGE handler. + * + * @param {object} message - The parsed WebSocket message + * @returns {boolean|object} true/data if handled, false if not + */ + handleMessage(message) { + if (!this._connected && message.OP !== 'YJS_SYNC') return false; + + const data = message.DATA || {}; + if (data.room_id && data.room_id !== this.roomId) return false; + + switch (message.OP) { + case 'YJS_SYNC': + return this._handleSync(data); + case 'YJS_UPDATE': + return this._handleUpdate(data); + case 'YJS_AWARENESS': + return this._handleAwareness(data); + case 'ROOM_MEMBERS': + return { type: 'ROOM_MEMBERS', members: data.members || [] }; + default: + return false; + } + } + + /** + * Handle YJS_SYNC messages from the server. + * @param {object} data + * @returns {boolean} + */ + _handleSync(data) { + const payload = data.payload; + if (!payload) return false; + + try { + const decoded = decodeBase64(payload); + + if (data.step === 0) { + // Initial full state from server + Y.applyUpdate(this.doc, decoded, 'server'); + this._synced = true; + log.info(`ScriptDocProvider: Synced with room ${this.roomId}`); + + // Send our state vector so server knows what we have + const stateVector = Y.encodeStateVector(this.doc); + this._sendToServer('YJS_SYNC', { + step: 1, + payload: encodeBase64(stateVector), + room_id: this.roomId, + }); + } else if (data.step === 2) { + // Server's diff response to our state vector + Y.applyUpdate(this.doc, decoded, 'server'); + } + } catch (e) { + log.error('ScriptDocProvider: Failed to handle sync message', e); + } + + return true; + } + + /** + * Handle YJS_UPDATE messages from the server (other clients' changes). + * @param {object} data + * @returns {boolean} + */ + _handleUpdate(data) { + const payload = data.payload; + if (!payload) return false; + + try { + const decoded = decodeBase64(payload); + Y.applyUpdate(this.doc, decoded, 'server'); + } catch (e) { + log.error('ScriptDocProvider: Failed to apply update', e); + } + + return true; + } + + /** + * Handle YJS_AWARENESS messages from the server. + * Decodes the JSON payload and returns the awareness state for + * the Vuex store to process. + * + * @param {object} data + * @returns {object|boolean} + */ + _handleAwareness(data) { + const payload = data.payload; + if (!payload) return true; + + try { + const decoded = decodeBase64(payload); + const jsonStr = new TextDecoder().decode(decoded); + const awarenessState = JSON.parse(jsonStr); + return { type: 'AWARENESS', state: awarenessState }; + } catch (e) { + log.error('ScriptDocProvider: Failed to handle awareness message', e); + } + + return true; + } + + /** + * Set local awareness state and broadcast to other clients. + * Used to share which line the user is currently editing. + * + * @param {object} state - e.g. { page, lineIndex, userId, username } + */ + setLocalAwareness(state) { + if (!this._connected) return; + + const jsonStr = JSON.stringify(state); + const encoded = new TextEncoder().encode(jsonStr); + this._sendToServer('YJS_AWARENESS', { + payload: encodeBase64(encoded), + room_id: this.roomId, + }); + } + + /** + * Called when the local Y.Doc is updated. + * Broadcasts the update to the server for other clients. + * + * @param {Uint8Array} update + * @param {*} origin - 'server' if from remote, otherwise local + */ + _onDocUpdate(update, origin) { + // Don't echo back updates that came from the server + if (origin === 'server') return; + if (!this._connected) return; + + this._sendToServer('YJS_UPDATE', { + payload: encodeBase64(update), + room_id: this.roomId, + }); + } + + /** + * Send a message to the server via the existing WebSocket. + * @param {string} op - The OP code + * @param {object} data - The DATA payload + */ + _sendToServer(op, data) { + const socket = this._socket; + if (!socket || socket.readyState !== WebSocket.OPEN) { + log.warn('ScriptDocProvider: Cannot send, WebSocket not connected'); + return; + } + + socket.sendObj({ OP: op, DATA: data }); + } + + /** @returns {boolean} Whether the provider is connected to a room */ + get connected() { + return this._connected; + } + + /** @returns {boolean} Whether the initial sync is complete */ + get synced() { + return this._synced; + } +} + +export { encodeBase64, decodeBase64 }; diff --git a/client/src/utils/yjs/useYjsBinding.js b/client/src/utils/yjs/useYjsBinding.js new file mode 100644 index 00000000..a7444535 --- /dev/null +++ b/client/src/utils/yjs/useYjsBinding.js @@ -0,0 +1,200 @@ +/** + * Vue 2.7 ↔ Yjs reactive bindings. + * + * These utilities create reactive Vue objects that stay in sync with + * Yjs shared types (Y.Map, Y.Array, Y.Text). Changes from remote + * clients are reflected in Vue reactivity, and local changes update + * the Yjs types. + * + * Pattern: + * Yjs type → observe → Vue.set() on reactive proxy + * User input → update Yjs type → observe fires → other clients see change + */ + +import Vue from 'vue'; + +/** + * Create a reactive object bound to a Y.Map. + * + * Returns a plain reactive object whose properties mirror the Y.Map. + * Remote changes update the reactive object automatically. + * + * @param {import('yjs').Map} ymap - The Y.Map to bind + * @param {string[]} [keys] - Specific keys to observe (default: all) + * @returns {{ data: object, destroy: Function }} + */ +export function useYMap(ymap, keys = null) { + const data = Vue.observable({}); + + // Initialize from current state + if (keys) { + keys.forEach((key) => { + Vue.set(data, key, ymap.get(key)); + }); + } else { + ymap.forEach((value, key) => { + Vue.set(data, key, _unwrapYjsValue(value)); + }); + } + + // Observe Y.Map changes + const observer = (event) => { + event.changes.keys.forEach((change, key) => { + if (keys && !keys.includes(key)) return; + + if (change.action === 'add' || change.action === 'update') { + Vue.set(data, key, _unwrapYjsValue(ymap.get(key))); + } else if (change.action === 'delete') { + Vue.delete(data, key); + } + }); + }; + + ymap.observe(observer); + + return { + data, + /** + * Set a value on the Y.Map (triggers sync to other clients). + * @param {string} key + * @param {*} value + */ + set(key, value) { + ymap.set(key, value); + }, + /** + * Stop observing the Y.Map. Call on component destroy. + */ + destroy() { + ymap.unobserve(observer); + }, + }; +} + +/** + * Create a reactive string bound to a Y.Text. + * + * Returns a reactive object with a `value` property that mirrors the Y.Text. + * Remote changes update the reactive value automatically. + * + * @param {import('yjs').Text} ytext - The Y.Text to bind + * @returns {{ data: { value: string }, set: Function, destroy: Function }} + */ +export function useYText(ytext) { + const data = Vue.observable({ value: ytext.toString() }); + + const observer = () => { + data.value = ytext.toString(); + }; + + ytext.observe(observer); + + return { + data, + /** + * Replace the entire text content. + * @param {string} newValue + */ + set(newValue) { + const doc = ytext.doc; + if (!doc) return; + + doc.transact(() => { + ytext.delete(0, ytext.length); + if (newValue) { + ytext.insert(0, newValue); + } + }); + }, + destroy() { + ytext.unobserve(observer); + }, + }; +} + +/** + * Create a reactive array bound to a Y.Array. + * + * Returns a reactive array that mirrors the Y.Array contents. + * Each element is unwrapped: Y.Map → plain object, Y.Text → string. + * + * @param {import('yjs').Array} yarray - The Y.Array to bind + * @returns {{ data: Array, destroy: Function }} + */ +export function useYArray(yarray) { + const data = Vue.observable([]); + + // Initialize from current state + _syncArrayData(yarray, data); + + const observer = () => { + _syncArrayData(yarray, data); + }; + + yarray.observe(observer); + + return { + data, + destroy() { + yarray.unobserve(observer); + }, + }; +} + +/** + * Sync Y.Array contents to a reactive array. + * @param {import('yjs').Array} yarray + * @param {Array} target + */ +function _syncArrayData(yarray, target) { + // Clear and rebuild — simpler than diffing for array changes + target.splice(0, target.length); + yarray.forEach((item) => { + target.push(_unwrapYjsValue(item)); + }); +} + +/** + * Unwrap a Yjs shared type to a plain JS value. + * Y.Map → plain object, Y.Text → string, Y.Array → array. + * Primitive values pass through unchanged. + * + * @param {*} value + * @returns {*} + */ +function _unwrapYjsValue(value) { + if (value == null) return value; + + // Check for Y.Text (has toString and insert methods) + if ( + typeof value === 'object' && + typeof value.insert === 'function' && + typeof value.toString === 'function' && + value.doc !== undefined + ) { + return value.toString(); + } + + // Check for Y.Map (has entries method and _map property) + if ( + typeof value === 'object' && + typeof value.entries === 'function' && + typeof value.set === 'function' && + value.doc !== undefined + ) { + const obj = {}; + value.forEach((v, k) => { + obj[k] = _unwrapYjsValue(v); + }); + return obj; + } + + // Check for Y.Array (has toArray method) + if (typeof value === 'object' && typeof value.toArray === 'function' && value.doc !== undefined) { + return value.toArray().map(_unwrapYjsValue); + } + + return value; +} + +export { _unwrapYjsValue }; diff --git a/client/src/utils/yjs/yjsBridge.js b/client/src/utils/yjs/yjsBridge.js new file mode 100644 index 00000000..626ee67c --- /dev/null +++ b/client/src/utils/yjs/yjsBridge.js @@ -0,0 +1,169 @@ +/** + * Bridge utilities for Y.Doc ↔ TMP_SCRIPT format conversion. + * + * Y.Doc is the source of truth during collaborative editing. TMP_SCRIPT is a + * read-only view cache populated one-way from Y.Doc via observers. + * Components write directly to Y.Map/Y.Text; this module provides: + * - Y.Doc → plain object conversion (for the TMP_SCRIPT view cache) + * - Structural helpers (add/delete lines in Y.Doc) + * - Sentinel conversion (nullToZero / zeroToNull) + * + * Schema differences between Y.Doc and TMP_SCRIPT: + * - `_id` instead of `id` + * - `parts` instead of `line_parts` + * - `0` as sentinel for null on FK fields + * - Y.Text for line_text instead of plain strings + */ + +import * as Y from 'yjs'; + +/** + * Convert 0 → null for FK fields stored as 0 in the Y.Doc. + * @param {*} val + * @returns {*} + */ +export function zeroToNull(val) { + return val === 0 ? null : val; +} + +/** + * Convert null → 0 for FK fields that need a non-null value in the Y.Doc. + * @param {*} val + * @returns {*} + */ +export function nullToZero(val) { + return val == null ? 0 : val; +} + +/** + * Convert a Y.Map line from the Y.Doc to a plain object compatible with TMP_SCRIPT. + * + * @param {import('yjs').Map} lineYMap - A Y.Map representing a script line + * @param {number|string} pageNo - The page number for this line + * @returns {object} A plain line object for TMP_SCRIPT + */ +export function ydocLineToPlain(lineYMap, pageNo) { + const lineId = zeroToNull(lineYMap.get('_id')); + const partsArray = lineYMap.get('parts'); + const lineParts = []; + + if (partsArray) { + for (let i = 0; i < partsArray.length; i++) { + const partYMap = partsArray.get(i); + const lineText = partYMap.get('line_text'); + lineParts.push({ + id: zeroToNull(partYMap.get('_id')), + line_id: lineId, + part_index: partYMap.get('part_index'), + character_id: zeroToNull(partYMap.get('character_id')), + character_group_id: zeroToNull(partYMap.get('character_group_id')), + line_text: lineText ? lineText.toString() : '', + }); + } + } + + return { + id: lineId, + act_id: zeroToNull(lineYMap.get('act_id')), + scene_id: zeroToNull(lineYMap.get('scene_id')), + page: parseInt(pageNo, 10), + line_type: lineYMap.get('line_type'), + line_parts: lineParts, + stage_direction_style_id: zeroToNull(lineYMap.get('stage_direction_style_id')), + }; +} + +/** + * Convert all lines on a Y.Doc page to an array of plain objects for TMP_SCRIPT. + * + * @param {import('yjs').Doc} ydoc - The Y.Doc instance + * @param {number|string} pageNo - The page number to read + * @returns {Array} Array of plain line objects, or empty array if page doesn't exist + */ +export function syncPageFromYDoc(ydoc, pageNo) { + const pages = ydoc.getMap('pages'); + const pageKey = pageNo.toString(); + const pageArray = pages.get(pageKey); + if (!pageArray) return []; + + const lines = []; + for (let i = 0; i < pageArray.length; i++) { + lines.push(ydocLineToPlain(pageArray.get(i), pageNo)); + } + return lines; +} + +/** + * Add a new line to a page in the Y.Doc. + * Creates the necessary Y.Map, Y.Array, and Y.Text structures. + * + * @param {import('yjs').Doc} ydoc - The Y.Doc instance + * @param {number|string} pageNo - The page number + * @param {object} lineObj - The TMP_SCRIPT line object to add + * @param {number} [insertAt] - Index to insert at. If omitted, appends to end. + */ +export function addYDocLine(ydoc, pageNo, lineObj, insertAt) { + const pages = ydoc.getMap('pages'); + const pageKey = pageNo.toString(); + let pageArray = pages.get(pageKey); + + ydoc.transact(() => { + // Create page array if it doesn't exist + if (!pageArray) { + pageArray = new Y.Array(); + pages.set(pageKey, pageArray); + } + + const lineMap = new Y.Map(); + if (insertAt !== undefined && insertAt < pageArray.length) { + pageArray.insert(insertAt, [lineMap]); + } else { + pageArray.push([lineMap]); + } + + lineMap.set('_id', nullToZero(lineObj.id)); + lineMap.set('act_id', nullToZero(lineObj.act_id)); + lineMap.set('scene_id', nullToZero(lineObj.scene_id)); + lineMap.set('line_type', lineObj.line_type); + lineMap.set('stage_direction_style_id', nullToZero(lineObj.stage_direction_style_id)); + + const partsArray = new Y.Array(); + lineMap.set('parts', partsArray); + + if (lineObj.line_parts) { + lineObj.line_parts.forEach((part, i) => { + const partMap = new Y.Map(); + partsArray.push([partMap]); + + partMap.set('_id', nullToZero(part.id)); + partMap.set('character_id', nullToZero(part.character_id)); + partMap.set('character_group_id', nullToZero(part.character_group_id)); + partMap.set('part_index', part.part_index ?? i); + + const ytext = new Y.Text(); + partMap.set('line_text', ytext); + if (part.line_text) { + ytext.insert(0, part.line_text); + } + }); + } + }, 'local-bridge'); +} + +/** + * Delete a line from a page in the Y.Doc. + * + * @param {import('yjs').Doc} ydoc - The Y.Doc instance + * @param {number|string} pageNo - The page number + * @param {number} lineIndex - Index of the line to delete + */ +export function deleteYDocLine(ydoc, pageNo, lineIndex) { + const pages = ydoc.getMap('pages'); + const pageKey = pageNo.toString(); + const pageArray = pages.get(pageKey); + if (!pageArray || lineIndex >= pageArray.length) return; + + ydoc.transact(() => { + pageArray.delete(lineIndex, 1); + }, 'local-bridge'); +} diff --git a/client/src/vue_components/show/config/script/CollaboratorPanel.vue b/client/src/vue_components/show/config/script/CollaboratorPanel.vue new file mode 100644 index 00000000..092205b6 --- /dev/null +++ b/client/src/vue_components/show/config/script/CollaboratorPanel.vue @@ -0,0 +1,93 @@ + + + + + diff --git a/client/src/vue_components/show/config/script/ScriptEditor.vue b/client/src/vue_components/show/config/script/ScriptEditor.vue index 9eb7386f..61a77b1d 100644 --- a/client/src/vue_components/show/config/script/ScriptEditor.vue +++ b/client/src/vue_components/show/config/script/ScriptEditor.vue @@ -53,6 +53,14 @@ + + + + + Act Scene @@ -75,6 +83,7 @@ :characters="CHARACTER_LIST" :character-groups="CHARACTER_GROUP_LIST" :value="TMP_SCRIPT[currentEditPage][index]" + :y-line-map="getYLineMap(index)" :previous-line-fn="getPreviousLineForIndex" :next-line-fn="getNextLineForIndex" :line-type="line.line_type" @@ -98,6 +107,7 @@ :line-part-cuts="linePartCuts" :stage-direction-styles="STAGE_DIRECTION_STYLES" :stage-direction-style-overrides="STAGE_DIRECTION_STYLE_OVERRIDES" + :editing-users="editingUsersForLine(index)" @editLine="beginEditingLine(currentEditPage, index)" @cutLinePart="cutLinePart" @insertDialogue="insertDialogueAt(currentEditPage, index)" @@ -203,13 +213,15 @@ import { sample } from 'lodash'; import ScriptLineEditor from '@/vue_components/show/config/script/ScriptLineEditor.vue'; import ScriptLineViewer from '@/vue_components/show/config/script/ScriptLineViewer.vue'; +import CollaboratorPanel from '@/vue_components/show/config/script/CollaboratorPanel.vue'; import { makeURL, randInt } from '@/js/utils'; import { notNull, notNullAndGreaterThanZero } from '@/js/customValidators'; import { LINE_TYPES } from '@/constants/lineTypes'; +import { syncPageFromYDoc, addYDocLine, deleteYDocLine } from '@/utils/yjs/yjsBridge'; export default { name: 'ScriptConfig', - components: { ScriptLineViewer, ScriptLineEditor }, + components: { ScriptLineViewer, ScriptLineEditor, CollaboratorPanel }, data() { return { currentEditPage: 1, @@ -238,6 +250,8 @@ export default { autoSaveInterval: null, isAutoSaving: false, navbarHeight: 0, + /** @type {Function|null} Deep observer cleanup for Y.Doc pages */ + ydocObserverCleanup: null, }; }, validations: { @@ -311,6 +325,14 @@ export default { 'STAGE_DIRECTION_STYLE_OVERRIDES', 'USER_SETTINGS', 'IS_SCRIPT_EDITOR', + 'CURRENT_REVISION', + 'IS_DRAFT_ACTIVE', + 'IS_DRAFT_SYNCED', + 'DRAFT_YDOC', + 'DRAFT_COLLABORATORS', + 'DRAFT_PROVIDER', + 'DRAFT_LINE_EDITORS', + 'DRAFT_AWARENESS_STATES', ]), }, watch: { @@ -323,8 +345,13 @@ export default { CURRENT_EDITOR() { this.setupAutoSave(); }, + IS_DRAFT_SYNCED(synced) { + if (synced) { + this.setupYDocBridge(); + } + }, }, - async beforeMount() { + async mounted() { await Promise.all([ this.GET_CURRENT_USER() .then(() => this.GET_USER_SETTINGS()) @@ -337,6 +364,7 @@ export default { } return Promise.resolve(); }), + this.GET_SCRIPT_REVISIONS(), this.GET_SCRIPT_CONFIG_STATUS(), this.GET_ACT_LIST(), this.GET_SCENE_LIST(), @@ -356,10 +384,18 @@ export default { this.currentEditPage = parseInt(storedPage, 10); } await this.goToPageInner(this.currentEditPage); - }, - mounted() { + + // Join collaborative editing room if a revision is active + if (this.CURRENT_REVISION) { + this.JOIN_DRAFT_ROOM({ + revisionId: this.CURRENT_REVISION, + role: this.IS_SCRIPT_EDITOR ? 'editor' : 'viewer', + }); + } + + // All data loaded — now safe to render this.loaded = true; - this.calculateNavbarHeight(); + this.$nextTick(() => this.calculateNavbarHeight()); }, created() { window.addEventListener('resize', this.calculateNavbarHeight); @@ -369,6 +405,8 @@ export default { if (this.autoSaveInterval != null) { clearInterval(this.autoSaveInterval); } + this.teardownYDocBridge(); + this.LEAVE_DRAFT_ROOM(); }, methods: { async getMaxScriptPage() { @@ -445,67 +483,50 @@ export default { // Pre-load next page await this.LOAD_SCRIPT_PAGE(this.currentEditPage + 1); }, - async addNewLine() { - this.ADD_BLANK_LINE({ - pageNo: this.currentEditPage, - lineObj: this.blankLineObj, - }); + /** + * Common logic for all add-line operations. + * Builds a complete lineObj (with act_id/scene_id inherited), then writes + * to Y.Doc (collab mode) or TMP_SCRIPT (non-collab mode). + * @param {number} lineType - LINE_TYPES value + * @param {boolean} [trackAsLatest=false] - Whether to track as latestAddedLine + */ + async addLineOfType(lineType, trackAsLatest = false) { + const lineObj = JSON.parse(JSON.stringify(this.blankLineObj)); + lineObj.line_type = lineType; + + // Determine target index and inherit act_id/scene_id from previous line + const currentPageLines = this.TMP_SCRIPT[this.currentEditPageKey] || []; + const prevLine = await this.getPreviousLineForIndex(currentPageLines.length); + if (prevLine) { + lineObj.act_id = prevLine.act_id; + lineObj.scene_id = prevLine.scene_id; + } + + if (this.IS_DRAFT_ACTIVE && this.DRAFT_YDOC) { + addYDocLine(this.DRAFT_YDOC, this.currentEditPage, lineObj); + } else { + this.ADD_BLANK_LINE({ pageNo: this.currentEditPage, lineObj }); + } + const lineIndex = this.TMP_SCRIPT[this.currentEditPageKey].length - 1; const lineIdent = `page_${this.currentEditPage}_line_${lineIndex}`; this.editPages.push(lineIdent); - this.latestAddedLine = lineIdent; - const prevLine = await this.getPreviousLineForIndex(lineIndex); - if (prevLine != null) { - this.TMP_SCRIPT[this.currentEditPageKey][lineIndex].act_id = prevLine.act_id; - this.TMP_SCRIPT[this.currentEditPageKey][lineIndex].scene_id = prevLine.scene_id; + this._broadcastAwareness(this.currentEditPage, lineIndex); + if (trackAsLatest) { + this.latestAddedLine = lineIdent; } }, + async addNewLine() { + await this.addLineOfType(LINE_TYPES.DIALOGUE, true); + }, async addStageDirection() { - const stageDirectionObject = JSON.parse(JSON.stringify(this.blankLineObj)); - stageDirectionObject.line_type = LINE_TYPES.STAGE_DIRECTION; - this.ADD_BLANK_LINE({ - pageNo: this.currentEditPage, - lineObj: stageDirectionObject, - }); - const lineIndex = this.TMP_SCRIPT[this.currentEditPageKey].length - 1; - this.editPages.push(`page_${this.currentEditPage}_line_${lineIndex}`); - const prevLine = await this.getPreviousLineForIndex(lineIndex); - if (prevLine != null) { - this.TMP_SCRIPT[this.currentEditPageKey][lineIndex].act_id = prevLine.act_id; - this.TMP_SCRIPT[this.currentEditPageKey][lineIndex].scene_id = prevLine.scene_id; - } + await this.addLineOfType(LINE_TYPES.STAGE_DIRECTION); }, async addCueLine() { - const cueLineObject = JSON.parse(JSON.stringify(this.blankLineObj)); - cueLineObject.line_type = LINE_TYPES.CUE_LINE; - cueLineObject.line_parts = []; - this.ADD_BLANK_LINE({ - pageNo: this.currentEditPage, - lineObj: cueLineObject, - }); - const lineIndex = this.TMP_SCRIPT[this.currentEditPageKey].length - 1; - this.editPages.push(`page_${this.currentEditPage}_line_${lineIndex}`); - const prevLine = await this.getPreviousLineForIndex(lineIndex); - if (prevLine != null) { - this.TMP_SCRIPT[this.currentEditPageKey][lineIndex].act_id = prevLine.act_id; - this.TMP_SCRIPT[this.currentEditPageKey][lineIndex].scene_id = prevLine.scene_id; - } + await this.addLineOfType(LINE_TYPES.CUE_LINE); }, async addSpacing() { - const spacingObject = JSON.parse(JSON.stringify(this.blankLineObj)); - spacingObject.line_type = LINE_TYPES.SPACING; - spacingObject.line_parts = []; - this.ADD_BLANK_LINE({ - pageNo: this.currentEditPage, - lineObj: spacingObject, - }); - const lineIndex = this.TMP_SCRIPT[this.currentEditPageKey].length - 1; - this.editPages.push(`page_${this.currentEditPage}_line_${lineIndex}`); - const prevLine = await this.getPreviousLineForIndex(lineIndex); - if (prevLine != null) { - this.TMP_SCRIPT[this.currentEditPageKey][lineIndex].act_id = prevLine.act_id; - this.TMP_SCRIPT[this.currentEditPageKey][lineIndex].scene_id = prevLine.scene_id; - } + await this.addLineOfType(LINE_TYPES.SPACING); }, async getPreviousLineForIndex(lineIndex) { // Search backwards from lineIndex - 1 on the current page, skipping deleted lines @@ -585,6 +606,12 @@ export default { return null; }, lineChange(line, index) { + if (this.IS_DRAFT_ACTIVE && this.DRAFT_YDOC) { + // Y.Doc is source of truth — components wrote directly to Y.Map/Y.Text. + // The deep observer handles TMP_SCRIPT updates synchronously. + return; + } + // Non-collab mode: write to TMP_SCRIPT as before this.SET_LINE({ pageNo: this.currentEditPage, lineIndex: index, @@ -596,6 +623,7 @@ export default { if (index === -1) { this.editPages.push(`page_${pageIndex}_line_${lineIndex}`); } + this._broadcastAwareness(pageIndex, lineIndex); }, doneEditingLine(pageIndex, lineIndex) { const lineIdent = `page_${pageIndex}_line_${lineIndex}`; @@ -603,6 +631,7 @@ export default { if (index !== -1) { this.editPages.splice(index, 1); } + this._broadcastAwareness(pageIndex, null); if (this.latestAddedLine === lineIdent) { this.addNewLine(); } @@ -611,10 +640,11 @@ export default { if (this.latestAddedLine === `page_${pageIndex}_line_${lineIndex}`) { this.latestAddedLine = null; } - this.DELETE_LINE({ - pageNo: pageIndex, - lineIndex, - }); + if (this.IS_DRAFT_ACTIVE && this.DRAFT_YDOC) { + deleteYDocLine(this.DRAFT_YDOC, pageIndex, lineIndex); + } else { + this.DELETE_LINE({ pageNo: pageIndex, lineIndex }); + } this.doneEditingLine(pageIndex, lineIndex); this.editPages.forEach(function updateEditPage(editPage, index) { @@ -663,17 +693,22 @@ export default { const newLineObject = JSON.parse(JSON.stringify(this.blankLineObj)); newLineObject.line_type = lineType; - // CUE_LINE and SPACING types need empty line_parts array - if (lineType === LINE_TYPES.CUE_LINE || lineType === LINE_TYPES.SPACING) { - newLineObject.line_parts = []; + // Inherit act and scene from previous line before inserting + const prevLine = await this.getPreviousLineForIndex(newLineIndex); + if (prevLine) { + newLineObject.act_id = prevLine.act_id; + newLineObject.scene_id = prevLine.scene_id; } - // Insert the blank line - this.INSERT_BLANK_LINE({ - pageNo: this.currentEditPage, - lineIndex: newLineIndex, - lineObj: newLineObject, - }); + if (this.IS_DRAFT_ACTIVE && this.DRAFT_YDOC) { + addYDocLine(this.DRAFT_YDOC, this.currentEditPage, newLineObject, newLineIndex); + } else { + this.INSERT_BLANK_LINE({ + pageNo: this.currentEditPage, + lineIndex: newLineIndex, + lineObj: newLineObject, + }); + } // Update existing edit page indices this.editPages.forEach(function updateEditPage(editPage, index) { @@ -688,13 +723,7 @@ export default { // Add new line to edit pages const lineIdent = `page_${this.currentEditPage}_line_${newLineIndex}`; this.editPages.push(lineIdent); - - // Inherit act and scene from previous line - const prevLine = await this.getPreviousLineForIndex(newLineIndex); - if (prevLine != null) { - this.TMP_SCRIPT[this.currentEditPageKey][newLineIndex].act_id = prevLine.act_id; - this.TMP_SCRIPT[this.currentEditPageKey][newLineIndex].scene_id = prevLine.scene_id; - } + this._broadcastAwareness(this.currentEditPage, newLineIndex); }, async insertDialogueAt(pageIndex, lineIndex) { await this.insertLineAt(pageIndex, lineIndex, LINE_TYPES.DIALOGUE); @@ -799,6 +828,11 @@ export default { this.ADD_BLANK_PAGE(this.currentEditPage); } await this.LOAD_SCRIPT_PAGE(parseInt(pageNo, 10) + 1); + + // If Y.Doc is synced, overlay collaborative data onto loaded pages + if (this.IS_DRAFT_SYNCED && this.DRAFT_YDOC) { + this.syncCurrentPageFromYDoc(); + } }, setupAutoSave() { const autoSaveInterval = Math.max( @@ -911,6 +945,116 @@ export default { } this.isAutoSaving = false; }, + /** + * Set up the Y.Doc → TMP_SCRIPT bridge after initial sync completes. + * Installs a deep observer on the Y.Doc pages map that updates + * TMP_SCRIPT whenever Y.Doc changes (local or remote). + * + * Components write directly to Y.Map/Y.Text, and this observer + * keeps the TMP_SCRIPT view cache in sync for ScriptLineViewer rendering. + */ + setupYDocBridge() { + const ydoc = this.DRAFT_YDOC; + if (!ydoc) return; + + const pages = ydoc.getMap('pages'); + + // Sync the current page from Y.Doc → TMP_SCRIPT on initial connect + this.syncCurrentPageFromYDoc(); + + // Observe deep changes on the pages map — all origins flow through + const observer = (events) => { + // Determine which pages were affected + const affectedPages = new Set(); + events.forEach((event) => { + const path = event.path; + if (path.length >= 1) { + affectedPages.add(path[0].toString()); + } else { + // Top-level pages map changed — sync all loaded pages + Object.keys(this.TMP_SCRIPT).forEach((p) => affectedPages.add(p)); + } + }); + + // Sync affected pages that are currently loaded + affectedPages.forEach((pageKey) => { + if (Object.keys(this.TMP_SCRIPT).includes(pageKey)) { + const lines = syncPageFromYDoc(ydoc, pageKey); + this.$store.commit('ADD_PAGE', { pageNo: pageKey, pageContents: lines }); + } + }); + }; + + pages.observeDeep(observer); + this.ydocObserverCleanup = () => pages.unobserveDeep(observer); + + log.info('ScriptEditor: Y.Doc bridge established'); + }, + /** + * Remove the Y.Doc observer. + */ + teardownYDocBridge() { + if (this.ydocObserverCleanup) { + this.ydocObserverCleanup(); + this.ydocObserverCleanup = null; + } + }, + /** + * Sync all currently loaded TMP_SCRIPT pages from Y.Doc data. + */ + syncCurrentPageFromYDoc() { + const ydoc = this.DRAFT_YDOC; + if (!ydoc) return; + + Object.keys(this.TMP_SCRIPT).forEach((pageKey) => { + const lines = syncPageFromYDoc(ydoc, pageKey); + if (lines.length > 0) { + this.$store.commit('ADD_PAGE', { pageNo: pageKey, pageContents: lines }); + } + }); + }, + /** + * Get the Y.Map for a specific line from the Y.Doc. + * Returns null when not in collab mode or if the line doesn't exist. + * @param {number} index - Line index on the current page + * @returns {import('yjs').Map|null} + */ + getYLineMap(index) { + if (!this.IS_DRAFT_ACTIVE || !this.DRAFT_YDOC) return null; + const pages = this.DRAFT_YDOC.getMap('pages'); + const pageArray = pages.get(this.currentEditPageKey); + if (!pageArray || index >= pageArray.length) { + return null; + } + return pageArray.get(index); + }, + /** + * Broadcast awareness state (which line the user is editing). + * @param {number} page - The page number + * @param {number|null} lineIndex - The line index, or null if no line is expanded + */ + _broadcastAwareness(page, lineIndex) { + if (!this.DRAFT_PROVIDER) return; + const user = this.CURRENT_USER; + this.DRAFT_PROVIDER.setLocalAwareness({ + userId: user ? user.id : null, + username: user ? user.username : 'Unknown', + page, + lineIndex, + }); + }, + /** + * Get the list of other users editing a specific line. + * @param {number} lineIndex - The line index on the current page + * @returns {Array<{userId: number, username: string}>} + */ + editingUsersForLine(lineIndex) { + const key = `${this.currentEditPage}:${lineIndex}`; + const editors = this.DRAFT_LINE_EDITORS[key] || []; + // Exclude current user + const currentUserId = this.CURRENT_USER ? this.CURRENT_USER.id : null; + return editors.filter((e) => e.userId !== currentUserId); + }, calculateNavbarHeight() { const navbar = document.querySelector('.navbar'); if (navbar) { @@ -947,6 +1091,9 @@ export default { 'GET_STAGE_DIRECTION_STYLE_OVERRIDES', 'GET_CUE_COLOUR_OVERRIDES', 'GET_USER_SETTINGS', + 'GET_SCRIPT_REVISIONS', + 'JOIN_DRAFT_ROOM', + 'LEAVE_DRAFT_ROOM', ]), }, }; diff --git a/client/src/vue_components/show/config/script/ScriptLineEditor.vue b/client/src/vue_components/show/config/script/ScriptLineEditor.vue index b06f429b..a67d3a51 100644 --- a/client/src/vue_components/show/config/script/ScriptLineEditor.vue +++ b/client/src/vue_components/show/config/script/ScriptLineEditor.vue @@ -44,6 +44,7 @@ v-for="(part, index) in state.line_parts" :key="`line_${lineIndex}_part_${index}`" v-model="$v.state.line_parts.$model[index]" + :y-part-map="getYPartMap(index)" :focus-input="index === 0" :characters="characters" :character-groups="characterGroups" @@ -98,11 +99,13 @@