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..bce5e1a6 100644 --- a/client/src/main.js +++ b/client/src/main.js @@ -15,6 +15,8 @@ import { getWebSocketURL, isElectron } from '@/js/platform'; import { initRemoteLogging } from '@/js/logger'; import log from 'loglevel'; +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..4a27f3b5 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) { @@ -118,7 +121,6 @@ export default { for (const page of Object.keys(context.state.script)) { await context.dispatch('LOAD_SCRIPT_PAGE', page); - await context.dispatch('ADD_BLANK_PAGE', page); } await context.dispatch('LOAD_CUES'); await context.dispatch('GET_CUTS'); diff --git a/client/src/store/modules/scriptConfig.js b/client/src/store/modules/scriptConfig.js index 0ca3adc5..b962a6b0 100644 --- a/client/src/store/modules/scriptConfig.js +++ b/client/src/store/modules/scriptConfig.js @@ -1,100 +1,27 @@ import Vue from 'vue'; -import { detailedDiff } from 'deep-object-diff'; -import log from 'loglevel'; import { makeURL } from '@/js/utils'; export default { state: { - tmpScript: {}, - deletedLines: {}, editStatus: { - canRequestEdit: false, - currentEditor: null, + editors: [], + cutters: [], + hasDraft: false, }, cutMode: false, - insertedLines: {}, }, mutations: { SET_EDIT_STATUS(state, editStatus) { state.editStatus = editStatus; }, - ADD_PAGE(state, { pageNo, pageContents }) { - Vue.set(state.tmpScript, pageNo, pageContents); - }, - REMOVE_PAGE(state, pageNo) { - Vue.delete(state.tmpScript, pageNo); - }, - ADD_BLANK_LINE(state, { pageNo, lineObj }) { - const line = JSON.parse(JSON.stringify(lineObj)); - line.page = pageNo; - state.tmpScript[pageNo].push(line); - }, - INSERT_BLANK_LINE(state, { pageNo, lineIndex, lineObj }) { - const pageNoStr = pageNo.toString(); - - if ( - Object.keys(state.deletedLines).includes(pageNoStr) && - state.deletedLines[pageNoStr].includes(lineIndex) - ) { - const lineId = state.tmpScript[pageNoStr][lineIndex].id; - const line = JSON.parse(JSON.stringify(lineObj)); - line.page = pageNo; - line.id = lineId; - state.tmpScript[pageNo].splice(lineIndex, 1, line); - state.deletedLines[pageNoStr].splice(state.deletedLines[pageNoStr].indexOf(lineIndex), 1); - } else { - const line = JSON.parse(JSON.stringify(lineObj)); - line.page = pageNo; - state.tmpScript[pageNo].splice(lineIndex, 0, line); - if (!Object.keys(state.insertedLines).includes(pageNoStr)) { - Vue.set(state.insertedLines, pageNoStr, []); - } - state.insertedLines[pageNoStr].push(lineIndex); - } - }, - SET_LINE(state, { pageNo, lineIndex, lineObj }) { - Vue.set(state.tmpScript[pageNo], lineIndex, lineObj); - }, - DELETE_LINE(state, { pageNo, lineIndex }) { - const pageNoStr = pageNo.toString(); - if (state.tmpScript[pageNoStr][lineIndex].id !== null) { - if (!Object.keys(state.deletedLines).includes(pageNoStr)) { - Vue.set(state.deletedLines, pageNoStr, []); - } - state.deletedLines[pageNoStr].push(lineIndex); - } else { - state.tmpScript[pageNoStr].splice(lineIndex, 1); - } - if ( - Object.keys(state.insertedLines).includes(pageNoStr) && - state.insertedLines[pageNoStr].includes(lineIndex) - ) { - state.insertedLines[pageNoStr].splice(lineIndex, 1); - } - }, - RESET_DELETED(state, pageNo) { - const pageNoStr = pageNo.toString(); - if (Object.keys(state.deletedLines).includes(pageNoStr)) { - Vue.set(state.deletedLines, pageNoStr, []); - } - }, - RESET_INSERTED(state, pageNo) { - const pageNoStr = pageNo.toString(); - if (Object.keys(state.insertedLines).includes(pageNoStr)) { - Vue.set(state.insertedLines, pageNoStr, []); - } - }, - EMPTY_SCRIPT(state) { - state.tmpScript = {}; - }, SET_CUT_MODE(state, cutMode) { state.cutMode = cutMode; }, }, actions: { - REQUEST_EDIT_FAILURE(context) { - Vue.$toast.error('Unable to edit script'); + REQUEST_EDIT_FAILURE(context, message) { + Vue.$toast.error(message?.DATA?.reason || 'Unable to edit script'); context.dispatch('GET_SCRIPT_CONFIG_STATUS'); context.commit('SET_CUT_MODE', false); }, @@ -103,113 +30,41 @@ export default { if (response.ok) { const status = await response.json(); context.commit('SET_EDIT_STATUS', status); - } else { - log.error('Unable to get script config status'); - } - }, - ADD_BLANK_PAGE(context, pageNo) { - const pageNoStr = pageNo.toString(); - const pageContents = JSON.parse(JSON.stringify(context.getters.GET_SCRIPT_PAGE(pageNoStr))); - context.commit('ADD_PAGE', { - pageNo, - pageContents, - }); - }, - RESET_TO_SAVED(context, pageNo) { - context.commit('EMPTY_SCRIPT'); - context.dispatch('ADD_BLANK_PAGE', pageNo); - }, - async SAVE_NEW_PAGE(context, pageNo) { - const searchParams = new URLSearchParams({ - page: pageNo, - }); - const response = await fetch(`${makeURL('/api/v1/show/script')}?${searchParams}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify(context.getters.TMP_SCRIPT[pageNo.toString()]), - }); - if (!response.ok) { - log.error('Failed to save new script page'); - return false; - } - return true; - }, - async SAVE_CHANGED_PAGE(context, pageNo) { - let actualScriptPage = context.getters.GET_SCRIPT_PAGE(pageNo); - const tmpScriptPage = context.getters.TMP_SCRIPT[pageNo.toString()]; - - // Need to augment the actual script page to include the inserted pages, this is a hack, - // but it will allow all the other pages to show as not edited if the really haven't been - // changed - actualScriptPage = JSON.parse(JSON.stringify(actualScriptPage)); - JSON.parse(JSON.stringify(context.getters.INSERTED_LINES(pageNo))) - .sort((a, b) => a - b) - .forEach((lineIndex) => { - actualScriptPage.splice( - lineIndex, - 0, - JSON.parse(JSON.stringify(tmpScriptPage[lineIndex])) - ); - }); - - const deepDiff = detailedDiff(actualScriptPage, tmpScriptPage); - const pageStatus = { - added: Object.keys(deepDiff.added).map((x) => parseInt(x, 10)), - updated: Object.keys(deepDiff.updated).map((x) => parseInt(x, 10)), - deleted: [...context.getters.DELETED_LINES(pageNo)], - inserted: [...context.getters.INSERTED_LINES(pageNo)], - }; - const searchParams = new URLSearchParams({ - page: pageNo, - }); - const response = await fetch(`${makeURL('/api/v1/show/script')}?${searchParams}`, { - method: 'PATCH', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - page: context.getters.TMP_SCRIPT[pageNo.toString()], - status: pageStatus, - }), - }); - if (!response.ok) { - log.error('Failed to edit script page'); - return false; } - return true; }, }, getters: { - TMP_SCRIPT(state) { - return state.tmpScript; + EDITORS(state) { + return state.editStatus.editors; }, - DELETED_LINES: (state) => (page) => { - const pageStr = page.toString(); - if (Object.keys(state.deletedLines).includes(pageStr)) { - return state.deletedLines[pageStr]; - } - return []; + CUTTERS(state) { + return state.editStatus.cutters; + }, + HAS_DRAFT(state) { + return state.editStatus.hasDraft; }, - ALL_DELETED_LINES(state) { - return state.deletedLines; + CAN_REQUEST_EDIT(state, getters, rootState, rootGetters) { + if (rootGetters.CURRENT_SHOW_SESSION) return false; + return state.editStatus.cutters.length === 0; }, - CAN_REQUEST_EDIT(state) { - return state.editStatus.canRequestEdit; + 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 + ); }, - CURRENT_EDITOR(state) { - return state.editStatus.currentEditor; + 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; }, - INSERTED_LINES: (state) => (page) => { - const pageStr = page.toString(); - if (Object.keys(state.insertedLines).includes(pageStr)) { - return state.insertedLines[pageStr]; - } - return []; - }, }, }; 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..5cb475d9 --- /dev/null +++ b/client/src/store/modules/scriptDraft.js @@ -0,0 +1,322 @@ +/** + * 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'; + +let _ydoc = null; +let _provider = null; +let _syncIntervalId = null; +let _syncTimeoutId = null; + +export default { + state: { + isRoomActive: false, + isConnected: false, + isSynced: false, + isDraft: false, + lastSavedAt: null, + collaborators: [], + awarenessStates: {}, + isSaving: false, + savePhase: null, + saveError: null, + savePage: 0, + saveTotalPages: 0, + syncError: null, + collabError: null, + }, + mutations: { + SET_DRAFT_ROOM(state) { + state.isRoomActive = true; + }, + 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; + }, + SET_SYNC_ERROR(state, error) { + state.syncError = error; + }, + SET_COLLAB_ERROR(state, error) { + state.collabError = error; + }, + CLEAR_DRAFT_STATE(state) { + state.isRoomActive = false; + 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; + state.syncError = null; + state.collabError = null; + _ydoc = null; + _provider = null; + }, + }, + actions: { + async JOIN_DRAFT_ROOM(context, { role = 'editor' } = {}) { + // Leave existing room first + if (_provider) { + await context.dispatch('LEAVE_DRAFT_ROOM'); + } + + const ydoc = new Y.Doc(); + const provider = new ScriptDocProvider(ydoc, { role }); + + // Store instances outside reactive state + _ydoc = ydoc; + _provider = provider; + + context.commit('SET_DRAFT_ROOM'); + + // 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: surface a sync failure to the user 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'); + context.commit( + 'SET_SYNC_ERROR', + 'Failed to sync with the collaboration server after 10 seconds. ' + + 'Please try refreshing the page.' + ); + } + }, 10000); + + provider.connect(); + log.debug('ScriptDraft: Provider connect() called'); + log.info(`ScriptDraft: Joined room as ${role}`); + }, + 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'); + }, + 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; + }, + YJS_UPDATE(context, message) { + if (!_provider) return false; + return _provider.applyUpdate(message.DATA); + }, + 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; + }, + ROOM_MEMBERS(context, message) { + context.commit('SET_DRAFT_COLLABORATORS', message.DATA?.members || []); + }, + ROOM_CLOSED(context) { + context.dispatch('LEAVE_DRAFT_ROOM'); + }, + SCRIPT_SAVED(context, message) { + context.commit('SET_DRAFT_SAVING', false); + context.commit('SET_DRAFT_SAVE_PHASE', null); + context.commit('SET_DRAFT_SAVE_ERROR', null); + context.commit('SET_DRAFT_DIRTY', false); + const timestamp = message.DATA?.last_saved_at; + if (timestamp) context.commit('SET_DRAFT_LAST_SAVED', timestamp); + }, + SAVE_PROGRESS(context, message) { + context.commit('SET_DRAFT_SAVING', true); + context.commit('SET_SAVE_PROGRESS', message.DATA); + }, + 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); + }, + COLLAB_ERROR(context, message) { + const error = message.DATA?.error || 'A collaboration error occurred'; + log.error('Collab error received:', error); + context.commit('SET_COLLAB_ERROR', error); + }, + }, + getters: { + IS_DRAFT_ACTIVE(state) { + return state.isRoomActive && state.isConnected; + }, + // NOTE: `state.isRoomActive` 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, `isRoomActive` toggles, busting the cache so the new + // Y.Doc instance is returned to all components. + DRAFT_YDOC(state) { + state.isRoomActive; + return _ydoc; + }, + DRAFT_PROVIDER(state) { + state.isRoomActive; // reactive dependency — see DRAFT_YDOC comment + return _provider; + }, + DRAFT_PAGES(state) { + state.isRoomActive; // reactive dependency — see DRAFT_YDOC comment + if (!_ydoc) return null; + return _ydoc.getMap('pages'); + }, + DRAFT_META(state) { + state.isRoomActive; // reactive dependency — see DRAFT_YDOC comment + if (!_ydoc) return null; + return _ydoc.getMap('meta'); + }, + DRAFT_DELETED_LINE_IDS(state) { + state.isRoomActive; // reactive dependency — see DRAFT_YDOC comment + if (!_ydoc) return null; + return _ydoc.getArray('deleted_line_ids'); + }, + IS_DRAFT_SAVING(state) { + return state.isSaving; + }, + IS_DRAFT_LAST_SAVED(state) { + return state.lastSavedAt; + }, + DRAFT_SAVE_ERROR(state) { + return state.saveError; + }, + DRAFT_SAVE_PHASE(state) { + return state.savePhase; + }, + DRAFT_SAVE_PROGRESS(state) { + return { page: state.savePage, total: state.saveTotalPages }; + }, + IS_DRAFT_DIRTY(state) { + return state.isDraft; + }, + IS_DRAFT_SYNCED(state) { + return state.isSynced; + }, + DRAFT_SYNC_ERROR(state) { + return state.syncError; + }, + DRAFT_COLLAB_ERROR(state) { + return state.collabError; + }, + DRAFT_COLLABORATORS(state) { + return state.collaborators; + }, + DRAFT_AWARENESS_STATES(state) { + return state.awarenessStates; + }, + 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..f6c12f5f --- /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, isRoomActive: false, ...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/collabColors.js b/client/src/utils/collabColors.js new file mode 100644 index 00000000..3f5abb64 --- /dev/null +++ b/client/src/utils/collabColors.js @@ -0,0 +1,4 @@ +export function collabColor(userId) { + const hue = (userId * 137.508) % 360; // golden-angle spacing + return `hsl(${Math.round(hue)}, 65%, 45%)`; +} diff --git a/client/src/utils/yjs/ScriptDocProvider.js b/client/src/utils/yjs/ScriptDocProvider.js new file mode 100644 index 00000000..25204abc --- /dev/null +++ b/client/src/utils/yjs/ScriptDocProvider.js @@ -0,0 +1,183 @@ +/** + * 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 + * + * The server determines which revision the room belongs to automatically; + * there is only ever one active room per server. + */ + +import Vue from 'vue'; +import * as Y from 'yjs'; +import log from 'loglevel'; + +function encodeBase64(uint8Array) { + let binary = ''; + for (let i = 0; i < uint8Array.length; i++) { + binary += String.fromCharCode(uint8Array[i]); + } + return btoa(binary); +} + +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 { + constructor(doc, options = {}) { + this.doc = doc; + this.role = options.role || 'editor'; + this._connected = false; + this._synced = false; + this._destroyed = false; + this._updateHandler = null; + this._onDocUpdate = this._onDocUpdate.bind(this); + } + get _socket() { + return Vue.prototype.$socket || null; + } + connect() { + if (this._destroyed) return; + const socket = this._socket; + if (!socket || socket.readyState !== WebSocket.OPEN) { + log.warn('ScriptDocProvider: WebSocket not ready, deferring connect'); + return; + } + socket.sendObj({ + OP: 'JOIN_SCRIPT_ROOM', + DATA: { role: this.role }, + }); + this.doc.on('update', this._onDocUpdate); + this._connected = true; + log.info(`ScriptDocProvider: Joining room as ${this.role}`); + } + disconnect() { + if (!this._connected) return; + this.setLocalAwareness({ page: null, lineIndex: null }); + const socket = this._socket; + if (socket && socket.readyState === WebSocket.OPEN) { + socket.sendObj({ OP: 'LEAVE_SCRIPT_ROOM', DATA: {} }); + } + this.doc.off('update', this._onDocUpdate); + this._connected = false; + this._synced = false; + log.info('ScriptDocProvider: Left room'); + } + 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. + applySync(data) { + const payload = data.payload; + if (!payload) return false; + + try { + const decoded = decodeBase64(payload); + + if (data.step === 0) { + 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'); + const stateVector = Y.encodeStateVector(this.doc); + this._sendToServer('YJS_SYNC', { + step: 1, + payload: encodeBase64(stateVector), + }); + } else if (data.step === 2) { + 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 false; + } + + return true; + } + applyUpdate(data) { + if (!this._connected) 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 false; + } + + return true; + } + applyAwareness(data) { + if (!this._connected) return false; + const payload = data.payload; + if (!payload) return false; + + 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 false; + } + } + setLocalAwareness(state) { + if (!this._connected) return; + const jsonStr = JSON.stringify(state); + const encoded = new TextEncoder().encode(jsonStr); + this._sendToServer('YJS_AWARENESS', { payload: encodeBase64(encoded) }); + } + _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) }); + } + _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 }); + } + get connected() { + return this._connected; + } + 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..eed42183 --- /dev/null +++ b/client/src/utils/yjs/ScriptDocProvider.test.js @@ -0,0 +1,195 @@ +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() { + const doc = makeDoc(); + const provider = new ScriptDocProvider(doc); + 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 payload is missing', () => { + const { provider } = makeProvider(); + const result = provider.applySync({ step: 0 }); + expect(result).toBe(false); + }); + + it('step 0: applies full state, sets _synced, returns true', () => { + const { provider, doc } = makeProvider(); + const sourceDoc = makeDoc(); + const meta = sourceDoc.getMap('meta'); + meta.set('revision_id', 42); + + const result = provider.applySync({ + 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 filtering)', () => { + const { provider } = makeProvider(); + const sourceDoc = makeDoc(); + const result = provider.applySync({ + step: 0, + payload: encodedUpdate(sourceDoc), + }); + expect(result).toBe(true); + }); + + it('step 0: accepts message with room_id (ignored, not filtered)', () => { + const { provider } = makeProvider(); + const sourceDoc = makeDoc(); + // room_id is present but should be ignored — server no longer sends it, + // but provider must still handle it gracefully for backwards compatibility + const result = provider.applySync({ + room_id: 'draft_99', + step: 0, + payload: encodedUpdate(sourceDoc), + }); + expect(result).toBe(true); + }); + + it('step 2: applies diff, returns true', () => { + const { provider, doc } = makeProvider(); + + // 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({ + 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(); + provider._connected = false; + expect(provider.applyUpdate({ payload: 'dA==' })).toBe(false); + }); + + it('returns false when payload is missing', () => { + const { provider } = makeProvider(); + provider._connected = true; + expect(provider.applyUpdate({})).toBe(false); + }); + + it('applies update to doc and returns true', () => { + const { provider, doc } = makeProvider(); + 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({ payload: encodeBase64(update) }); + + expect(result).toBe(true); + expect(doc.getMap('data').get('key')).toBe('value'); + }); + + it('applies update even when room_id is present (ignored)', () => { + const { provider, doc } = makeProvider(); + provider._connected = true; + + const sourceDoc = makeDoc(); + const sv = Y.encodeStateVector(doc); + sourceDoc.getMap('data').set('key', 'value'); + const update = Y.encodeStateAsUpdate(sourceDoc, sv); + + // room_id no longer causes filtering + const result = provider.applyUpdate({ + room_id: 'draft_99', + payload: encodeBase64(update), + }); + + expect(result).toBe(true); + }); + }); + + describe('applyAwareness()', () => { + it('returns false when not connected', () => { + const { provider } = makeProvider(); + provider._connected = false; + expect(provider.applyAwareness({ payload: 'dA==' })).toBe(false); + }); + + it('returns false when payload is missing', () => { + const { provider } = makeProvider(); + provider._connected = true; + expect(provider.applyAwareness({})).toBe(false); + }); + + it('decodes payload and returns AWARENESS result', () => { + const { provider } = makeProvider(); + provider._connected = true; + + const state = { userId: 7, username: 'alice', page: 2, lineIndex: 5 }; + const result = provider.applyAwareness({ payload: encodedAwareness(state) }); + + expect(result).toEqual({ type: 'AWARENESS', state }); + }); + + it('handles message without room_id', () => { + const { provider } = makeProvider(); + provider._connected = true; + + const state = { userId: 3, page: null, lineIndex: null }; + const result = provider.applyAwareness({ payload: encodedAwareness(state) }); + + expect(result).toEqual({ type: 'AWARENESS', state }); + }); + + it('applies awareness even when room_id is present (ignored)', () => { + const { provider } = makeProvider(); + provider._connected = true; + + const state = { userId: 5, page: 1, lineIndex: 2 }; + // room_id no longer causes filtering + const result = provider.applyAwareness({ + room_id: 'draft_99', + 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..e97991dc --- /dev/null +++ b/client/src/utils/yjs/useYjsBinding.js @@ -0,0 +1,144 @@ +/** + * 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'; + +export function useYMap(ymap, keys = null) { + const data = Vue.observable({}); + + if (keys) { + keys.forEach((key) => { + Vue.set(data, key, _unwrapYjsValue(ymap.get(key))); + }); + } else { + ymap.forEach((value, key) => { + Vue.set(data, key, _unwrapYjsValue(value)); + }); + } + + 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(key, value) { + ymap.set(key, value); + }, + destroy() { + ymap.unobserve(observer); + }, + }; +} + +export function useYText(ytext) { + const data = Vue.observable({ value: ytext.toString() }); + + const observer = () => { + data.value = ytext.toString(); + }; + + ytext.observe(observer); + + return { + data, + 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); + }, + }; +} + +export function useYArray(yarray) { + const data = Vue.observable([]); + + _syncArrayData(yarray, data); + + const observer = () => { + _syncArrayData(yarray, data); + }; + + yarray.observe(observer); + + return { + data, + destroy() { + yarray.unobserve(observer); + }, + }; +} + +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)); + }); +} + +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..c5b6f601 --- /dev/null +++ b/client/src/utils/yjs/yjsBridge.js @@ -0,0 +1,96 @@ +/** + * Bridge utilities for Y.Doc structural operations. + * + * Y.Doc is the source of truth during collaborative editing. + * Components write directly to Y.Map/Y.Text; this module provides: + * - Structural helpers (add/delete lines in Y.Doc) + * - Sentinel conversion (nullToZero / zeroToNull) + * + * Y.Doc schema: + * - `_id` for line/part IDs (string; numeric DB id or UUID for new items) + * - `parts` (Y.Array of Y.Map) 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'; + +export function zeroToNull(val) { + return val === 0 ? null : val; +} + +export function nullToZero(val) { + return val == null ? 0 : val; +} + +export function addYDocLine(ydoc, pageNo, lineObj, insertAt) { + const pages = ydoc.getMap('pages'); + const pageKey = pageNo.toString(); + let pageArray = pages.get(pageKey); + + ydoc.transact(() => { + 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'); +} + +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..c5215b3c 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. + @@ -105,7 +109,7 @@ + + diff --git a/client/src/vue_components/show/config/script/ScriptEditor.vue b/client/src/vue_components/show/config/script/ScriptEditor.vue index 9eb7386f..ca39537f 100644 --- a/client/src/vue_components/show/config/script/ScriptEditor.vue +++ b/client/src/vue_components/show/config/script/ScriptEditor.vue @@ -18,41 +18,66 @@ - - 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 @@ -63,8 +88,8 @@ - - + + @@ -87,17 +112,45 @@ v-else :key="`page_${currentEditPage}_line_${index}`" :line-index="index" - :line="TMP_SCRIPT[currentEditPage][index]" - :page="TMP_SCRIPT[currentEditPage]" + :line="line" + :page="localPageScript" :acts="ACT_LIST" :scenes="SCENE_LIST" :characters="CHARACTER_LIST" :character-groups="CHARACTER_GROUP_LIST" - :previous-line="TMP_SCRIPT[currentEditPage][index - 1]" + :previous-line="localPageScript[index - 1] || null" :can-edit="canEdit" :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)" + @insertStageDirection="insertStageDirectionAt(currentEditPage, index)" + @insertCueLine="insertCueLineAt(currentEditPage, index)" + @insertSpacing="insertSpacingAt(currentEditPage, index)" + @deleteLine="deleteLine(currentEditPage, index)" + /> + + + + + + + An unsaved draft exists for this script. What would you like to do? + + Resume Draft + + Discard & Start Fresh + + + Cancel + + + @@ -203,13 +275,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 { zeroToNull, addYDocLine, deleteYDocLine } from '@/utils/yjs/yjsBridge'; export default { name: 'ScriptConfig', - components: { ScriptLineViewer, ScriptLineEditor }, + components: { ScriptLineViewer, ScriptLineEditor, CollaboratorPanel }, data() { return { currentEditPage: 1, @@ -232,12 +306,12 @@ export default { pageNo: 1, }, changingPage: false, - loaded: false, + dataLoaded: false, latestAddedLine: null, linePartCuts: [], - autoSaveInterval: null, - isAutoSaving: false, navbarHeight: 0, + ydocObserverCleanup: null, + localPageScript: [], }; }, validations: { @@ -251,6 +325,14 @@ export default { }, }, computed: { + loaded() { + if (!this.dataLoaded) return false; + // Show spinner while editor is joining the room (server confirmed, room not yet active) + if (this.IS_CURRENT_EDITOR && !this.IS_DRAFT_ACTIVE) return false; + // Show spinner while the Y.Doc is syncing + if (this.IS_DRAFT_ACTIVE && !this.IS_DRAFT_SYNCED) return false; + return true; + }, currentEditPageKey() { return this.currentEditPage.toString(); }, @@ -258,18 +340,7 @@ export default { if (this.IS_CUT_MODE) { return Object.keys(diff(this.SCRIPT_CUTS, this.linePartCuts)).length > 0; } - let hasChanges = false; - Object.keys(this.TMP_SCRIPT).forEach(function checkPageHasChanges(pageNo) { - const lineDiff = diff(this.GET_SCRIPT_PAGE(pageNo), this.TMP_SCRIPT[pageNo]); - if ( - Object.keys(lineDiff).length > 0 || - this.DELETED_LINES(pageNo).length > 0 || - this.INSERTED_LINES(pageNo).length > 0 - ) { - hasChanges = true; - } - }, this); - return hasChanges; + return false; }, saveProgressVariant() { if (!this.savingInProgress) { @@ -277,54 +348,155 @@ 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 || this.IS_CURRENT_CUTTER; }, canSave() { - if (this.IS_CUT_MODE) { - return this.scriptChanges; - } - return this.scriptChanges && this.editPages.length === 0; + if (this.IS_CUT_MODE) return this.scriptChanges; + if (this.IS_DRAFT_ACTIVE) return this.IS_DRAFT_DIRTY && this.editPages.length === 0; + return false; }, pagesWithOpenChanges() { return [...new Set(this.editPages.map((x) => parseInt(x.split('_')[1], 10)))]; }, ...mapGetters([ 'CURRENT_SHOW', - 'TMP_SCRIPT', + 'CURRENT_SHOW_SESSION', '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', 'SCENE_BY_ID', 'ACT_BY_ID', 'IS_CUT_MODE', 'SCRIPT_CUTS', - 'INSERTED_LINES', 'STAGE_DIRECTION_STYLES', 'CURRENT_USER', 'STAGE_DIRECTION_STYLE_OVERRIDES', - 'USER_SETTINGS', 'IS_SCRIPT_EDITOR', + 'CURRENT_REVISION', + 'IS_DRAFT_ACTIVE', + 'IS_DRAFT_DIRTY', + 'IS_DRAFT_SYNCED', + 'DRAFT_YDOC', + 'DRAFT_COLLABORATORS', + 'DRAFT_PROVIDER', + 'DRAFT_LINE_EDITORS', + 'DRAFT_AWARENESS_STATES', + 'IS_DRAFT_SAVING', + 'IS_DRAFT_LAST_SAVED', + 'DRAFT_SAVE_ERROR', + 'DRAFT_SAVE_PHASE', + 'DRAFT_SAVE_PROGRESS', + 'DRAFT_SYNC_ERROR', + 'DRAFT_COLLAB_ERROR', ]), }, watch: { currentEditPage(val) { localStorage.setItem('scriptEditPage', val); }, - USER_SETTINGS() { - this.setupAutoSave(); + IS_CURRENT_EDITOR(isEditor) { + if (isEditor && this.CURRENT_REVISION && !this.IS_DRAFT_ACTIVE) { + this.JOIN_DRAFT_ROOM({ role: 'editor' }); + } + }, + EDITORS: { + handler(editors) { + if ( + editors.length > 0 && + !this.IS_CURRENT_EDITOR && + !this.IS_DRAFT_ACTIVE && + this.CURRENT_REVISION + ) { + this.JOIN_DRAFT_ROOM({ role: 'viewer' }); + } + }, + immediate: true, + }, + IS_DRAFT_ACTIVE(active) { + if (!active) { + this.teardownYDocBridge(); + // Reload the current page into the Vuex script store so the non-draft + // template branch has data immediately after leaving the room. + this.LOAD_SCRIPT_PAGE(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}%)`; + }, + DRAFT_SYNC_ERROR(error) { + if (error) { + this.$toast.error(error); + } + }, + DRAFT_COLLAB_ERROR(error) { + if (error) { + this.$toast.error(error); + } }, - CURRENT_EDITOR() { - this.setupAutoSave(); + IS_DRAFT_SAVING: function onSavingChanged(saving) { + if (!this.IS_CURRENT_EDITOR) return; + if (saving && !this._collabSaveToast) { + 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.DRAFT_SAVE_ERROR; + if (error) { + if (Array.isArray(error)) { + 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.IS_DRAFT_LAST_SAVED) { + this.$toast.success('Script saved successfully'); + this.LOAD_SCRIPT_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 +509,7 @@ export default { } return Promise.resolve(); }), + this.GET_SCRIPT_REVISIONS(), this.GET_SCRIPT_CONFIG_STATUS(), this.GET_ACT_LIST(), this.GET_SCENE_LIST(), @@ -356,19 +529,18 @@ 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); }, destroyed() { window.removeEventListener('resize', this.calculateNavbarHeight); - if (this.autoSaveInterval != null) { - clearInterval(this.autoSaveInterval); - } + this.teardownYDocBridge(); + this.LEAVE_DRAFT_ROOM(); }, methods: { async getMaxScriptPage() { @@ -383,6 +555,16 @@ export default { this.currentMaxPage = respJson.max_page; } else { log.error('Unable to get current max page'); + this.$toast.warning( + 'Failed to load script page count — page navigation may not work correctly.' + ); + } + }, + onEditClick() { + if (this.HAS_DRAFT && this.EDITORS.length === 0) { + this.$bvModal.show('draft-resume-modal'); + } else { + this.requestEdit(); } }, requestEdit() { @@ -391,10 +573,39 @@ export default { DATA: {}, }); }, + resumeDraft() { + this.$bvModal.hide('draft-resume-modal'); + this.requestEdit(); + }, + async discardAndStartFresh() { + const response = await fetch(makeURL('/api/v1/show/script/draft'), { method: 'DELETE' }); + if (response.ok) { + this.$bvModal.hide('draft-resume-modal'); + await this.GET_SCRIPT_CONFIG_STATUS(); + this.requestEdit(); + } else { + log.error('Failed to discard draft'); + this.$toast.error('Failed to discard draft, please try again.'); + } + }, + 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) { + const response = await fetch(makeURL('/api/v1/show/script/draft'), { method: 'DELETE' }); + if (response.ok) { + await this.GET_SCRIPT_CONFIG_STATUS(); + } else { + this.$toast.error('Failed to discard draft, please try again.'); + } + } + }, requestCutEdit() { this.SET_CUT_MODE(true); this.$socket.sendObj({ - OP: 'REQUEST_SCRIPT_EDIT', + OP: 'REQUEST_SCRIPT_CUTS', DATA: {}, }); }, @@ -402,200 +613,94 @@ 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.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: {} }); }, async decrPage() { - if (this.currentEditPage > 1) { - const targetPage = this.currentEditPage - 1; - // Load from backend if not in buffer - if (!Object.keys(this.TMP_SCRIPT).includes(targetPage.toString())) { - await this.LOAD_SCRIPT_PAGE(targetPage); - this.ADD_BLANK_PAGE(targetPage); - } - if (this.TMP_SCRIPT[this.currentEditPageKey].length === 0) { - this.REMOVE_PAGE(this.currentEditPage); - } + if (this.currentEditPage <= 1) return; + if (this.IS_DRAFT_ACTIVE) { this.currentEditPage--; - - // Pre-load previous page - await this.LOAD_SCRIPT_PAGE(this.currentEditPage - 1); + this._syncLocalPageScript(); + return; } + const targetPage = this.currentEditPage - 1; + await this.LOAD_SCRIPT_PAGE(targetPage); + this.currentEditPage--; + await this.LOAD_SCRIPT_PAGE(this.currentEditPage - 1); }, async incrPage() { - this.currentEditPage++; - if (!Object.keys(this.TMP_SCRIPT).includes(this.currentEditPageKey)) { - this.ADD_BLANK_PAGE(this.currentEditPage); + if (this.IS_DRAFT_ACTIVE) { + this.currentEditPage++; + this._syncLocalPageScript(); + return; } - // Pre-load next page + this.currentEditPage++; + await this.LOAD_SCRIPT_PAGE(this.currentEditPage); await this.LOAD_SCRIPT_PAGE(this.currentEditPage + 1); }, - async addNewLine() { - this.ADD_BLANK_LINE({ - pageNo: this.currentEditPage, - lineObj: this.blankLineObj, - }); - 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; - } - }, - 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; - } - }, - 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; - } - }, - 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; - } - }, - async getPreviousLineForIndex(lineIndex) { - // Search backwards from lineIndex - 1 on the current page, skipping deleted lines - for (let i = lineIndex - 1; i >= 0; i--) { - if (!this.DELETED_LINES(this.currentEditPage).includes(i)) { - return this.TMP_SCRIPT[this.currentEditPage][i]; - } - } - - // No non-deleted lines before this index on current page, check previous pages - if (this.currentEditPage > 1) { - let loopPageNo = this.currentEditPage - 1; + addLineOfType(lineType, trackAsLatest = false) { + const lineObj = JSON.parse(JSON.stringify(this.blankLineObj)); + lineObj.line_type = lineType; - while (loopPageNo >= 1) { - let loopPage = null; - if (Object.keys(this.TMP_SCRIPT).includes(loopPageNo.toString())) { - loopPage = this.TMP_SCRIPT[loopPageNo.toString()]; - } else { - await this.LOAD_SCRIPT_PAGE(loopPageNo); - loopPage = this.GET_SCRIPT_PAGE(loopPageNo); - } - // Find the last non-deleted line on this page - const deletedLines = this.DELETED_LINES(loopPageNo); - for (let i = loopPage.length - 1; i >= 0; i--) { - if (!deletedLines.includes(i)) { - return loopPage[i]; - } - } - loopPageNo -= 1; - } - } - return null; - }, - async getNextLineForIndex(lineIndex) { - // Search forwards from lineIndex + 1 on the current page, skipping deleted lines - const currentPageLines = this.TMP_SCRIPT[this.currentEditPage]; - const deletedLines = this.DELETED_LINES(this.currentEditPage); - for (let i = lineIndex + 1; i < currentPageLines.length; i++) { - if (!deletedLines.includes(i)) { - return currentPageLines[i]; - } - } - - // No non-deleted lines after this index on current page, check next pages - // See if there are any edit pages loaded which are after this page - const editPages = Object.keys(this.TMP_SCRIPT) - .map((x) => parseInt(x, 10)) - .sort(); - for (let i = 0; i < editPages.length; i++) { - const editPage = editPages[i]; - if (editPage > this.currentEditPage) { - const pageContent = this.TMP_SCRIPT[editPage.toString()]; - const pageDeletedLines = this.DELETED_LINES(editPage); - // Find the first non-deleted line on this page - for (let j = 0; j < pageContent.length; j++) { - if (!pageDeletedLines.includes(j)) { - return pageContent[j]; - } - } - } + // Inherit act_id/scene_id from the last line on this page + const prevLine = + this.localPageScript.length > 0 + ? this.localPageScript[this.localPageScript.length - 1] + : null; + if (prevLine) { + lineObj.act_id = prevLine.act_id; + lineObj.scene_id = prevLine.scene_id; } - // Edit pages do not have any non-deleted lines, try loading script pages up to the max + // addYDocLine transacts synchronously; observer fires and updates localPageScript + addYDocLine(this.DRAFT_YDOC, this.currentEditPage, lineObj); - for (let i = this.currentEditPage + 1; i <= this.currentMaxPage; i++) { - await this.LOAD_SCRIPT_PAGE(i); - const loopPage = this.GET_SCRIPT_PAGE(i); - const loopPageDeletedLines = this.DELETED_LINES(i); - // Find the first non-deleted line on this page - for (let j = 0; j < loopPage.length; j++) { - if (!loopPageDeletedLines.includes(j)) { - return loopPage[j]; - } - } + const lineIndex = this.localPageScript.length - 1; + const lineIdent = `page_${this.currentEditPage}_line_${lineIndex}`; + this.editPages.push(lineIdent); + this._broadcastAwareness(this.currentEditPage, lineIndex); + if (trackAsLatest) { + this.latestAddedLine = lineIdent; } - - return null; }, - lineChange(line, index) { - this.SET_LINE({ - pageNo: this.currentEditPage, - lineIndex: index, - lineObj: line, - }); + addNewLine() { + this.addLineOfType(LINE_TYPES.DIALOGUE, true); + }, + addStageDirection() { + this.addLineOfType(LINE_TYPES.STAGE_DIRECTION); + }, + addCueLine() { + this.addLineOfType(LINE_TYPES.CUE_LINE); + }, + addSpacing() { + this.addLineOfType(LINE_TYPES.SPACING); }, 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 +708,7 @@ export default { if (index !== -1) { this.editPages.splice(index, 1); } + this._broadcastAwareness(pageIndex, null); if (this.latestAddedLine === lineIdent) { this.addNewLine(); } @@ -611,10 +717,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) { @@ -643,7 +746,7 @@ export default { this.linePartCuts.splice(index, 1); } }, - async insertLineAt(pageIndex, lineIndex, lineType) { + insertLineAt(pageIndex, lineIndex, lineType) { // Map line types to their corresponding add methods const addMethodMap = { [LINE_TYPES.DIALOGUE]: () => this.addNewLine(), @@ -653,8 +756,8 @@ export default { }; // If we're inserting at the end of the page, use the add method instead - if (this.TMP_SCRIPT[pageIndex].length - 1 === lineIndex) { - await addMethodMap[lineType](); + if (this.localPageScript.length - 1 === lineIndex) { + addMethodMap[lineType](); return; } @@ -663,17 +766,17 @@ 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 the line at the insert position + const prevLine = + lineIndex >= 0 && lineIndex < this.localPageScript.length + ? this.localPageScript[lineIndex] + : null; + 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,95 +791,32 @@ 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); + insertDialogueAt(pageIndex, lineIndex) { + this.insertLineAt(pageIndex, lineIndex, LINE_TYPES.DIALOGUE); }, - async insertStageDirectionAt(pageIndex, lineIndex) { - await this.insertLineAt(pageIndex, lineIndex, LINE_TYPES.STAGE_DIRECTION); + insertStageDirectionAt(pageIndex, lineIndex) { + this.insertLineAt(pageIndex, lineIndex, LINE_TYPES.STAGE_DIRECTION); }, - async insertCueLineAt(pageIndex, lineIndex) { - await this.insertLineAt(pageIndex, lineIndex, LINE_TYPES.CUE_LINE); + insertCueLineAt(pageIndex, lineIndex) { + this.insertLineAt(pageIndex, lineIndex, LINE_TYPES.CUE_LINE); }, - async insertSpacingAt(pageIndex, lineIndex) { - await this.insertLineAt(pageIndex, lineIndex, LINE_TYPES.SPACING); + insertSpacingAt(pageIndex, lineIndex) { + this.insertLineAt(pageIndex, lineIndex, LINE_TYPES.SPACING); }, async saveScript() { - if (!this.IS_CUT_MODE) { - if (this.scriptChanges) { - this.savingInProgress = true; - this.totalSavePages = Object.keys(this.TMP_SCRIPT).length; - this.curSavePage = 0; - this.$bvModal.show('save-script'); - - const orderedPages = Object.keys(this.TMP_SCRIPT) - .map((x) => parseInt(x, 10)) - .sort((a, b) => a - b); - - for (const pageNo of orderedPages) { - this.curSavePage = pageNo; - // Check whether the page actually has any lines on it, and if not then skip - const tmpScriptPage = this.TMP_SCRIPT[pageNo.toString()]; - if (tmpScriptPage.length !== 0) { - // Check the actual script to see if the page exists or not - const actualScriptPage = this.GET_SCRIPT_PAGE(pageNo); - if (actualScriptPage.length === 0) { - // New page - const response = await this.SAVE_NEW_PAGE(pageNo); - if (response) { - await this.LOAD_SCRIPT_PAGE(pageNo); - this.ADD_BLANK_PAGE(pageNo); - this.RESET_DELETED(pageNo); - this.RESET_INSERTED(pageNo); - } else { - this.$toast.error('Unable to save script. Please try again.'); - this.saveError = true; - break; - } - } else { - // Existing page, check if anything has changed before saving - const lineDiff = diff(actualScriptPage, tmpScriptPage); - if ( - Object.keys(lineDiff).length > 0 || - this.DELETED_LINES(pageNo).length > 0 || - this.INSERTED_LINES(pageNo).length > 0 - ) { - const response = await this.SAVE_CHANGED_PAGE(pageNo); - if (response) { - await this.LOAD_SCRIPT_PAGE(pageNo); - this.ADD_BLANK_PAGE(pageNo); - this.RESET_DELETED(pageNo); - this.RESET_INSERTED(pageNo); - } else { - this.$toast.error('Unable to save script. Please try again.'); - this.saveError = true; - break; - } - } - } - } - } + // 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; + } - this.savingInProgress = false; - // Re-setup autosave (to reset the timer since we have just saved) - this.setupAutoSave(); - } else { - this.$toast.warning('No changes to save!'); - } - await this.getMaxScriptPage(); - } else { + if (this.IS_CUT_MODE) { this.savingInProgress = true; await this.SAVE_SCRIPT_CUTS(this.linePartCuts); this.resetCutsToSaved(); - // Re-setup autosave (to reset the timer since we have just saved) - this.setupAutoSave(); this.savingInProgress = false; } }, @@ -790,126 +830,132 @@ export default { this.changingPage = false; }, async goToPageInner(pageNo) { + if (this.IS_DRAFT_ACTIVE) { + this.currentEditPage = pageNo; + this._syncLocalPageScript(); + return; + } if (pageNo > 1) { await this.LOAD_SCRIPT_PAGE(parseInt(pageNo, 10) - 1); } await this.LOAD_SCRIPT_PAGE(pageNo); this.currentEditPage = pageNo; - if (!Object.keys(this.TMP_SCRIPT).includes(this.currentEditPageKey)) { - this.ADD_BLANK_PAGE(this.currentEditPage); - } await this.LOAD_SCRIPT_PAGE(parseInt(pageNo, 10) + 1); }, - 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) { - clearInterval(this.autoSaveInterval); - } else if (this.INTERNAL_UUID === this.CURRENT_EDITOR) { - if (this.USER_SETTINGS.enable_script_auto_save) { - if (this.autoSaveInterval == null) { - this.autoSaveInterval = setInterval(this.autosave, autoSaveInterval); - } else { - clearInterval(this.autoSaveInterval); - this.autoSaveInterval = setInterval(this.autosave, autoSaveInterval); - } - } else if (this.autoSaveInterval != null) { - clearInterval(this.autoSaveInterval); - } - } + /** + * Convert a Y.Map line to a plain JS object safe for Vue 2 reactive state. + * Y.Maps must never be stored directly in reactive data — Vue 2 walks their + * internal properties, breaking Yjs internals. + * + * @param {import('yjs').Map} yMap - Y.Map for one script line + * @returns {object|null} Plain line object, or null if yMap is falsy + */ + _ydocLineToPlain(yMap) { + if (!yMap) return null; + const lineId = zeroToNull(yMap.get('_id')); + const partsArray = yMap.get('parts'); + const lineParts = partsArray + ? Array.from({ length: partsArray.length }, (_, i) => { + const p = partsArray.get(i); + if (!p) return null; + return { + id: zeroToNull(p.get('_id') ?? 0), + line_id: lineId, + part_index: p.get('part_index'), + character_id: zeroToNull(p.get('character_id') ?? 0), + character_group_id: zeroToNull(p.get('character_group_id') ?? 0), + line_text: p.get('line_text') ? p.get('line_text').toString() : '', + }; + }).filter(Boolean) + : []; + return { + id: lineId, + line_type: yMap.get('line_type'), + act_id: zeroToNull(yMap.get('act_id') ?? 0), + scene_id: zeroToNull(yMap.get('scene_id') ?? 0), + stage_direction_style_id: zeroToNull(yMap.get('stage_direction_style_id') ?? 0), + line_parts: lineParts, + }; }, - async autosave() { - if (this.isAutoSaving) { + /** + * Rebuild localPageScript from the current page in Y.Doc. + * Called on initial sync and on every Y.Doc change. + */ + _syncLocalPageScript() { + const ydoc = this.DRAFT_YDOC; + if (!ydoc) { + this.localPageScript = []; return; } - this.isAutoSaving = true; - const toastInstance = this.$toast.open({ - type: 'info', - message: 'Performing autosave...', - duration: 0, - dismissible: false, - }); - if (!this.IS_CUT_MODE) { - if (this.scriptChanges) { - let curSavePage = 0; - const orderedPages = Object.keys(this.TMP_SCRIPT) - .map((x) => parseInt(x, 10)) - .sort((a, b) => a - b); - let saveFailure = false; + try { + const pages = ydoc.getMap('pages'); + const pageArray = pages.get(this.currentEditPageKey); + this.localPageScript = pageArray + ? Array.from({ length: pageArray.length }, (_, i) => + this._ydocLineToPlain(pageArray.get(i)) + ).filter(Boolean) + : []; + } catch (e) { + log.error('ScriptEditor: Error syncing local page script from Y.Doc', e); + } + }, + /** + * Set up the Y.Doc → localPageScript bridge after initial sync completes. + * Installs a deep observer on the Y.Doc pages map that rebuilds + * localPageScript whenever Y.Doc changes (local or remote). + */ + setupYDocBridge() { + const ydoc = this.DRAFT_YDOC; + if (!ydoc) return; - for (const pageNo of orderedPages) { - curSavePage = pageNo; - // If the page we are trying to save currently has edits, then stop - // here as we cannot save further changes (ordering is important, so if we have open - // edits on line X, then pages Y > X depend on the changes from X being saved - if (this.pagesWithOpenChanges.includes(pageNo)) { - break; - } - toastInstance.message = `Performing autosave...Saving page ${curSavePage}`; - // Check whether the page actually has any lines on it, and if not then skip - const tmpScriptPage = this.TMP_SCRIPT[pageNo.toString()]; - if (tmpScriptPage.length !== 0) { - // Check the actual script to see if the page exists or not - const actualScriptPage = this.GET_SCRIPT_PAGE(pageNo); - if (actualScriptPage.length === 0) { - // New page - const response = await this.SAVE_NEW_PAGE(pageNo); - if (response) { - await this.LOAD_SCRIPT_PAGE(pageNo); - this.ADD_BLANK_PAGE(pageNo); - this.RESET_DELETED(pageNo); - this.RESET_INSERTED(pageNo); - } else { - saveFailure = true; - break; - } - } else { - // Existing page, check if anything has changed before saving - const lineDiff = diff(actualScriptPage, tmpScriptPage); - if ( - Object.keys(lineDiff).length > 0 || - this.DELETED_LINES(pageNo).length > 0 || - this.INSERTED_LINES(pageNo).length > 0 - ) { - const response = await this.SAVE_CHANGED_PAGE(pageNo); - if (response) { - await this.LOAD_SCRIPT_PAGE(pageNo); - this.ADD_BLANK_PAGE(pageNo); - this.RESET_DELETED(pageNo); - this.RESET_INSERTED(pageNo); - } else { - saveFailure = true; - break; - } - } - } - } - } + const pages = ydoc.getMap('pages'); - if (saveFailure) { - toastInstance.message = 'Autosave failed.'; - toastInstance.type = 'danger'; - } else { - toastInstance.message = `Autosave successfulSaved up to page ${curSavePage}`; - toastInstance.type = 'success'; - } - setTimeout(() => toastInstance.dismiss(), 5000); - } else { - toastInstance.message = 'Autosave successfulNo changes to save'; - toastInstance.type = 'success'; - setTimeout(() => toastInstance.dismiss(), 5000); - } - await this.getMaxScriptPage(); - } else { - await this.SAVE_SCRIPT_CUTS(this.linePartCuts); - this.resetCutsToSaved(); - toastInstance.message = 'Autosave successful'; - toastInstance.type = 'success'; - setTimeout(() => toastInstance.dismiss(), 5000); + // Populate localPageScript from current Y.Doc state + this._syncLocalPageScript(); + + if (this.HAS_DRAFT) { + this.$store.commit('SET_DRAFT_DIRTY', true); } - this.isAutoSaving = false; + + const observer = () => { + this._syncLocalPageScript(); + this.$store.commit('SET_DRAFT_DIRTY', true); + }; + + pages.observeDeep(observer); + this.ydocObserverCleanup = () => pages.unobserveDeep(observer); + + log.info('ScriptEditor: Y.Doc bridge established'); + }, + teardownYDocBridge() { + if (this.ydocObserverCleanup) { + this.ydocObserverCleanup(); + this.ydocObserverCleanup = null; + } + this.localPageScript = []; + }, + 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); + }, + _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, + }); + }, + editingUsersForLine(lineIndex) { + const key = `${this.currentEditPage}:${lineIndex}`; + const editors = this.DRAFT_LINE_EDITORS[key] || []; + const currentUserId = this.CURRENT_USER ? this.CURRENT_USER.id : null; + return editors.filter((e) => e.userId !== currentUserId); }, calculateNavbarHeight() { const navbar = document.querySelector('.navbar'); @@ -919,27 +965,14 @@ export default { this.navbarHeight = 56; } }, - ...mapMutations([ - 'REMOVE_PAGE', - 'ADD_BLANK_LINE', - 'SET_LINE', - 'DELETE_LINE', - 'RESET_DELETED', - 'SET_CUT_MODE', - 'INSERT_BLANK_LINE', - 'RESET_INSERTED', - ]), + ...mapMutations(['SET_CUT_MODE', 'SET_DRAFT_SAVING']), ...mapActions([ 'GET_SCENE_LIST', 'GET_ACT_LIST', 'GET_CHARACTER_LIST', 'GET_CHARACTER_GROUP_LIST', 'LOAD_SCRIPT_PAGE', - 'ADD_BLANK_PAGE', 'GET_SCRIPT_CONFIG_STATUS', - 'RESET_TO_SAVED', - 'SAVE_NEW_PAGE', - 'SAVE_CHANGED_PAGE', 'GET_CUTS', 'SAVE_SCRIPT_CUTS', 'GET_STAGE_DIRECTION_STYLES', @@ -947,6 +980,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 +1000,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..9ab97f8d 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?