diff --git a/src/__tests__/MindMap.spec.js b/src/__tests__/MindMap.spec.js index 507ef3fc..f40f259f 100644 --- a/src/__tests__/MindMap.spec.js +++ b/src/__tests__/MindMap.spec.js @@ -49,6 +49,13 @@ function mountMindMap(dataOverrides = {}) { }) } +/** + * Mount into the document, so the iframe has a window to talk to. + */ +function mountAttached() { + return shallowMount(MindMap, { attachTo: document.body, global: { mixins: [viewerMixin] } }) +} + describe('MindMap', () => { beforeEach(() => { window.OCA = { FilesMindMap: { setFile: vi.fn() } } @@ -105,4 +112,44 @@ describe('MindMap', () => { expect(window.OCA.FilesMindMap.setFile).toHaveBeenCalled() }) }) + + describe('Ctrl+S', () => { + it('saves the mind map instead of the browser saving the page', () => { + const wrapper = mountAttached() + const save = vi.fn() + wrapper.element.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) + wrapper.unmount() + }) + + it('leaves other keys alone', () => { + const wrapper = mountAttached() + const save = vi.fn() + wrapper.element.contentWindow.MindMap = { save } + + const event = new KeyboardEvent('keydown', { key: 's', cancelable: true }) + document.dispatchEvent(event) + + expect(save).not.toHaveBeenCalled() + expect(event.defaultPrevented).toBe(false) + wrapper.unmount() + }) + + it('stops handling Ctrl+S once the viewer closes', () => { + const add = vi.spyOn(document, 'addEventListener') + const remove = vi.spyOn(document, 'removeEventListener') + const wrapper = mountAttached() + const [, handler] = add.mock.calls.find(([type]) => type === 'keydown') + wrapper.unmount() + + expect(remove).toHaveBeenCalledWith('keydown', handler) + add.mockRestore() + remove.mockRestore() + }) + }) }) diff --git a/src/__tests__/mindmap.spec.js b/src/__tests__/mindmap.spec.js index e0173758..249bb308 100644 --- a/src/__tests__/mindmap.spec.js +++ b/src/__tests__/mindmap.spec.js @@ -214,6 +214,12 @@ describe('FilesMindMap', () => { FilesMindMap.setFile({ filename: '/docs/sub/test.km', basename: 'test.km' }) expect(FilesMindMap._currentContext).toEqual(expect.objectContaining({ dir: '/docs/sub' })) }) + + it('forgets the previous file when the viewer passes none', () => { + FilesMindMap.setFile({ filename: '/docs/test.km', basename: 'test.km' }) + expect(() => FilesMindMap.setFile(undefined)).not.toThrow() + expect(FilesMindMap._file).toEqual({}) + }) }) // ─── Public share detection ──────────────────────────────────────────────── diff --git a/src/mindmap.js b/src/mindmap.js index 65466c55..e769e062 100644 --- a/src/mindmap.js +++ b/src/mindmap.js @@ -215,6 +215,12 @@ const FilesMindMap = { }, setFile(file) { + // Forget the previous file, so the editor cannot load or save it in place of this one + if (!file) { + logger.warn('The viewer passed no file to open') + this._file = {} + return + } const filename = file.filename + '' const basename = file.basename + '' diff --git a/src/viewer.js b/src/viewer.js index f701c34a..46108aa0 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, @@ -152,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 @@ -224,25 +227,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 diff --git a/src/views/MindMap.js b/src/views/MindMap.js index b314c218..3da217d5 100644 --- a/src/views/MindMap.js +++ b/src/views/MindMap.js @@ -39,9 +39,34 @@ export default { if (OCA.FilesMindMap) { OCA.FilesMindMap.setFile(this.file) } + document.addEventListener('keydown', this.onKeyDown) this.doneLoading() }, + // Vue 2.7 (viewer app) calls beforeDestroy; Vue 3 (test env) calls beforeUnmount. + beforeDestroy() { + document.removeEventListener('keydown', this.onKeyDown) + }, + + beforeUnmount() { + document.removeEventListener('keydown', this.onKeyDown) + }, + + methods: { + /** + * 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.$el.contentWindow?.MindMap?.save() + } + }, + }, + render(h) { // Vue 2.7 passes h as an argument; Vue 3 (test env) does not — fall back to the peer dep import. const createElement = typeof h === 'function' ? h : _h