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 @@ + + 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 - + + + + + + + + Act Scene @@ -63,8 +88,8 @@
-