diff --git a/bun.lock b/bun.lock index d5ee1c76..36e752df 100644 --- a/bun.lock +++ b/bun.lock @@ -10,7 +10,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.6.5", - "@celldl/editor": "^0.20261029.0", + "@celldl/editor": "^0.20261105.0", "@celldl/rdf": "^1.20260822.0", "@electron-toolkit/tsconfig": "^2.0.0", "@electron-toolkit/utils": "^4.0.0", @@ -118,7 +118,7 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.6.5", "", { "os": "win32", "cpu": "x64" }, "sha512-07LaCeN9vvmbAAMOMsUjMfL5UbsCDkvVeyMJIgwNRc0xYqhizBGkjVzx05LYEwCXl2AiHcdzqVPR4IOYAsI1QQ=="], - "@celldl/editor": ["@celldl/editor@0.20261029.0", "", { "dependencies": { "@atomic-ehr/ucum": "^0.2.5", "@celldl/metadata": "^1.20260822.0", "@celldl/rdf": "^1.20260822.0", "@ctrl/tinycolor": "^4.2.0", "@lucide/vue": "^1.39.0", "@mathjax/src": "^4.1.3", "@vueuse/core": "14.3.0", "buffer": "^6.0.3", "flatbush": "^4.5.0", "graphology": "^0.26.0", "isomorphic-fetch": "^3.0.0", "jsonschema": "^1.5.0", "mini-svg-data-uri": "^1.4.4", "primeicons": "^7.0.0", "primevue": "^4.5.5", "rbush": "^4.0.1", "svg-intersections": "github:dbrnz/svg-intersections#fix-numbers", "svg-path-commander": "^2.1.11", "systeminformation": "^5.27.11", "transformation-matrix": "^3.1.0", "tslog": "^5.4.0", "ua-parser-js": "^2.0.8", "vue": "^3.5.44", "vue-tippy": "^6.7.1" } }, "sha512-T0rDG1hksJrYjRO60q6aOiq01SyjSelkwralFh3YLd80WEWla1v7WpiiWRkd4Tu8/Xu6OdDgiybKuGniN1Z7BA=="], + "@celldl/editor": ["@celldl/editor@0.20261105.0", "", { "dependencies": { "@atomic-ehr/ucum": "^0.2.5", "@celldl/metadata": "^1.20260822.0", "@celldl/rdf": "^1.20260822.0", "@ctrl/tinycolor": "^4.2.0", "@lucide/vue": "^1.39.0", "@mathjax/src": "^4.1.3", "@vueuse/core": "14.3.0", "buffer": "^6.0.3", "flatbush": "^4.5.0", "graphology": "^0.26.0", "isomorphic-fetch": "^3.0.0", "jsonschema": "^1.5.0", "mini-svg-data-uri": "^1.4.4", "primeicons": "^7.0.0", "primevue": "^4.5.5", "rbush": "^4.0.1", "svg-intersections": "github:dbrnz/svg-intersections#fix-numbers", "svg-path-commander": "^2.1.11", "systeminformation": "^5.27.11", "transformation-matrix": "^3.1.0", "tslog": "^5.4.0", "ua-parser-js": "^2.0.8", "vue": "^3.5.44", "vue-tippy": "^6.7.1" } }, "sha512-xTUm7r56Q9uNmJtRc/iyCzim1S0fKfFa6AH4VLzuKtWy3qAYapKeD/1iHV0U1YdmeG0icJDi6reUW2PB8cS8bg=="], "@celldl/metadata": ["@celldl/metadata@1.20260822.0", "", { "dependencies": { "@celldl/rdf": "^1.20260822.0" } }, "sha512-xqew4gPCrE4JbkmHWhjS3+Khj70jx7i/L/qvFVf3CrdhVAUrH4Wi1Bodcbz4DQHsnEQUHDcBV8oXvJhXjSg1cg=="], diff --git a/package.json b/package.json index 20f1dec1..667ec400 100644 --- a/package.json +++ b/package.json @@ -23,7 +23,7 @@ "url": "git+https://github.com/opencor/webapp.git" }, "type": "module", - "version": "1.20261105.0", + "version": "1.20261105.1", "engines": { "bun": ">=1.2.0" }, @@ -54,7 +54,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.6.5", - "@celldl/editor": "^0.20261029.0", + "@celldl/editor": "^0.20261105.0", "@celldl/rdf": "^1.20260822.0", "@electron-toolkit/tsconfig": "^2.0.0", "@electron-toolkit/utils": "^4.0.0", diff --git a/src/renderer/bun.lock b/src/renderer/bun.lock index 89bc1377..94d3339b 100644 --- a/src/renderer/bun.lock +++ b/src/renderer/bun.lock @@ -6,7 +6,7 @@ "name": "@opencor/opencor", "devDependencies": { "@biomejs/biome": "^2.6.5", - "@celldl/editor": "^0.20261029.0", + "@celldl/editor": "^0.20261105.0", "@celldl/rdf": "^1.20260822.0", "@opencor/libopencor-types": "^1.20261029.0", "@physiomelinks/bg2cellml": "^0.6.2", @@ -80,7 +80,7 @@ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.6.5", "", { "os": "win32", "cpu": "x64" }, "sha512-07LaCeN9vvmbAAMOMsUjMfL5UbsCDkvVeyMJIgwNRc0xYqhizBGkjVzx05LYEwCXl2AiHcdzqVPR4IOYAsI1QQ=="], - "@celldl/editor": ["@celldl/editor@0.20261029.0", "", { "dependencies": { "@atomic-ehr/ucum": "^0.2.5", "@celldl/metadata": "^1.20260822.0", "@celldl/rdf": "^1.20260822.0", "@ctrl/tinycolor": "^4.2.0", "@lucide/vue": "^1.39.0", "@mathjax/src": "^4.1.3", "@vueuse/core": "14.3.0", "buffer": "^6.0.3", "flatbush": "^4.5.0", "graphology": "^0.26.0", "isomorphic-fetch": "^3.0.0", "jsonschema": "^1.5.0", "mini-svg-data-uri": "^1.4.4", "primeicons": "^7.0.0", "primevue": "^4.5.5", "rbush": "^4.0.1", "svg-intersections": "github:dbrnz/svg-intersections#fix-numbers", "svg-path-commander": "^2.1.11", "systeminformation": "^5.27.11", "transformation-matrix": "^3.1.0", "tslog": "^5.4.0", "ua-parser-js": "^2.0.8", "vue": "^3.5.44", "vue-tippy": "^6.7.1" } }, "sha512-T0rDG1hksJrYjRO60q6aOiq01SyjSelkwralFh3YLd80WEWla1v7WpiiWRkd4Tu8/Xu6OdDgiybKuGniN1Z7BA=="], + "@celldl/editor": ["@celldl/editor@0.20261105.0", "", { "dependencies": { "@atomic-ehr/ucum": "^0.2.5", "@celldl/metadata": "^1.20260822.0", "@celldl/rdf": "^1.20260822.0", "@ctrl/tinycolor": "^4.2.0", "@lucide/vue": "^1.39.0", "@mathjax/src": "^4.1.3", "@vueuse/core": "14.3.0", "buffer": "^6.0.3", "flatbush": "^4.5.0", "graphology": "^0.26.0", "isomorphic-fetch": "^3.0.0", "jsonschema": "^1.5.0", "mini-svg-data-uri": "^1.4.4", "primeicons": "^7.0.0", "primevue": "^4.5.5", "rbush": "^4.0.1", "svg-intersections": "github:dbrnz/svg-intersections#fix-numbers", "svg-path-commander": "^2.1.11", "systeminformation": "^5.27.11", "transformation-matrix": "^3.1.0", "tslog": "^5.4.0", "ua-parser-js": "^2.0.8", "vue": "^3.5.44", "vue-tippy": "^6.7.1" } }, "sha512-xTUm7r56Q9uNmJtRc/iyCzim1S0fKfFa6AH4VLzuKtWy3qAYapKeD/1iHV0U1YdmeG0icJDi6reUW2PB8cS8bg=="], "@celldl/metadata": ["@celldl/metadata@1.20260822.0", "", { "dependencies": { "@celldl/rdf": "^1.20260822.0" } }, "sha512-xqew4gPCrE4JbkmHWhjS3+Khj70jx7i/L/qvFVf3CrdhVAUrH4Wi1Bodcbz4DQHsnEQUHDcBV8oXvJhXjSg1cg=="], diff --git a/src/renderer/changelog.json b/src/renderer/changelog.json index a0ff07c4..eace1538 100644 --- a/src/renderer/changelog.json +++ b/src/renderer/changelog.json @@ -1,4 +1,13 @@ [ + { + "version": "1.20261105.1", + "changes": [ + { + "type": "improved", + "description": "Switching between CellDL files no longer loses their selection, zoom, scroll position, undo history, etc." + } + ] + }, { "version": "1.20261105.0", "changes": [ diff --git a/src/renderer/package.json b/src/renderer/package.json index 32c41cad..19abfbce 100644 --- a/src/renderer/package.json +++ b/src/renderer/package.json @@ -42,7 +42,7 @@ }, "./style.css": "./dist/opencor.css" }, - "version": "1.20261105.0", + "version": "1.20261105.1", "libopencorVersion": "1.20261029.0", "scripts": { "build": "vite build && bun scripts/generate.version.ts", @@ -63,7 +63,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.6.5", - "@celldl/editor": "^0.20261029.0", + "@celldl/editor": "^0.20261105.0", "@celldl/rdf": "^1.20260822.0", "@opencor/libopencor-types": "^1.20261029.0", "@physiomelinks/bg2cellml": "^0.6.2", diff --git a/src/renderer/scripts/celldl.plugin.ts b/src/renderer/scripts/celldl.plugin.ts index 0d5062b8..436ce4b5 100644 --- a/src/renderer/scripts/celldl.plugin.ts +++ b/src/renderer/scripts/celldl.plugin.ts @@ -5,17 +5,17 @@ import type { Plugin } from 'vite'; // Note: CellDL Editor's stylesheet has some global rules that target PrimeVue's components (e.g., // `.p-checkbox-checked .p-checkbox-icon { color: red !important; }`) or generic class names (e.g., `.error`), // which would otherwise affect the rest of OpenCOR (see https://github.com/CellDL/CellDLEditor/issues/9). So, we -// scope those rules to the elements in which our CellDL Editor can be (see CellDLEditorView.vue and -// CellDLEditorHostComponent.vue). We leave alone the rules that only target CellDL Editor's own elements (incl. -// tippy.js' tooltips, which are appended to the document body), its icons (incl. PrimeIcons, which we also use), -// or its CSS variables, as well as the rules in Tailwind CSS' layers (i.e. Tailwind CSS' variables and standard +// scope those rules to CellDL Editor's root element, which has the `celldl-editor` class. We leave alone the +// rules that only target CellDL Editor's own elements (incl. tippy.js' tooltips, which are appended to the +// document body, and its diagram, which is in a shadow tree), its icons (incl. PrimeIcons, which we also use), or +// its CSS variables, as well as the rules in Tailwind CSS' layers (i.e. Tailwind CSS' variables and standard // utility classes, e.g., `.hidden`, which, being layered, lose to any unlayered rule) and in keyframes. Our // plugin must be enforced as 'pre', so that it processes CellDL Editor's stylesheet before Vite does. const CELLDL_EDITOR_CSS = '/@celldl/editor/dist/'; const CELLDL_EDITOR_SCOPE = '.celldl-editor'; const UNSCOPED_SELECTOR = - /^(:root|\[data-tippy-root\]|\.tippy-|\.tooltip-|\.pi\b|\.pi-|\.ci\b|\.ci-|\.lucide|#celldl-|\.celldl-|#svg-container|#status-)/; + /^(:root|\[data-tippy-root\]|\.tippy-|\.tooltip-|\.pi\b|\.pi-|\.ci\b|\.ci-|\.lucide|#celldl-|\.celldl-|:host\b)/; export const scopeCellDLEditorCssPlugin = (): Plugin => { return { diff --git a/src/renderer/src/common/celldlEditorHost.ts b/src/renderer/src/common/celldlEditorHost.ts deleted file mode 100644 index eede3a35..00000000 --- a/src/renderer/src/common/celldlEditorHost.ts +++ /dev/null @@ -1,383 +0,0 @@ -import type { CellDLEditorCommand, EditorData } from '@celldl/editor'; -import * as vueusecore from '@vueuse/core'; - -import * as vue from 'vue'; - -import * as locApi from '../libopencor/locApi'; - -import * as common from './common'; - -// CellDL Editor is shared by all our CellDL Editor views, including those of different OpenCOR instances. -// Note: CellDL Editor listens to keyboard and pointer events on the window and the document, never stops listening to -// them, and relies on some global state (e.g., its undo/redo history). This means that if we had one CellDL -// Editor per CellDL Editor view, then pressing Backspace in one file tab could, for instance, delete the selected -// object(s) in another (hidden) file tab. So, instead, we have only one CellDL Editor (see -// CellDLEditorHostComponent.vue), which we move to the CellDL Editor view that is visible, and in which we open -// the corresponding diagram. When no CellDL Editor view is visible, we close the diagram, so that CellDL Editor -// cannot modify anything. - -export interface ICellDLEditorClient { - name: string; - container: HTMLElement | null; - data: string; - active: boolean; - activation: number; - loaded: boolean; - editCount: number; - issues: locApi.IIssue[]; -} - -export const useCellDLEditorHost = vueusecore.createGlobalState(() => { - const clients = vue.shallowReactive(new Set()); - const ready = vue.ref(false); - const switching = vue.ref(false); - const editorCommand = vue.shallowRef(); - let loadedClient: ICellDLEditorClient | null = null; - let openedClient: ICellDLEditorClient | null = null; - - // The client which diagram should be shown, if any. - // Note: several clients may be active at the same time (e.g., if several OpenCOR instances each show a CellDL file), - // in which case we show the diagram of the client that was activated last (see activate()). - - const targetClient = vue.computed(() => { - let res: ICellDLEditorClient | null = null; - - for (const client of clients) { - if (client.active && client.container && !client.issues.length && client.activation > (res?.activation ?? -1)) { - res = client; - } - } - - return res; - }); - - // Mark the given client as the one that was activated last. - - let activationNumber = 0; - - const activate = (client: ICellDLEditorClient): void => { - client.activation = ++activationNumber; - }; - - // The container in which the CellDL Editor should be mounted, if any. - // Note: when a CellDL Editor view gets deactivated, KeepAlive moves the view (and therefore our CellDL Editor) out of - // the document. Our CellDL Editor only gets moved back into the document once our container has been updated, - // but this all happens within the same (synchronous) Vue flush, so neither the user nor any asynchronous code - // (e.g., a ResizeObserver) can ever see our CellDL Editor out of the document. - - const container = vue.computed(() => { - return targetClient.value?.container ?? null; - }); - - // Mount our CellDL Editor, if needed. - // Note: our CellDL Editor must never be destroyed since it never stops listening to events (see above), yet an - // OpenCOR instance may get unmounted (e.g., when OpenCOR is used as a library). So, rather than mounting our - // CellDL Editor within an OpenCOR instance, we render it in its own element, which we append to the document's - // body and in which our CellDL Editor is "parked" when no CellDL Editor view is visible. We render it using the - // app context of our first client, so that it has access to PrimeVue, among other things. Our element has the - // "opencor" class, so that our Tailwind CSS classes also apply to our CellDL Editor while it is parked (see - // tailwind.config.ts). - - let mounted = false; - - const mount = (appContext: vue.AppContext | null): void => { - if (mounted) { - return; - } - - mounted = true; - - const element = document.createElement('div'); - - element.className = 'opencor'; - element.style.cssText = 'position: fixed; inset: 0; visibility: hidden; pointer-events: none;'; - - document.body.appendChild(element); - - const vnode = vue.createVNode( - vue.defineAsyncComponent(() => import('../components/CellDLEditorHostComponent.vue')) - ); - - vnode.appContext = appContext; - - vue.render(vnode, element); - }; - - // Report the failure of our CellDL Editor, if any, to the given client. - - let failure: string | null = null; - - const reportFailure = (client: ICellDLEditorClient): void => { - if (failure !== null) { - client.issues.push({ - type: locApi.EIssueType.ERROR, - description: failure - }); - } - }; - - // Register/unregister a client, mounting our CellDL Editor when registering our first client. - - const register = (client: ICellDLEditorClient, appContext: vue.AppContext | null): void => { - clients.add(client); - - reportFailure(client); - mount(appContext); - }; - - const unregister = (client: ICellDLEditorClient): void => { - clients.delete(client); - }; - - // Run our operations one after the other since CellDL Editor can only handle one command at a time. - - let lastOperation: Promise = Promise.resolve(); - - const enqueue = (operation: () => Promise): Promise => { - const res = lastOperation.then(operation); - - lastOperation = res.catch(() => undefined); - - return res; - }; - - // Send a command to CellDL Editor. - // Note: CellDL Editor doesn't tell us when it is done with a command, but it handles our command as soon as it gets - // it and then only needs a tick to finish handling it, so we wait for the next animation frame to be safe. - - const sendCommand = async (command: CellDLEditorCommand): Promise => { - editorCommand.value = command; - - await vue.nextTick(); - await common.waitForNextAnimationFrame(); - }; - - // Retrieve the diagram from CellDL Editor. - // Note #1: we give up if CellDL Editor doesn't provide the diagram in time since, otherwise, our queue of operations - // would be blocked forever. - // Note #2: we also give up if CellDL Editor provides no diagram (e.g., if it failed to serialise it) since, - // otherwise, we would replace the last version we have of the diagram with nothing. - - const DATA_REQUEST_TIMEOUT = 3 * 1000; // 3 seconds. - - let dataRequestNumber = 0; - const dataRequests = new Map void>(); - - const diagramData = (): Promise => { - return new Promise((resolve, reject) => { - const kind = `opencor-${++dataRequestNumber}`; - const timeoutId = window.setTimeout(() => { - dataRequests.delete(kind); - - reject(new Error('CellDL Editor did not provide the diagram in time.')); - }, DATA_REQUEST_TIMEOUT); - - dataRequests.set(kind, (data: string | undefined) => { - window.clearTimeout(timeoutId); - - if (data) { - resolve(data); - } else { - reject(new Error('CellDL Editor did not provide the diagram.')); - } - }); - - editorCommand.value = { - command: 'file', - options: { - action: 'data', - kind - } - }; - }); - }; - - const onEditorData = (data: EditorData): void => { - const settle = dataRequests.get(data.kind ?? ''); - - if (settle) { - dataRequests.delete(data.kind ?? ''); - - settle(data.data); - } - }; - - // Report an error to the client which diagram we last asked CellDL Editor to open. - // Note: CellDL Editor opens a diagram asynchronously, so it may report an error after we have considered the diagram - // to be loaded. In that case, our target client changes (since it now has some issues), so we sync again and - // close the diagram (see sync()). - - const onEditorState = (state: { error: string }): void => { - openedClient?.issues.push({ - type: locApi.EIssueType.ERROR, - description: state.error - }); - }; - - // Count the edits made to the diagram of our loaded client, i.e. whenever CellDL Editor lets us know that it has been - // edited. - // Note: CellDL Editor doesn't tell us which diagram has been edited, but it can only be the one that it holds, i.e. - // the one of our loaded client. This is also the case while we are switching until we have retrieved the - // diagram of our loaded client (see sync()). Indeed, our CellDL Editor is then invisible (see - // CellDLEditorHostComponent.vue), but an edit may still complete (e.g., CellDL Editor updates the properties of - // an object asynchronously) and CellDL Editor still handles keyboard events (e.g., Backspace to delete the - // selected objects). - - let loadedEditCount = 0; - - document.addEventListener('file-edited', () => { - if (loadedClient) { - ++loadedClient.editCount; - } - }); - - // Also count undoing/redoing something as an edit. - // Note #1: CellDL Editor doesn't let us know when something gets undone/redone, so we mirror its handling of the - // keyboard, i.e. Ctrl/Cmd+Z to undo and Ctrl/Cmd+Shift+Z to redo. - // Note #2: CellDL Editor clears its undo history whenever it opens a diagram, so there is nothing to undo/redo unless - // the diagram of our loaded client has been edited since it was loaded. Otherwise, we may still count an - // undo/redo that doesn't actually do anything (e.g., if everything has already been undone), but we would - // rather consider a diagram modified when it isn't than the other way round. - - window.addEventListener('keydown', (event: KeyboardEvent) => { - if ( - loadedClient && - loadedClient.editCount !== loadedEditCount && - common.isCtrlOrCmd(event) && - (event.key === 'z' || event.key === 'Z') - ) { - ++loadedClient.editCount; - } - }); - - // Open the diagram of our target client in CellDL Editor, after having saved the diagram of the previously loaded - // client, if any. - // Note: we only ever have one sync operation queued or running (i.e. while we are switching) since that operation - // keeps going until it has caught up with our target client, should it change in the meantime. - - const sync = (): void => { - if (!ready.value || switching.value || targetClient.value === loadedClient) { - return; - } - - switching.value = true; - - void enqueue(async () => { - try { - for (let client = targetClient.value; ready.value && client !== loadedClient; client = targetClient.value) { - // Save the diagram of the previously loaded client, if any. - // Note #1: if we cannot retrieve the diagram, then we keep the last version we have of it (i.e. we lose its - // latest changes) rather than prevent other diagrams from being opened. - // Note #2: if CellDL Editor reported an error after we considered the diagram to be loaded (see - // onEditorState()), then CellDL Editor doesn't (properly) hold the diagram, so we don't save it. - - if (loadedClient) { - if (clients.has(loadedClient) && !loadedClient.issues.length) { - try { - loadedClient.data = await diagramData(); - } catch (error: unknown) { - console.warn( - `OpenCOR: failed to retrieve the diagram of ${loadedClient.name}:`, - common.formatError(error) - ); - } - } - - loadedClient.loaded = false; - loadedClient = null; - } - - // Open the diagram of our target client in CellDL Editor. - - if (client) { - openedClient = client; - - await sendCommand({ - command: 'file', - options: { - action: 'open', - data: client.data, - name: client.name - } - }); - - // If we successfully opened the diagram, then we are done. - - if (!client.issues.length) { - client.loaded = true; - loadedClient = client; - loadedEditCount = client.editCount; - - continue; - } - } - - // Close the diagram if there is no target client or if we couldn't open its diagram, in which case CellDL - // Editor would still show the previous diagram. - - openedClient = null; - - await sendCommand({ - command: 'file', - options: { - action: 'close' - } - }); - } - } finally { - switching.value = false; - } - }); - }; - - // Sync our CellDL Editor with our target client whenever either of them changes. - - vue.watch([ready, targetClient], sync); - - // Mark our CellDL Editor as ready. - - const setReady = (): void => { - ready.value = true; - }; - - // Mark our CellDL Editor as having failed to get ready (e.g., if CellDL Editor failed to initialise itself), and let - // our clients know about it since, otherwise, they would wait for it forever. - - const setFailed = (error: unknown): void => { - if (ready.value || failure !== null) { - return; - } - - failure = `CellDL Editor could not be loaded (${common.formatError(error)}).`; - - for (const client of clients) { - reportFailure(client); - } - }; - - // Return the (possibly edited) diagram of the given client. - - const clientData = (client: ICellDLEditorClient): Promise => { - return enqueue(async () => { - if (client === loadedClient && !client.issues.length) { - client.data = await diagramData(); - } - - return client.data; - }); - }; - - return { - activate, - clientData, - container, - editorCommand, - onEditorData, - onEditorState, - register, - setFailed, - setReady, - switching, - unregister - }; -}); diff --git a/src/renderer/src/common/celldlEditorPool.ts b/src/renderer/src/common/celldlEditorPool.ts new file mode 100644 index 00000000..a1e6314f --- /dev/null +++ b/src/renderer/src/common/celldlEditorPool.ts @@ -0,0 +1,373 @@ +import type { CellDLEditorCommand, EditorData } from '@celldl/editor'; +import * as vueusecore from '@vueuse/core'; + +import * as vue from 'vue'; + +import * as common from './common'; + +// Our CellDL Editor views get their CellDL Editor from a pool of CellDL Editors. +// Note #1: CellDL Editor never stops listening to some events on the window and the document, i.e. it never really gets +// destroyed. So, rather than having a CellDL Editor per CellDL Editor view, which would mean leaking a CellDL +// Editor every time a CellDL Editor view gets unmounted (e.g., when closing a CellDL file), a CellDL Editor +// view acquires a CellDL Editor from our pool when it gets mounted and releases it when it gets unmounted. We +// then close its diagram (which also clears its undo history), so that it can be reused. Our pool only creates +// a CellDL Editor when it has no other spare one (see acquire()), so it never has more CellDL Editors than the +// maximum number of CellDL Editor views that have been mounted at the same time, plus one. +// Note #2: CellDL Editor listens to keyboard events on the window and lets only one of its instances handle them, i.e. +// the instance in which a pointer was last pressed or, by default, the first instance to have been mounted. +// This means that pressing Backspace in one file tab could, for instance, delete the selected object(s) in +// another (hidden) file tab. So, whenever the CellDL Editor view that is visible changes, we simulate a +// pointer press in its CellDL Editor, so that it is the one that handles keyboard events. When no CellDL +// Editor view is visible, we do the same with a spare CellDL Editor, which has nothing to edit. + +export interface ICellDLEditorClient { + container: HTMLElement | null; + active: boolean; + activation: number; + loaded: boolean; + onError: (message: string) => void; + onFileEdited: () => void; +} + +export interface ICellDLEditor { + open: (data: string, name: string) => Promise; + data: () => Promise; +} + +export interface IPooledCellDLEditor extends ICellDLEditor { + id: number; + client: ICellDLEditorClient | null; + command: CellDLEditorCommand | undefined; + root: HTMLElement | null; + close: () => void; + onEditorData: (data: EditorData) => void; + onEditorReady: (root: HTMLElement | null) => void; + onEditorState: (state: { error: string }) => void; + onFileEdited: () => void; +} + +const DATA_REQUEST_TIMEOUT = 3 * 1000; // 3 seconds. + +export const useCellDLEditorPool = vueusecore.createGlobalState(() => { + const editors = vue.shallowReactive([]); + let failure: string | null = null; + let anyReady = false; + const readyRejecters = new Set<(error: Error) => void>(); + + // Create a CellDL Editor, which gets rendered by our pool component (see CellDLEditorPoolComponent.vue). + + let editorNumber = 0; + + const createEditor = (): IPooledCellDLEditor => { + // Run our operations one after the other since CellDL Editor can only handle one command at a time. + + let lastOperation: Promise = Promise.resolve(); + + const enqueue = (operation: () => Promise): Promise => { + const res = lastOperation.then(operation); + + lastOperation = res.catch(() => undefined); + + return res; + }; + + // Wait for our CellDL Editor to be ready, unless CellDL Editor failed to get ready (see setFailed()). + + let resolveReady: () => void = () => {}; + let rejectReady: (error: Error) => void = () => {}; + const ready = new Promise((resolve, reject) => { + resolveReady = resolve; + rejectReady = reject; + }); + + ready.catch(() => undefined); + + if (failure !== null) { + rejectReady(new Error(failure)); + } else { + readyRejecters.add(rejectReady); + } + + // Send a command to our CellDL Editor. + // Note: CellDL Editor doesn't tell us when it is done with a command, but it handles our command as soon as it gets + // it and then only needs a tick to finish handling it, so we wait for the next animation frame to be safe. + + const sendCommand = async (command: CellDLEditorCommand): Promise => { + editor.command = command; + + await vue.nextTick(); + await common.waitForNextAnimationFrame(); + }; + + // Retrieve the diagram from our CellDL Editor. + // Note #1: we give up if CellDL Editor doesn't provide the diagram in time since, otherwise, our queue of + // operations would be blocked forever. + // Note #2: we also give up if CellDL Editor provides no diagram (e.g., if it failed to serialise it) since, + // otherwise, we would provide nothing as the diagram. + + let dataRequestNumber = 0; + const dataRequests = new Map void>(); + + const diagramData = (): Promise => { + return new Promise((resolve, reject) => { + const kind = `opencor-${++dataRequestNumber}`; + const timeoutId = window.setTimeout(() => { + dataRequests.delete(kind); + + reject(new Error('CellDL Editor did not provide the diagram in time.')); + }, DATA_REQUEST_TIMEOUT); + + dataRequests.set(kind, (data: string | undefined) => { + window.clearTimeout(timeoutId); + + if (data) { + resolve(data); + } else { + reject(new Error('CellDL Editor did not provide the diagram.')); + } + }); + + editor.command = { + command: 'file', + options: { + action: 'data', + kind + } + }; + }); + }; + + // The client which diagram we last asked our CellDL Editor to open, if any. + // Note: our CellDL Editor may report an error or an edit after it has been released (e.g., if it gets released + // while it is opening a diagram), in which case we must report it neither to the client that released our + // CellDL Editor nor to the client that has acquired it since. So, we forget about our opened client as soon + // as our CellDL Editor gets released (see close()). + + let openedClient: ICellDLEditorClient | null = null; + + const editor: IPooledCellDLEditor = vue.shallowReactive({ + id: ++editorNumber, + client: null, + command: undefined, + root: null, + open: (data: string, name: string): Promise => { + const client = editor.client; + + // Note: our CellDL Editor may get released before it gets to open the diagram (e.g., if our client gets + // unmounted while our CellDL Editor is getting ready), in which case there is no point in opening it and + // we must not do it since our CellDL Editor may have been acquired by another client in the meantime. + + return enqueue(async () => { + await ready; + + if (!client || editor.client !== client) { + return; + } + + openedClient = client; + + await sendCommand({ + command: 'file', + options: { + action: 'open', + data, + name + } + }); + }); + }, + data: (): Promise => { + return enqueue(diagramData); + }, + close: (): void => { + openedClient = null; + + void enqueue(async () => { + await ready; + + await sendCommand({ + command: 'file', + options: { + action: 'close' + } + }); + }).catch(() => undefined); + }, + onEditorData: (data: EditorData): void => { + const settle = dataRequests.get(data.kind ?? ''); + + if (settle) { + dataRequests.delete(data.kind ?? ''); + + settle(data.data); + } + }, + onEditorReady: (root: HTMLElement | null): void => { + anyReady = true; + + readyRejecters.delete(rejectReady); + + // Note: Vue doesn't make DOM elements reactive, but we mark our root element as raw to be safe. + + editor.root = root ? vue.markRaw(root) : null; + + resolveReady(); + }, + onEditorState: (state: { error: string }): void => { + openedClient?.onError(state.error); + }, + onFileEdited: (): void => { + openedClient?.onFileEdited(); + } + }); + + editors.push(editor); + + return editor; + }; + + // Mount our pool component, if needed. + // Note: our CellDL Editors must never be destroyed (see above), yet an OpenCOR instance may get unmounted (e.g., when + // OpenCOR is used as a library). So, rather than mounting our pool component within an OpenCOR instance, we + // render it in its own invisible element, which we append to the document's body and in which a CellDL Editor + // is "parked" when it is not used by a CellDL Editor view. We render it using the app context of our first + // client, so that it has access to PrimeVue, among other things. Our element has the "opencor" class, so that + // our Tailwind CSS classes also apply to our CellDL Editors while they are parked. + + let mounted = false; + + const mount = (appContext: vue.AppContext | null): void => { + if (mounted) { + return; + } + + mounted = true; + + const element = document.createElement('div'); + + element.className = 'opencor'; + element.style.cssText = 'position: fixed; inset: 0; visibility: hidden; pointer-events: none;'; + + document.body.appendChild(element); + + const vnode = vue.createVNode( + vue.defineAsyncComponent(() => import('../components/CellDLEditorPoolComponent.vue')) + ); + + vnode.appContext = appContext; + + vue.render(vnode, element); + }; + + // Acquire a CellDL Editor for the given client, making sure that we still have a spare CellDL Editor afterwards (see + // above). + + const spareEditor = (): IPooledCellDLEditor | undefined => { + return editors.find((editor) => !editor.client); + }; + + const acquire = (client: ICellDLEditorClient, appContext: vue.AppContext | null): ICellDLEditor => { + mount(appContext); + + const res = spareEditor() ?? createEditor(); + + res.client = client; + + if (!spareEditor()) { + createEditor(); + } + + if (failure !== null) { + client.onError(failure); + } + + return res; + }; + + // Release the given CellDL Editor and close its diagram, so that it can be reused. + + const release = (editor: ICellDLEditor): void => { + const pooledEditor = editors.find((pooledEditor) => pooledEditor === editor); + + if (pooledEditor) { + pooledEditor.client = null; + + pooledEditor.close(); + } + }; + + // Mark our CellDL Editors as having failed to get ready (e.g., if CellDL Editor failed to initialise itself), and let + // our clients know about it since, otherwise, they would wait for it forever. + + const setFailed = (error: unknown): void => { + if (anyReady || failure !== null) { + return; + } + + failure = `CellDL Editor could not be loaded (${common.formatError(error)}).`; + + for (const rejectReady of readyRejecters) { + rejectReady(new Error(failure)); + } + + readyRejecters.clear(); + + for (const editor of editors) { + editor.client?.onError(failure); + } + }; + + // The client which CellDL Editor should handle keyboard events, if any. + // Note: several clients may be active at the same time (e.g., if several OpenCOR instances each show a CellDL file), + // in which case we use the client that was activated last (see activate()). + + const targetClient = vue.computed(() => { + let res: ICellDLEditorClient | null = null; + + for (const editor of editors) { + const client = editor.client; + + if (client?.active && client.loaded && editor.root && client.activation > (res?.activation ?? -1)) { + res = client; + } + } + + return res; + }); + + // Mark the given client as the one that was activated last. + + let activationNumber = 0; + + const activate = (client: ICellDLEditorClient): void => { + client.activation = ++activationNumber; + }; + + // Simulate a pointer press in the CellDL Editor that should handle keyboard events whenever it changes, i.e. the + // CellDL Editor of our target client or, if there is none, a spare CellDL Editor. + // Note: CellDL Editor checks whether a pointer press occurred within one of its instances during the capture phase of + // the document, so our pointer press doesn't need to bubble up (and it is better that it doesn't since nothing + // else should handle it). + + vue.watch( + () => { + const client = targetClient.value; + + return ( + editors.find((editor) => (client ? editor.client === client : !editor.client && editor.root))?.root ?? null + ); + }, + (root) => { + root?.dispatchEvent(new PointerEvent('pointerdown')); + } + ); + + return { + acquire, + activate, + editors, + release, + setFailed, + targetClient + }; +}); diff --git a/src/renderer/src/components/CellDLEditorHostComponent.vue b/src/renderer/src/components/CellDLEditorHostComponent.vue deleted file mode 100644 index 432c130e..00000000 --- a/src/renderer/src/components/CellDLEditorHostComponent.vue +++ /dev/null @@ -1,71 +0,0 @@ - - - diff --git a/src/renderer/src/components/CellDLEditorPoolComponent.vue b/src/renderer/src/components/CellDLEditorPoolComponent.vue new file mode 100644 index 00000000..a8f6e454 --- /dev/null +++ b/src/renderer/src/components/CellDLEditorPoolComponent.vue @@ -0,0 +1,61 @@ + + + diff --git a/src/renderer/src/components/views/CellDLEditorView.vue b/src/renderer/src/components/views/CellDLEditorView.vue index 164d1554..368389bb 100644 --- a/src/renderer/src/components/views/CellDLEditorView.vue +++ b/src/renderer/src/components/views/CellDLEditorView.vue @@ -29,9 +29,9 @@
- -
- + +
+
@@ -54,10 +54,12 @@