diff --git a/.idea/DigiScript-2.iml b/.idea/DigiScript-2.iml index e036e0f0..4d04d861 100644 --- a/.idea/DigiScript-2.iml +++ b/.idea/DigiScript-2.iml @@ -20,6 +20,7 @@ + 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 091e524d..ee8af47e 100644 --- a/client/src/main.js +++ b/client/src/main.js @@ -15,6 +15,9 @@ import { getWebSocketURL, isElectron } from '@/js/platform'; import { initRemoteLogging } from '@/js/logger'; import log from 'loglevel'; +// Expose loglevel globally so `log.setLevel('debug')` works in DevTools +window.log = log; + import './assets/styles/dark.scss'; import 'vue-toast-notification/dist/theme-sugar.css'; import 'vue-multiselect/dist/vue-multiselect.min.css'; diff --git a/client/src/store/modules/script.js b/client/src/store/modules/script.js index 7cfe85f9..81d3b307 100644 --- a/client/src/store/modules/script.js +++ b/client/src/store/modules/script.js @@ -73,8 +73,9 @@ export default { context.dispatch('GET_SCRIPT_REVISIONS'); Vue.$toast.success('Added new script revision!'); } else { + const data = await response.json().catch(() => ({})); log.error('Unable to add new script revision'); - Vue.$toast.error('Unable to add new script revision'); + Vue.$toast.error(data.message || 'Unable to add new script revision'); } }, async DELETE_SCRIPT_REVISION(context, revisionID) { @@ -91,8 +92,9 @@ export default { context.dispatch('GET_SCRIPT_REVISIONS'); Vue.$toast.success('Deleted script revision!'); } else { + const data = await response.json().catch(() => ({})); log.error('Unable to delete script revision'); - Vue.$toast.error('Unable to delete script revision'); + Vue.$toast.error(data.message || 'Unable to delete script revision'); } }, async LOAD_SCRIPT_REVISION(context, revisionID) { @@ -109,8 +111,9 @@ export default { context.dispatch('GET_SCRIPT_REVISIONS'); Vue.$toast.success('Loaded script revision!'); } else { + const data = await response.json().catch(() => ({})); log.error('Unable to load script revision'); - Vue.$toast.error('Unable to load script revision'); + Vue.$toast.error(data.message || 'Unable to load script revision'); } }, async SCRIPT_REVISION_CHANGED(context) { diff --git a/client/src/store/modules/scriptConfig.js b/client/src/store/modules/scriptConfig.js index 0ca3adc5..816a8c25 100644 --- a/client/src/store/modules/scriptConfig.js +++ b/client/src/store/modules/scriptConfig.js @@ -9,8 +9,9 @@ export default { tmpScript: {}, deletedLines: {}, editStatus: { - canRequestEdit: false, - currentEditor: null, + editors: [], + cutters: [], + hasDraft: false, }, cutMode: false, insertedLines: {}, @@ -93,8 +94,9 @@ export default { }, }, actions: { - REQUEST_EDIT_FAILURE(context) { - Vue.$toast.error('Unable to edit script'); + REQUEST_EDIT_FAILURE(context, message) { + const reason = message?.DATA?.reason || 'Unable to edit script'; + Vue.$toast.error(reason); context.dispatch('GET_SCRIPT_CONFIG_STATUS'); context.commit('SET_CUT_MODE', false); }, @@ -195,11 +197,34 @@ export default { ALL_DELETED_LINES(state) { return state.deletedLines; }, - CAN_REQUEST_EDIT(state) { - return state.editStatus.canRequestEdit; + EDITORS(state) { + return state.editStatus.editors; }, - CURRENT_EDITOR(state) { - return state.editStatus.currentEditor; + CUTTERS(state) { + return state.editStatus.cutters; + }, + HAS_DRAFT(state) { + return state.editStatus.hasDraft; + }, + CAN_REQUEST_EDIT(state, getters, rootState, rootGetters) { + if (rootGetters.CURRENT_SHOW_SESSION) return false; + return state.editStatus.cutters.length === 0; + }, + CAN_REQUEST_CUTS(state, getters, rootState, rootGetters) { + if (rootGetters.CURRENT_SHOW_SESSION) return false; + return ( + state.editStatus.editors.length === 0 && + state.editStatus.cutters.length === 0 && + !state.editStatus.hasDraft + ); + }, + IS_CURRENT_EDITOR: (state, getters, rootState, rootGetters) => { + const uuid = rootGetters.INTERNAL_UUID; + return state.editStatus.editors.some((e) => e.internal_id === uuid); + }, + IS_CURRENT_CUTTER: (state, getters, rootState, rootGetters) => { + const uuid = rootGetters.INTERNAL_UUID; + return state.editStatus.cutters.some((c) => c.internal_id === uuid); }, IS_CUT_MODE(state) { return state.cutMode; diff --git a/client/src/store/modules/scriptConfig.test.js b/client/src/store/modules/scriptConfig.test.js new file mode 100644 index 00000000..a0c00e62 --- /dev/null +++ b/client/src/store/modules/scriptConfig.test.js @@ -0,0 +1,98 @@ +import { vi } from 'vitest'; + +// Stub external dependencies before importing scriptConfig +vi.mock('vue', () => ({ + default: { prototype: {}, set: vi.fn(), delete: vi.fn() }, + set: vi.fn(), + delete: vi.fn(), +})); +vi.mock('loglevel', () => ({ + default: { debug: vi.fn(), info: vi.fn(), error: vi.fn(), warn: vi.fn() }, +})); +vi.mock('deep-object-diff', () => ({ + detailedDiff: vi.fn(() => ({ added: {}, updated: {}, deleted: {} })), +})); +vi.mock('@/js/utils', () => ({ + makeURL: vi.fn((path) => `http://localhost${path}`), +})); + +import scriptConfigModule from './scriptConfig'; + +const { getters } = scriptConfigModule; + +function makeState(overrides = {}) { + return { + editStatus: { editors: [], cutters: [], hasDraft: false }, + ...overrides, + }; +} + +describe('scriptConfig getters', () => { + describe('CAN_REQUEST_EDIT', () => { + it('returns false when a live session is active (no cutters)', () => { + const state = makeState(); + const result = getters.CAN_REQUEST_EDIT(state, {}, {}, { CURRENT_SHOW_SESSION: { id: 1 } }); + expect(result).toBe(false); + }); + + it('returns false when live session is active even if cutters also present', () => { + const state = makeState({ + editStatus: { editors: [], cutters: [{ internal_id: 'x' }], hasDraft: false }, + }); + const result = getters.CAN_REQUEST_EDIT(state, {}, {}, { CURRENT_SHOW_SESSION: { id: 1 } }); + expect(result).toBe(false); + }); + + it('returns true when no live session and no cutters', () => { + const state = makeState(); + const result = getters.CAN_REQUEST_EDIT(state, {}, {}, { CURRENT_SHOW_SESSION: null }); + expect(result).toBe(true); + }); + + it('returns false when no live session but cutters exist', () => { + const state = makeState({ + editStatus: { editors: [], cutters: [{ internal_id: 'x' }], hasDraft: false }, + }); + const result = getters.CAN_REQUEST_EDIT(state, {}, {}, { CURRENT_SHOW_SESSION: null }); + expect(result).toBe(false); + }); + }); + + describe('CAN_REQUEST_CUTS', () => { + it('returns false when a live session is active (all else clear)', () => { + const state = makeState(); + const result = getters.CAN_REQUEST_CUTS(state, {}, {}, { CURRENT_SHOW_SESSION: { id: 1 } }); + expect(result).toBe(false); + }); + + it('returns true when no live session and all else clear', () => { + const state = makeState(); + const result = getters.CAN_REQUEST_CUTS(state, {}, {}, { CURRENT_SHOW_SESSION: null }); + expect(result).toBe(true); + }); + + it('returns false when no live session but an editor exists', () => { + const state = makeState({ + editStatus: { editors: [{ internal_id: 'y' }], cutters: [], hasDraft: false }, + }); + const result = getters.CAN_REQUEST_CUTS(state, {}, {}, { CURRENT_SHOW_SESSION: null }); + expect(result).toBe(false); + }); + + it('returns false when no live session but a cutter exists', () => { + const state = makeState({ + editStatus: { editors: [], cutters: [{ internal_id: 'z' }], hasDraft: false }, + }); + const result = getters.CAN_REQUEST_CUTS(state, {}, {}, { CURRENT_SHOW_SESSION: null }); + expect(result).toBe(false); + }); + + it('returns false when no live session but a draft exists', () => { + const state = makeState({ + editStatus: { editors: [], cutters: [], hasDraft: true }, + }); + const result = getters.CAN_REQUEST_CUTS(state, {}, {}, { CURRENT_SHOW_SESSION: null }); + expect(result).toBe(false); + }); + }); +}); diff --git a/client/src/store/modules/scriptDraft.js b/client/src/store/modules/scriptDraft.js new file mode 100644 index 00000000..4d8c110a --- /dev/null +++ b/client/src/store/modules/scriptDraft.js @@ -0,0 +1,451 @@ +/** + * 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; + +/** @type {number|null} Interval ID for the sync-polling loop */ +let _syncIntervalId = null; + +/** @type {number|null} Timeout ID for the sync-failure watchdog */ +let _syncTimeoutId = 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: {}, + + /** @type {boolean} Whether a save is currently in progress */ + isSaving: false, + + /** @type {string|null} Current save phase ('validating', 'persisting', 'finalizing') */ + savePhase: null, + + /** @type {object|null} Last save error */ + saveError: null, + + /** @type {number} Pages saved so far during the current save (0 = starting) */ + savePage: 0, + + /** @type {number} Total pages to save in the current save operation */ + saveTotalPages: 0, + }, + + 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); + }, + + SET_DRAFT_SAVING(state, value) { + state.isSaving = value; + }, + + SET_DRAFT_SAVE_PHASE(state, phase) { + state.savePhase = phase; + }, + + SET_DRAFT_SAVE_ERROR(state, error) { + state.saveError = error; + }, + + SET_SAVE_PROGRESS(state, { page, total }) { + state.savePage = page; + state.saveTotalPages = total; + }, + + CLEAR_DRAFT_STATE(state) { + state.roomId = null; + state.isConnected = false; + state.isSynced = false; + state.isDraft = false; + state.lastSavedAt = null; + state.collaborators = []; + state.awarenessStates = {}; + state.isSaving = false; + state.savePhase = null; + state.saveError = null; + state.savePage = 0; + state.saveTotalPages = 0; + _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 }); + + // Cancel any stale timers from a previous join before creating new ones + if (_syncIntervalId) { + clearInterval(_syncIntervalId); + _syncIntervalId = null; + } + if (_syncTimeoutId) { + clearTimeout(_syncTimeoutId); + _syncTimeoutId = null; + } + + // Listen for sync completion + _syncIntervalId = setInterval(() => { + if (provider.synced) { + log.debug('ScriptDraft: Sync detected via polling; clearing timer'); + context.commit('SET_DRAFT_SYNCED', true); + context.commit('SET_DRAFT_CONNECTED', true); + clearInterval(_syncIntervalId); + _syncIntervalId = null; + } + }, 100); + + // Watchdog: log an error only if this is still the active provider + _syncTimeoutId = setTimeout(() => { + clearInterval(_syncIntervalId); + _syncIntervalId = null; + _syncTimeoutId = null; + log.debug( + `ScriptDraft: Sync timeout fired (provider is ${provider === _provider ? 'current' : 'stale'}); synced=${provider.synced}` + ); + if (provider === _provider && !provider.synced) { + log.error('ScriptDraft: Sync timeout after 10 seconds'); + } + }, 10000); + + provider.connect(); + log.debug(`ScriptDraft: Provider connect() called for revision ${revisionId}`); + log.info(`ScriptDraft: Joined room for revision ${revisionId} as ${role}`); + }, + + /** + * Leave the current collaborative editing room. + */ + async LEAVE_DRAFT_ROOM(context) { + log.debug( + `ScriptDraft: Cancelling sync timers (interval=${_syncIntervalId}, timeout=${_syncTimeoutId})` + ); + if (_syncIntervalId) { + clearInterval(_syncIntervalId); + _syncIntervalId = null; + } + if (_syncTimeoutId) { + clearTimeout(_syncTimeoutId); + _syncTimeoutId = null; + } + + if (_provider) { + _provider.destroy(); + } + + context.commit('CLEAR_DRAFT_STATE'); + log.info('ScriptDraft: Left draft room'); + }, + + /** + * Handle a YJS_SYNC message: apply to provider, update synced state. + * + * @param {object} context + * @param {object} message - The WebSocket message + * @returns {boolean} Whether the message was handled + */ + YJS_SYNC(context, message) { + if (!_provider) return false; + const handled = _provider.applySync(message.DATA); + if (handled && _provider.synced && !context.state.isSynced) { + context.commit('SET_DRAFT_SYNCED', true); + context.commit('SET_DRAFT_CONNECTED', true); + } + return handled; + }, + + /** + * Handle a YJS_UPDATE message: apply remote doc update to provider. + * + * @param {object} context + * @param {object} message - The WebSocket message + * @returns {boolean} Whether the message was handled + */ + YJS_UPDATE(context, message) { + if (!_provider) return false; + return _provider.applyUpdate(message.DATA); + }, + + /** + * Handle a YJS_AWARENESS message: decode and commit awareness state. + * + * @param {object} context + * @param {object} message - The WebSocket message + * @returns {boolean|object} Whether the message was handled + */ + YJS_AWARENESS(context, message) { + if (!_provider) return false; + const handled = _provider.applyAwareness(message.DATA); + if (handled && typeof handled === 'object' && handled.type === 'AWARENESS') { + const state = handled.state; + if (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; + }, + + /** + * Handle a ROOM_MEMBERS message: update collaborator list. + * + * @param {object} context + * @param {object} message - The WebSocket message + */ + ROOM_MEMBERS(context, message) { + context.commit('SET_DRAFT_COLLABORATORS', message.DATA?.members || []); + }, + + /** + * Handle a ROOM_CLOSED message: leave the draft room. + * + * @param {object} context + */ + ROOM_CLOSED(context) { + context.dispatch('LEAVE_DRAFT_ROOM'); + }, + + /** + * Handle a SCRIPT_SAVED message: clear saving state and record timestamp. + * + * @param {object} context + * @param {object} message - The WebSocket message + */ + SCRIPT_SAVED(context, message) { + context.commit('SET_DRAFT_SAVING', false); + context.commit('SET_DRAFT_SAVE_PHASE', null); + context.commit('SET_DRAFT_SAVE_ERROR', null); + const timestamp = message.DATA?.last_saved_at; + if (timestamp) context.commit('SET_DRAFT_LAST_SAVED', timestamp); + }, + + /** + * Handle a SAVE_PROGRESS message: update save progress state. + * + * @param {object} context + * @param {object} message - The WebSocket message + */ + SAVE_PROGRESS(context, message) { + context.commit('SET_DRAFT_SAVING', true); + context.commit('SET_SAVE_PROGRESS', message.DATA); + }, + + /** + * Handle a SAVE_ERROR message: clear saving state and record error. + * + * @param {object} context + * @param {object} message - The WebSocket message + */ + SAVE_ERROR(context, message) { + context.commit('SET_DRAFT_SAVING', false); + context.commit('SET_DRAFT_SAVE_PHASE', null); + context.commit('SET_DRAFT_SAVE_ERROR', message.DATA?.error); + }, + + /** + * Handle a COLLAB_ERROR message: log the error from the server. + * + * @param {object} _context + * @param {object} message - The WebSocket message + */ + COLLAB_ERROR(_context, message) { + log.error('Collab error received:', message.DATA?.error); + }, + }, + + 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) + * + * NOTE: `state.roomId` is accessed intentionally to create a reactive + * dependency. Without it, Vue/Vuex caches this getter permanently (since + * `_ydoc` is a non-reactive module variable). After LEAVE_DRAFT_ROOM + + * JOIN_DRAFT_ROOM, `roomId` changes, busting the cache so the new Y.Doc + * instance is returned to all components. + */ + DRAFT_YDOC(state) { + state.roomId; // reactive dependency — forces re-evaluation on room change + return _ydoc; + }, + + /** @returns {ScriptDocProvider|null} The provider instance (non-reactive) */ + DRAFT_PROVIDER(state) { + state.roomId; // reactive dependency — see DRAFT_YDOC comment + return _provider; + }, + + /** @returns {import('yjs').Map|null} The Y.Doc pages map */ + DRAFT_PAGES(state) { + state.roomId; // reactive dependency — see DRAFT_YDOC comment + if (!_ydoc) return null; + return _ydoc.getMap('pages'); + }, + + /** @returns {import('yjs').Map|null} The Y.Doc meta map */ + DRAFT_META(state) { + state.roomId; // reactive dependency — see DRAFT_YDOC comment + if (!_ydoc) return null; + return _ydoc.getMap('meta'); + }, + + /** @returns {import('yjs').Array|null} The deleted line IDs array */ + DRAFT_DELETED_LINE_IDS(state) { + state.roomId; // reactive dependency — see DRAFT_YDOC comment + if (!_ydoc) return null; + return _ydoc.getArray('deleted_line_ids'); + }, + + /** @returns {boolean} Whether a save is in progress */ + IS_DRAFT_SAVING(state) { + return state.isSaving; + }, + + /** @returns {string|null} Current save phase */ + DRAFT_SAVE_PHASE(state) { + return state.savePhase; + }, + + /** @returns {{page: number, total: number}} Current save progress */ + DRAFT_SAVE_PROGRESS(state) { + return { page: state.savePage, total: state.saveTotalPages }; + }, + + /** @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/scriptDraft.test.js b/client/src/store/modules/scriptDraft.test.js new file mode 100644 index 00000000..f3a1ef72 --- /dev/null +++ b/client/src/store/modules/scriptDraft.test.js @@ -0,0 +1,156 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +// Stub external dependencies before importing scriptDraft +vi.mock('vue', () => ({ + default: { prototype: {}, set: vi.fn(), delete: vi.fn() }, + set: vi.fn(), + delete: vi.fn(), +})); +vi.mock('loglevel', () => ({ + default: { debug: vi.fn(), info: vi.fn(), error: vi.fn(), warn: vi.fn() }, +})); +vi.mock('yjs', () => ({ + Doc: vi.fn(() => ({ + on: vi.fn(), + off: vi.fn(), + getMap: vi.fn(() => ({ set: vi.fn() })), + getArray: vi.fn(() => []), + })), +})); +vi.mock('@/utils/yjs/ScriptDocProvider', () => ({ + default: vi.fn(), +})); + +import log from 'loglevel'; +import scriptDraftModule from './scriptDraft'; + +const { actions } = scriptDraftModule; + +/** + * Create a minimal mock Vuex action context. + */ +function makeContext(stateOverrides = {}) { + const commits = []; + const dispatches = []; + return { + state: { isSynced: false, roomId: null, ...stateOverrides }, + commit: vi.fn((type, payload) => commits.push({ type, payload })), + dispatch: vi.fn((type, payload) => dispatches.push({ type, payload })), + _commits: commits, + _dispatches: dispatches, + }; +} + +describe('scriptDraft Vuex actions', () => { + describe('ROOM_MEMBERS', () => { + it('commits SET_DRAFT_COLLABORATORS with members from DATA', () => { + const context = makeContext(); + const members = [{ user_id: 1, username: 'alice', role: 'editor' }]; + actions.ROOM_MEMBERS(context, { DATA: { members } }); + expect(context.commit).toHaveBeenCalledWith('SET_DRAFT_COLLABORATORS', members); + }); + + it('commits empty array when DATA.members is missing', () => { + const context = makeContext(); + actions.ROOM_MEMBERS(context, { DATA: {} }); + expect(context.commit).toHaveBeenCalledWith('SET_DRAFT_COLLABORATORS', []); + }); + + it('commits empty array when DATA is missing', () => { + const context = makeContext(); + actions.ROOM_MEMBERS(context, {}); + expect(context.commit).toHaveBeenCalledWith('SET_DRAFT_COLLABORATORS', []); + }); + }); + + describe('ROOM_CLOSED', () => { + it('dispatches LEAVE_DRAFT_ROOM', () => { + const context = makeContext(); + actions.ROOM_CLOSED(context); + expect(context.dispatch).toHaveBeenCalledWith('LEAVE_DRAFT_ROOM'); + }); + }); + + describe('SCRIPT_SAVED', () => { + it('clears saving state and commits timestamp', () => { + const context = makeContext(); + const now = '2026-02-25T12:00:00Z'; + actions.SCRIPT_SAVED(context, { DATA: { last_saved_at: now } }); + expect(context.commit).toHaveBeenCalledWith('SET_DRAFT_SAVING', false); + expect(context.commit).toHaveBeenCalledWith('SET_DRAFT_SAVE_PHASE', null); + expect(context.commit).toHaveBeenCalledWith('SET_DRAFT_SAVE_ERROR', null); + expect(context.commit).toHaveBeenCalledWith('SET_DRAFT_LAST_SAVED', now); + }); + + it('does not commit timestamp when last_saved_at is absent', () => { + const context = makeContext(); + actions.SCRIPT_SAVED(context, { DATA: {} }); + expect(context.commit).not.toHaveBeenCalledWith('SET_DRAFT_LAST_SAVED', expect.anything()); + }); + }); + + describe('SAVE_PROGRESS', () => { + it('sets saving true and commits progress data', () => { + const context = makeContext(); + const data = { page: 1, total: 5, percent: 20 }; + actions.SAVE_PROGRESS(context, { DATA: data }); + expect(context.commit).toHaveBeenCalledWith('SET_DRAFT_SAVING', true); + expect(context.commit).toHaveBeenCalledWith('SET_SAVE_PROGRESS', data); + }); + }); + + describe('SAVE_ERROR', () => { + it('clears saving state and commits error message', () => { + const context = makeContext(); + actions.SAVE_ERROR(context, { DATA: { error: 'Something went wrong' } }); + expect(context.commit).toHaveBeenCalledWith('SET_DRAFT_SAVING', false); + expect(context.commit).toHaveBeenCalledWith('SET_DRAFT_SAVE_PHASE', null); + expect(context.commit).toHaveBeenCalledWith('SET_DRAFT_SAVE_ERROR', 'Something went wrong'); + }); + + it('commits undefined error when DATA is missing', () => { + const context = makeContext(); + actions.SAVE_ERROR(context, {}); + expect(context.commit).toHaveBeenCalledWith('SET_DRAFT_SAVE_ERROR', undefined); + }); + }); + + describe('COLLAB_ERROR', () => { + it('logs error and does not throw', () => { + const context = makeContext(); + expect(() => actions.COLLAB_ERROR(context, { DATA: { error: 'Room full' } })).not.toThrow(); + expect(log.error).toHaveBeenCalled(); + }); + + it('handles missing DATA without throwing', () => { + const context = makeContext(); + expect(() => actions.COLLAB_ERROR(context, {})).not.toThrow(); + }); + }); + + describe('YJS_SYNC (no provider)', () => { + it('returns false when no provider is active', () => { + const context = makeContext(); + const result = actions.YJS_SYNC(context, { DATA: { step: 0 } }); + expect(result).toBe(false); + expect(context.commit).not.toHaveBeenCalled(); + }); + }); + + describe('YJS_UPDATE (no provider)', () => { + it('returns false when no provider is active', () => { + const context = makeContext(); + const result = actions.YJS_UPDATE(context, { DATA: { payload: 'dA==' } }); + expect(result).toBe(false); + }); + }); + + describe('YJS_AWARENESS (no provider)', () => { + it('returns false when no provider is active', () => { + const context = makeContext(); + const result = actions.YJS_AWARENESS(context, { DATA: { payload: 'dA==' } }); + expect(result).toBe(false); + expect(context.commit).not.toHaveBeenCalled(); + }); + }); +}); 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..0e208e5a --- /dev/null +++ b/client/src/utils/yjs/ScriptDocProvider.js @@ -0,0 +1,299 @@ +/** + * 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; + } + + /** + * Apply a YJS_SYNC message from the server. + * Accepts sync messages even before the room is marked connected, + * since step 0 is what triggers the connected state. + * + * @param {object} data - The DATA payload from the server message + * @returns {boolean} true if handled, false if filtered + */ + applySync(data) { + if (data.room_id && data.room_id !== this.roomId) return false; + const payload = data.payload; + if (!payload) return false; + + try { + const decoded = decodeBase64(payload); + + if (data.step === 0) { + // Initial full state from server + log.debug( + `ScriptDocProvider: Received step 0 (${decoded.length} bytes); applying full state` + ); + 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 + log.debug(`ScriptDocProvider: Received step 2 diff (${decoded.length} bytes); applied`); + Y.applyUpdate(this.doc, decoded, 'server'); + } + } catch (e) { + log.error('ScriptDocProvider: Failed to handle sync message', e); + } + + return true; + } + + /** + * Apply a YJS_UPDATE message from the server (other clients' changes). + * Requires the room to be connected; filters mismatched room IDs. + * + * @param {object} data - The DATA payload from the server message + * @returns {boolean} true if handled, false if filtered + */ + applyUpdate(data) { + if (!this._connected) return false; + if (data.room_id && data.room_id !== this.roomId) return false; + const payload = data.payload; + if (!payload) return false; + + try { + const decoded = decodeBase64(payload); + log.debug(`ScriptDocProvider: Applied remote update (${decoded.length} bytes)`); + Y.applyUpdate(this.doc, decoded, 'server'); + } catch (e) { + log.error('ScriptDocProvider: Failed to apply update', e); + } + + return true; + } + + /** + * Apply a YJS_AWARENESS message from the server. + * Requires the room to be connected; filters mismatched room IDs. + * Returns the decoded awareness state object for the Vuex store to process. + * + * @param {object} data - The DATA payload from the server message + * @returns {object|boolean} awareness result object, true (no payload), or false (filtered) + */ + applyAwareness(data) { + if (!this._connected) return false; + if (data.room_id && data.room_id !== this.roomId) return false; + 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) { + log.debug(`ScriptDocProvider: _onDocUpdate suppressed (not connected, origin=${origin})`); + return; + } + if (!this._synced) { + log.debug(`ScriptDocProvider: _onDocUpdate suppressed (not yet synced, origin=${origin})`); + return; + } + + log.debug(`ScriptDocProvider: _onDocUpdate sending ${update.length}B (origin=${origin})`); + 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/ScriptDocProvider.test.js b/client/src/utils/yjs/ScriptDocProvider.test.js new file mode 100644 index 00000000..eeaded71 --- /dev/null +++ b/client/src/utils/yjs/ScriptDocProvider.test.js @@ -0,0 +1,180 @@ +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import * as Y from 'yjs'; +import ScriptDocProvider, { encodeBase64, decodeBase64 } from './ScriptDocProvider'; + +// ScriptDocProvider uses Vue.prototype.$socket — stub it so the module loads +vi.mock('vue', () => ({ + default: { prototype: {} }, +})); + +function makeDoc() { + return new Y.Doc(); +} + +function makeProvider(revisionId = 1) { + const doc = makeDoc(); + const provider = new ScriptDocProvider(doc, revisionId); + return { provider, doc }; +} + +function encodedUpdate(doc) { + return encodeBase64(Y.encodeStateAsUpdate(doc)); +} + +function encodedStateVector(doc) { + return encodeBase64(Y.encodeStateVector(doc)); +} + +function encodedAwareness(state) { + return encodeBase64(new TextEncoder().encode(JSON.stringify(state))); +} + +describe('ScriptDocProvider', () => { + describe('applySync()', () => { + it('returns false when room_id does not match', () => { + const { provider } = makeProvider(1); + const result = provider.applySync({ + room_id: 'draft_99', + step: 0, + payload: 'dA==', + }); + expect(result).toBe(false); + }); + + it('returns false when payload is missing', () => { + const { provider } = makeProvider(1); + const result = provider.applySync({ step: 0 }); + expect(result).toBe(false); + }); + + it('step 0: applies full state, sets _synced, returns true', () => { + const { provider, doc } = makeProvider(1); + const sourceDoc = makeDoc(); + const meta = sourceDoc.getMap('meta'); + meta.set('revision_id', 42); + + const result = provider.applySync({ + room_id: 'draft_1', + step: 0, + payload: encodedUpdate(sourceDoc), + }); + + expect(result).toBe(true); + expect(provider.synced).toBe(true); + expect(doc.getMap('meta').get('revision_id')).toBe(42); + }); + + it('step 0: accepts message without room_id (no filter)', () => { + const { provider } = makeProvider(1); + const sourceDoc = makeDoc(); + const result = provider.applySync({ + step: 0, + payload: encodedUpdate(sourceDoc), + }); + expect(result).toBe(true); + }); + + it('step 2: applies diff, returns true', () => { + const { provider, doc } = makeProvider(1); + + // First sync step 0 to get a baseline + const sourceDoc = makeDoc(); + provider.applySync({ step: 0, payload: encodedUpdate(sourceDoc) }); + + // Now produce a diff and apply as step 2 + const sv = Y.encodeStateVector(doc); + sourceDoc.getMap('meta').set('new_key', 'new_val'); + const diff = Y.encodeStateAsUpdate(sourceDoc, sv); + + const result = provider.applySync({ + room_id: 'draft_1', + step: 2, + payload: encodeBase64(diff), + }); + + expect(result).toBe(true); + expect(doc.getMap('meta').get('new_key')).toBe('new_val'); + }); + }); + + describe('applyUpdate()', () => { + it('returns false when not connected', () => { + const { provider } = makeProvider(1); + provider._connected = false; + expect(provider.applyUpdate({ payload: 'dA==' })).toBe(false); + }); + + it('returns false when room_id does not match', () => { + const { provider } = makeProvider(1); + provider._connected = true; + expect(provider.applyUpdate({ room_id: 'draft_99', payload: 'dA==' })).toBe(false); + }); + + it('returns false when payload is missing', () => { + const { provider } = makeProvider(1); + provider._connected = true; + expect(provider.applyUpdate({})).toBe(false); + }); + + it('applies update to doc and returns true', () => { + const { provider, doc } = makeProvider(1); + provider._connected = true; + + const sourceDoc = makeDoc(); + const sv = Y.encodeStateVector(doc); + sourceDoc.getMap('data').set('key', 'value'); + const update = Y.encodeStateAsUpdate(sourceDoc, sv); + + const result = provider.applyUpdate({ + room_id: 'draft_1', + payload: encodeBase64(update), + }); + + expect(result).toBe(true); + expect(doc.getMap('data').get('key')).toBe('value'); + }); + }); + + describe('applyAwareness()', () => { + it('returns false when not connected', () => { + const { provider } = makeProvider(1); + provider._connected = false; + expect(provider.applyAwareness({ payload: 'dA==' })).toBe(false); + }); + + it('returns false when room_id does not match', () => { + const { provider } = makeProvider(1); + provider._connected = true; + expect(provider.applyAwareness({ room_id: 'draft_99', payload: 'dA==' })).toBe(false); + }); + + it('returns true when payload is missing', () => { + const { provider } = makeProvider(1); + provider._connected = true; + expect(provider.applyAwareness({})).toBe(true); + }); + + it('decodes payload and returns AWARENESS result', () => { + const { provider } = makeProvider(1); + provider._connected = true; + + const state = { userId: 7, username: 'alice', page: 2, lineIndex: 5 }; + const result = provider.applyAwareness({ + room_id: 'draft_1', + payload: encodedAwareness(state), + }); + + expect(result).toEqual({ type: 'AWARENESS', state }); + }); + + it('handles message without room_id', () => { + const { provider } = makeProvider(1); + provider._connected = true; + + const state = { userId: 3, page: null, lineIndex: null }; + const result = provider.applyAwareness({ payload: encodedAwareness(state) }); + + expect(result).toEqual({ type: 'AWARENESS', state }); + }); + }); +}); 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..5755f81f --- /dev/null +++ b/client/src/utils/yjs/yjsBridge.js @@ -0,0 +1,183 @@ +/** + * 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'; +import { uuidv4 } from 'lib0/random'; + +/** + * 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', lineObj.id ? String(lineObj.id) : uuidv4()); + 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', part.id ? String(part.id) : uuidv4()); + 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(() => { + // If line has a real DB id (not a UUID), record it for backend deletion. + // Use a strict all-digits test rather than parseInt — parseInt('3f1e…', 10) + // returns 3, which would falsely classify UUIDs starting with a digit as DB ids. + const lineMap = pageArray.get(lineIndex); + if (lineMap) { + const rawId = String(lineMap.get('_id') ?? ''); + if (/^\d+$/.test(rawId)) { + const dbId = parseInt(rawId, 10); + if (dbId > 0) { + ydoc.getArray('deleted_line_ids').push([dbId]); + } + } + } + pageArray.delete(lineIndex, 1); + }, 'local-bridge'); +} diff --git a/client/src/vue_components/show/config/cues/CueEditor.vue b/client/src/vue_components/show/config/cues/CueEditor.vue index c424147c..a129342a 100644 --- a/client/src/vue_components/show/config/cues/CueEditor.vue +++ b/client/src/vue_components/show/config/cues/CueEditor.vue @@ -1,5 +1,9 @@ + + Cue editing is disabled while a script draft exists. Save or discard the draft before editing + cues. + @@ -165,8 +169,6 @@ export default { 'SCENE_LIST', 'CHARACTER_LIST', 'CHARACTER_GROUP_LIST', - 'CAN_REQUEST_EDIT', - 'CURRENT_EDITOR', 'INTERNAL_UUID', 'GET_SCRIPT_PAGE', 'CUE_TYPES', @@ -175,6 +177,7 @@ export default { 'STAGE_DIRECTION_STYLES', 'STAGE_DIRECTION_STYLE_OVERRIDES', 'CURRENT_USER', + 'HAS_DRAFT', ]), }, watch: { @@ -227,18 +230,6 @@ export default { log.error('Unable to get current max page'); } }, - requestEdit() { - this.$socket.sendObj({ - OP: 'REQUEST_SCRIPT_EDIT', - DATA: {}, - }); - }, - async stopEditing() { - this.$socket.sendObj({ - OP: 'STOP_SCRIPT_EDIT', - DATA: {}, - }); - }, async decrPage() { if (this.currentEditPage > 1) { const targetPage = this.currentEditPage - 1; diff --git a/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue b/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue index 308b05ce..63cab40e 100644 --- a/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue +++ b/client/src/vue_components/show/config/cues/ScriptLineCueEditor.vue @@ -12,7 +12,7 @@ + + {{ collaborators.length }} in room + + + {{ collab.username }} + + {{ collab.role }} + + + + + + + + diff --git a/client/src/vue_components/show/config/script/ScriptEditor.vue b/client/src/vue_components/show/config/script/ScriptEditor.vue index 9eb7386f..e92202e3 100644 --- a/client/src/vue_components/show/config/script/ScriptEditor.vue +++ b/client/src/vue_components/show/config/script/ScriptEditor.vue @@ -18,41 +18,72 @@ - - Edit - - - Cuts - - - Stop Editing - - - Save - + + + + Edit + + + + + Cuts + + + + + Discard Draft + + + + + + Stop Editing + + + {{ IS_DRAFT_SAVING ? 'Saving...' : 'Save' }} + + + + Saving{{ DRAFT_SAVE_PHASE ? ` (${DRAFT_SAVE_PHASE})` : '' }}... + + Draft — unsaved changes + + + + + Stop Cuts + + + Save + + + + + + + Act Scene @@ -75,11 +106,11 @@ :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" :stage-direction-styles="STAGE_DIRECTION_STYLES" - @input="lineChange(line, index)" @doneEditing="doneEditingLine(currentEditPage, index)" @deleteLine="deleteLine(currentEditPage, index)" /> @@ -98,6 +129,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)" @@ -182,6 +214,25 @@ + + An unsaved draft exists for this script. What would you like to do? + + Resume Draft + + Discard & Start Fresh + + + Cancel + + + @@ -203,13 +254,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, @@ -232,12 +285,14 @@ export default { pageNo: 1, }, changingPage: false, - loaded: false, + dataLoaded: false, latestAddedLine: null, linePartCuts: [], autoSaveInterval: null, isAutoSaving: false, navbarHeight: 0, + /** @type {Function|null} Deep observer cleanup for Y.Doc pages */ + ydocObserverCleanup: null, }; }, validations: { @@ -251,6 +306,9 @@ export default { }, }, computed: { + loaded() { + return this.dataLoaded && (!this.IS_DRAFT_ACTIVE || this.IS_DRAFT_SYNCED); + }, currentEditPageKey() { return this.currentEditPage.toString(); }, @@ -277,8 +335,20 @@ export default { } return 'primary'; }, + editDisabledReason() { + if (this.CURRENT_SHOW_SESSION) return 'Cannot edit script during a live session'; + if (this.CUTTERS.length > 0) return 'Another user is currently making cuts'; + return ''; + }, + cutsDisabledReason() { + if (this.CURRENT_SHOW_SESSION) return 'Cannot make cuts during a live session'; + if (this.EDITORS.length > 0) return 'Another user is currently editing'; + if (this.CUTTERS.length > 0) return 'Another user is currently making cuts'; + if (this.HAS_DRAFT) return 'An unsaved draft exists'; + return ''; + }, canEdit() { - return this.INTERNAL_UUID === this.CURRENT_EDITOR; + return this.IS_CURRENT_EDITOR; }, canSave() { if (this.IS_CUT_MODE) { @@ -291,13 +361,19 @@ export default { }, ...mapGetters([ 'CURRENT_SHOW', + 'CURRENT_SHOW_SESSION', 'TMP_SCRIPT', 'ACT_LIST', 'SCENE_LIST', 'CHARACTER_LIST', 'CHARACTER_GROUP_LIST', 'CAN_REQUEST_EDIT', - 'CURRENT_EDITOR', + 'CAN_REQUEST_CUTS', + 'EDITORS', + 'CUTTERS', + 'HAS_DRAFT', + 'IS_CURRENT_EDITOR', + 'IS_CURRENT_CUTTER', 'INTERNAL_UUID', 'GET_SCRIPT_PAGE', 'DELETED_LINES', @@ -311,6 +387,17 @@ 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', + 'IS_DRAFT_SAVING', + 'DRAFT_SAVE_PHASE', + 'DRAFT_SAVE_PROGRESS', ]), }, watch: { @@ -320,11 +407,87 @@ export default { USER_SETTINGS() { this.setupAutoSave(); }, - CURRENT_EDITOR() { + IS_CURRENT_EDITOR(isEditor) { this.setupAutoSave(); + if (isEditor && this.CURRENT_REVISION && !this.IS_DRAFT_ACTIVE) { + this.JOIN_DRAFT_ROOM({ + revisionId: this.CURRENT_REVISION, + role: 'editor', + }); + } + }, + EDITORS: { + handler(editors) { + if ( + editors.length > 0 && + !this.IS_CURRENT_EDITOR && + !this.IS_DRAFT_ACTIVE && + this.CURRENT_REVISION + ) { + this.JOIN_DRAFT_ROOM({ + revisionId: this.CURRENT_REVISION, + role: 'viewer', + }); + } + }, + immediate: true, + }, + IS_DRAFT_ACTIVE(active) { + if (!active) { + this.teardownYDocBridge(); + this.RESET_TO_SAVED(this.currentEditPage); + } + }, + IS_DRAFT_SYNCED(synced) { + if (synced) { + this.setupYDocBridge(); + } + }, + DRAFT_SAVE_PROGRESS({ page, total }) { + if (!this._collabSaveToast || !total) return; + const percent = Math.round((page / total) * 100); + this._collabSaveToast.message = + page === 0 ? 'Saving script...' : `Saving page ${page} of ${total} (${percent}%)`; + }, + '$store.state.scriptDraft.isSaving': function onSavingChanged(saving) { + if (saving && !this._collabSaveToast) { + // Open toast for all editors — the save-initiating user triggers this via + // SET_DRAFT_SAVING(true) in saveScript(); other editors get it when the + // first SAVE_PROGRESS message arrives and commits SET_DRAFT_SAVING(true). + this._collabSaveToast = this.$toast.open({ + type: 'info', + message: 'Saving script...', + duration: 0, + dismissible: false, + }); + } else if (!saving && this._collabSaveToast) { + this._collabSaveToast.dismiss(); + this._collabSaveToast = null; + + const error = this.$store.state.scriptDraft.saveError; + if (error) { + if (Array.isArray(error)) { + // Validation errors + const messages = error.map( + (e) => `Page ${e.page}, line ${e.lineIndex + 1}: ${e.message}` + ); + this.$toast.error(`Save failed:\n${messages.join('\n')}`); + } else { + this.$toast.error(`Save failed: ${error}`); + } + } else if (this.$store.state.scriptDraft.lastSavedAt) { + this.$toast.success('Script saved successfully'); + // Refresh script data to match DB + this.LOAD_SCRIPT_PAGE(this.currentEditPage).then(() => { + this.ADD_BLANK_PAGE(this.currentEditPage); + }); + this.GET_SCRIPT_CONFIG_STATUS(); + this.getMaxScriptPage(); + } + } }, }, - async beforeMount() { + async mounted() { await Promise.all([ this.GET_CURRENT_USER() .then(() => this.GET_USER_SETTINGS()) @@ -337,6 +500,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 +520,10 @@ export default { this.currentEditPage = parseInt(storedPage, 10); } await this.goToPageInner(this.currentEditPage); - }, - mounted() { - this.loaded = true; - this.calculateNavbarHeight(); + + // All data loaded — now safe to render + this.dataLoaded = true; + this.$nextTick(() => this.calculateNavbarHeight()); }, created() { window.addEventListener('resize', this.calculateNavbarHeight); @@ -369,6 +533,8 @@ export default { if (this.autoSaveInterval != null) { clearInterval(this.autoSaveInterval); } + this.teardownYDocBridge(); + this.LEAVE_DRAFT_ROOM(); }, methods: { async getMaxScriptPage() { @@ -385,16 +551,53 @@ export default { log.error('Unable to get current max page'); } }, + onEditClick() { + if (this.HAS_DRAFT) { + this.$bvModal.show('draft-resume-modal'); + } else { + this.requestEdit(); + } + }, requestEdit() { this.$socket.sendObj({ OP: 'REQUEST_SCRIPT_EDIT', DATA: {}, }); }, + resumeDraft() { + this.$bvModal.hide('draft-resume-modal'); + this.requestEdit(); + }, + async discardAndStartFresh() { + this.$bvModal.hide('draft-resume-modal'); + this.$socket.sendObj({ + OP: 'DISCARD_SCRIPT_DRAFT', + DATA: {}, + }); + // Wait for ROOM_CLOSED + status update, then request edit + // Use a short delay to let the server process the discard + await new Promise((resolve) => setTimeout(resolve, 500)); + await this.GET_SCRIPT_CONFIG_STATUS(); + this.requestEdit(); + }, + async confirmDiscardDraft() { + const confirmed = await this.$bvModal.msgBoxConfirm( + 'Are you sure you want to discard the unsaved draft? This cannot be undone.', + { okVariant: 'danger', okTitle: 'Discard Draft' } + ); + if (confirmed) { + this.$socket.sendObj({ + OP: 'DISCARD_SCRIPT_DRAFT', + DATA: {}, + }); + await new Promise((resolve) => setTimeout(resolve, 300)); + await this.GET_SCRIPT_CONFIG_STATUS(); + } + }, requestCutEdit() { this.SET_CUT_MODE(true); this.$socket.sendObj({ - OP: 'REQUEST_SCRIPT_EDIT', + OP: 'REQUEST_SCRIPT_CUTS', DATA: {}, }); }, @@ -402,23 +605,32 @@ export default { this.linePartCuts = JSON.parse(JSON.stringify(this.SCRIPT_CUTS)); }, async stopEditing() { - if (this.scriptChanges) { - const msg = - 'Are you sure you want to stop editing the script? ' + - 'This will cause all unsaved changes to be lost'; - const action = await this.$bvModal.msgBoxConfirm(msg, {}); - if (action === false) { - return; + if (this.IS_CUT_MODE) { + // Cuts mode: local state, no room involvement + if (this.scriptChanges) { + const msg = + 'Are you sure you want to stop editing cuts? ' + + 'This will cause all unsaved changes to be lost'; + const action = await this.$bvModal.msgBoxConfirm(msg, {}); + if (action === false) { + return; + } } + this.editPages = []; + this.RESET_TO_SAVED(this.currentEditPage); + this.resetCutsToSaved(); + this.$socket.sendObj({ OP: 'STOP_SCRIPT_EDIT', DATA: {} }); + this.SET_CUT_MODE(false); + return; } + + // Collab edit mode: stay in room as viewer this.editPages = []; - this.RESET_TO_SAVED(this.currentEditPage); - this.resetCutsToSaved(); - this.$socket.sendObj({ - OP: 'STOP_SCRIPT_EDIT', - DATA: {}, - }); - this.SET_CUT_MODE(false); + this._broadcastAwareness(this.currentEditPage, null); + this.$socket.sendObj({ OP: 'STOP_SCRIPT_EDIT', DATA: {} }); + // Server downgrades role; IS_CURRENT_EDITOR goes false via GET_SCRIPT_CONFIG_STATUS + // → canEdit becomes false → UI switches to read-only + // Y.Doc bridge stays active, TMP_SCRIPT stays populated }, async decrPage() { if (this.currentEditPage > 1) { @@ -435,6 +647,9 @@ export default { // Pre-load previous page await this.LOAD_SCRIPT_PAGE(this.currentEditPage - 1); + + // Overlay Y.Doc data onto the newly current page + this.syncCurrentPageFromYDoc(); } }, async incrPage() { @@ -444,68 +659,50 @@ export default { } // Pre-load next page await this.LOAD_SCRIPT_PAGE(this.currentEditPage + 1); + + // Overlay Y.Doc data onto the newly current page + this.syncCurrentPageFromYDoc(); }, - 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; + } + + addYDocLine(this.DRAFT_YDOC, 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 @@ -584,18 +781,12 @@ export default { return null; }, - lineChange(line, index) { - this.SET_LINE({ - pageNo: this.currentEditPage, - lineIndex: index, - lineObj: line, - }); - }, beginEditingLine(pageIndex, lineIndex) { const index = this.editPages.indexOf(`page_${pageIndex}_line_${lineIndex}`); 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 +794,7 @@ export default { if (index !== -1) { this.editPages.splice(index, 1); } + this._broadcastAwareness(pageIndex, null); if (this.latestAddedLine === lineIdent) { this.addNewLine(); } @@ -611,10 +803,7 @@ export default { if (this.latestAddedLine === `page_${pageIndex}_line_${lineIndex}`) { this.latestAddedLine = null; } - this.DELETE_LINE({ - pageNo: pageIndex, - lineIndex, - }); + deleteYDocLine(this.DRAFT_YDOC, pageIndex, lineIndex); this.doneEditingLine(pageIndex, lineIndex); this.editPages.forEach(function updateEditPage(editPage, index) { @@ -663,17 +852,14 @@ 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, - }); + addYDocLine(this.DRAFT_YDOC, this.currentEditPage, newLineObject, newLineIndex); // Update existing edit page indices this.editPages.forEach(function updateEditPage(editPage, index) { @@ -688,13 +874,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); @@ -709,6 +889,13 @@ export default { await this.insertLineAt(pageIndex, lineIndex, LINE_TYPES.SPACING); }, async saveScript() { + // Collaborative save — server handles persistence via WebSocket + if (this.IS_DRAFT_ACTIVE) { + this.SET_DRAFT_SAVING(true); + this.$socket.sendObj({ OP: 'SAVE_SCRIPT_DRAFT', DATA: {} }); + return; + } + if (!this.IS_CUT_MODE) { if (this.scriptChanges) { this.savingInProgress = true; @@ -799,15 +986,18 @@ export default { this.ADD_BLANK_PAGE(this.currentEditPage); } await this.LOAD_SCRIPT_PAGE(parseInt(pageNo, 10) + 1); + + // Overlay collaborative data onto loaded pages + this.syncCurrentPageFromYDoc(); }, setupAutoSave() { const autoSaveInterval = Math.max( this.USER_SETTINGS.script_auto_save_interval * 1000 * 60, 1000 * 60 ); - if (this.INTERNAL_UUID !== this.CURRENT_EDITOR && this.autoSaveInterval != null) { + if (!this.IS_CURRENT_EDITOR && this.autoSaveInterval != null) { clearInterval(this.autoSaveInterval); - } else if (this.INTERNAL_UUID === this.CURRENT_EDITOR) { + } else if (this.IS_CURRENT_EDITOR) { if (this.USER_SETTINGS.enable_script_auto_save) { if (this.autoSaveInterval == null) { this.autoSaveInterval = setInterval(this.autosave, autoSaveInterval); @@ -824,6 +1014,11 @@ export default { if (this.isAutoSaving) { return; } + // In collab mode, trigger server-side save (no toast for autosave) + if (this.IS_DRAFT_ACTIVE) { + this.$socket.sendObj({ OP: 'SAVE_SCRIPT_DRAFT', DATA: {} }); + return; + } this.isAutoSaving = true; const toastInstance = this.$toast.open({ type: 'info', @@ -911,6 +1106,123 @@ 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'); + + log.debug( + `ScriptEditor: Initialising Y.Doc bridge; TMP_SCRIPT pages at bridge init: ` + + `[${Object.keys(this.TMP_SCRIPT).join(', ')}]` + ); + + // 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, transaction) => { + log.debug( + `ScriptEditor: Deep observer fired (origin=${transaction.origin}); syncing to TMP_SCRIPT` + ); + // 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() { + log.debug('ScriptEditor: Tearing down Y.Doc bridge observers'); + 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.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) { @@ -921,13 +1233,10 @@ export default { }, ...mapMutations([ 'REMOVE_PAGE', - 'ADD_BLANK_LINE', - 'SET_LINE', - 'DELETE_LINE', 'RESET_DELETED', 'SET_CUT_MODE', - 'INSERT_BLANK_LINE', 'RESET_INSERTED', + 'SET_DRAFT_SAVING', ]), ...mapActions([ 'GET_SCENE_LIST', @@ -947,6 +1256,9 @@ export default { 'GET_STAGE_DIRECTION_STYLE_OVERRIDES', 'GET_CUE_COLOUR_OVERRIDES', 'GET_USER_SETTINGS', + 'GET_SCRIPT_REVISIONS', + 'JOIN_DRAFT_ROOM', + 'LEAVE_DRAFT_ROOM', ]), }, }; @@ -964,4 +1276,18 @@ export default { border-bottom: 1px solid #dee2e6; background: var(--body-background); } + +.btn-group-item { + display: flex; +} + +.btn-group-item:first-child > .btn { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} + +.btn-group-item:last-child > .btn { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} diff --git a/client/src/vue_components/show/config/script/ScriptLineEditor.vue b/client/src/vue_components/show/config/script/ScriptLineEditor.vue index b06f429b..e7214c70 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,14 @@
An unsaved draft exists for this script. What would you like to do?