From ea2776dfb751c07af6ff194c168aefbeee704a68 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 27 Oct 2026 16:05:10 +0100 Subject: [PATCH 1/2] fix(viewer): do not start a save while another one is running Autosave runs every 10 seconds next to the save button and Ctrl+S. When two saves overlapped, the second request still carried the old mtime and failed with "The file you are working on was updated in the meantime", although nobody else had touched the file. A save requested while one is running now waits for it and runs afterwards, so edits made in between are saved as well and a pending close still gets its answer. The map is marked unchanged when the request goes out and changed again when it fails, so edits made during a save are no longer lost. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Andy Scherzinger --- src/viewer.js | 68 +++++++++++++++++++++++++++++++++++++-------------- 1 file changed, 50 insertions(+), 18 deletions(-) diff --git a/src/viewer.js b/src/viewer.js index 41ec415d..39719df9 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -110,6 +110,8 @@ normalizeMouseWheel(); const MindMap = { _changed: false, + _saving: false, + _saveQueue: [], _autoSaveTimer: null, _clearStatusMessageTimer: null, _loadStatus: false, @@ -224,25 +226,55 @@ normalizeMouseWheel(); }, save(onComplete) { const self = this - if (self._changed) { - self.updateSaveButtonInfo(t('files_mindmap', 'Saving…')) - const data = JSON.stringify(minder.exportJson()) - window.parent.OCA.FilesMindMap.save(data, function(msg) { - self.updateSaveButtonInfo(msg) - self._changed = false - self.restoreSaveButtonInfo(3000) - if (undefined !== onComplete) { - onComplete(true, msg) - } - }, function(msg) { - self.updateSaveButtonInfo(msg) - self.restoreSaveButtonInfo(3000) - if (undefined !== onComplete) { - onComplete(false, msg) - } - }) - self.restoreSaveButtonInfo(6000) + // A second request before the first one returns would fail the server's mtime check + if (self._saving) { + self._saveQueue.push(onComplete) + return + } + if (!self._changed) { + if (undefined !== onComplete) { + onComplete(true) + } + return } + const finish = function(status, msg) { + self._saving = false + if (undefined !== onComplete) { + onComplete(status, msg) + } + const queued = self._saveQueue.splice(0) + const notify = function(queuedStatus, queuedMsg) { + queued.forEach(function(callback) { + if (undefined !== callback) { + callback(queuedStatus, queuedMsg) + } + }) + } + if (queued.length === 0) { + return + } + if (status) { + self.save(notify) + } else { + notify(false, msg) + } + } + self._saving = true + // Edits made while the request is out mark the map as changed again + self._changed = false + self.updateSaveButtonInfo(t('files_mindmap', 'Saving…')) + const data = JSON.stringify(minder.exportJson()) + window.parent.OCA.FilesMindMap.save(data, function(msg) { + self.updateSaveButtonInfo(msg) + self.restoreSaveButtonInfo(3000) + finish(true, msg) + }, function(msg) { + self._changed = true + self.updateSaveButtonInfo(msg) + self.restoreSaveButtonInfo(3000) + finish(false, msg) + }) + self.restoreSaveButtonInfo(6000) }, startAutoSaveTimer() { const self = this From 4b99f038cc390291d37b55056dedf97e2cfb6736 Mon Sep 17 00:00:00 2001 From: Andy Scherzinger Date: Tue, 27 Oct 2026 16:05:18 +0100 Subject: [PATCH 2/2] fix(viewer): save on Ctrl+S wherever the focus is Inside the editor, Ctrl+S was handled in the bubble phase, where the editor's own key handlers could swallow it first. It is now handled in the capture phase and checks event.key instead of the deprecated event.which. With the focus outside the editor iframe, for example on the viewer's header, Ctrl+S opened the browser's save dialog. The viewer element now catches it while it is shown and asks the editor to save. Assisted-by: Claude Code:claude-opus-5-5 Signed-off-by: Andy Scherzinger --- src/__tests__/mindmapElement.spec.js | 37 ++++++++++++++++++++++++++++ src/viewer.js | 11 +++++---- src/views/MindMap.js | 18 ++++++++++++++ 3 files changed, 61 insertions(+), 5 deletions(-) diff --git a/src/__tests__/mindmapElement.spec.js b/src/__tests__/mindmapElement.spec.js index 0787c4b2..20d6bea5 100644 --- a/src/__tests__/mindmapElement.spec.js +++ b/src/__tests__/mindmapElement.spec.js @@ -82,6 +82,43 @@ describe('MindMap element', () => { await vi.waitFor(() => expect(loaded).toHaveBeenCalledOnce()) expect(loaded).toHaveReturned() }) + + it('saves the mind map on Ctrl+S instead of the browser saving the page', async () => { + const element = await mount() + const save = vi.fn() + element.querySelector('iframe').contentWindow.MindMap = { save } + + const event = new KeyboardEvent('keydown', { key: 's', ctrlKey: true, cancelable: true }) + document.dispatchEvent(event) + + expect(save).toHaveBeenCalledOnce() + expect(event.defaultPrevented).toBe(true) + }) + + it('leaves other keys alone', async () => { + const element = await mount() + const save = vi.fn() + element.querySelector('iframe').contentWindow.MindMap = { save } + + const event = new KeyboardEvent('keydown', { key: 's', cancelable: true }) + document.dispatchEvent(event) + + expect(save).not.toHaveBeenCalled() + expect(event.defaultPrevented).toBe(false) + }) + + it('stops handling Ctrl+S once the viewer removes it', async () => { + const element = await mount() + const save = vi.fn() + element.querySelector('iframe').contentWindow.MindMap = { save } + element.remove() + + const event = new KeyboardEvent('keydown', { key: 's', ctrlKey: true, cancelable: true }) + document.dispatchEvent(event) + + expect(save).not.toHaveBeenCalled() + expect(event.defaultPrevented).toBe(false) + }) }) describe('MIMES', () => { diff --git a/src/viewer.js b/src/viewer.js index 39719df9..69f59831 100644 --- a/src/viewer.js +++ b/src/viewer.js @@ -154,13 +154,14 @@ normalizeMouseWheel(); }, initHotkey() { const self = this - window.$(document).keydown(function(e) { - if ((e.ctrlKey || e.metaKey) && e.which === 83) { - self.save() + // Capture phase, so the editor's own key handlers cannot swallow it + document.addEventListener('keydown', function(e) { + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { e.preventDefault() - return false + e.stopPropagation() + self.save() } - }) + }, true) }, bindEvent() { const self = this diff --git a/src/views/MindMap.js b/src/views/MindMap.js index d93bb9f5..c72cc9bc 100644 --- a/src/views/MindMap.js +++ b/src/views/MindMap.js @@ -38,7 +38,21 @@ export default class MindMap extends HTMLElement { this._file = file } + /** + * Save the mind map on Ctrl+S while the focus is outside the editor iframe, + * instead of the browser saving the page. + * + * @param {KeyboardEvent} event the key event + */ + onKeyDown = (event) => { + if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') { + event.preventDefault() + this.querySelector('iframe')?.contentWindow?.MindMap?.save() + } + } + async connectedCallback() { + document.addEventListener('keydown', this.onKeyDown) try { const FilesMindMap = await loadFilesMindMap() FilesMindMap.setFile(this.file) @@ -59,6 +73,10 @@ export default class MindMap extends HTMLElement { this.dispatchEvent(new CustomEvent('loaded')) } + disconnectedCallback() { + document.removeEventListener('keydown', this.onKeyDown) + } + attributeChangedCallback() { this.resize() }