diff --git a/app/main.js b/app/main.js index 104f783..f287d50 100644 --- a/app/main.js +++ b/app/main.js @@ -22,6 +22,7 @@ const { readLock } = require('../core/locks'); const CaptureService = require('./capture'); const { TextIntelService } = require('./text-intel'); const { keepProcessesResponsive } = require('./win-power'); +const { zoomShortcutFromInputEvent } = require('./shortcut-utils'); const security = require('./security'); const PACKAGE_JSON = require(path.join(__dirname, '..', 'package.json')); @@ -113,6 +114,12 @@ function createWindow() { // away from it and every popup is denied, so no other document can run // with this window's preload bridge. security.installWindowSecurity(mainWindow, 'main'); + mainWindow.webContents.on('before-input-event', (event, input) => { + const kind = zoomShortcutFromInputEvent(input); + if (!kind) return; + event.preventDefault(); + sendToRenderer('editor:zoom-shortcut', kind); + }); mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html')); mainWindow.once('ready-to-show', () => { mainWindow.show(); diff --git a/app/preload.js b/app/preload.js index 35b4a5f..6ce5020 100644 --- a/app/preload.js +++ b/app/preload.js @@ -67,6 +67,9 @@ const api = { onState: (fn) => ipcRenderer.on('capture:state', (e, payload) => fn(payload)), onStepUpdated: (fn) => ipcRenderer.on('step:updated', (e, payload) => fn(payload)), }, + editor: { + onZoomShortcut: (fn) => ipcRenderer.on('editor:zoom-shortcut', (e, payload) => fn(payload)), + }, archive: { export: invoke('archive:export'), open: invoke('archive:open'), diff --git a/app/renderer/dialogs.js b/app/renderer/dialogs.js index c46bf37..4a185dc 100644 --- a/app/renderer/dialogs.js +++ b/app/renderer/dialogs.js @@ -169,7 +169,14 @@ function makeHotkeyInput(value = '') { return wrap; } -async function promptText({ title, label = 'Value', value = '', placeholder = '', multiline = false } = {}) { +async function promptText({ + title, + label = 'Value', + value = '', + placeholder = '', + multiline = false, + onInput = null, +} = {}) { return new Promise((resolve) => { const field = multiline ? el('textarea', { rows: 6, placeholder }, value) @@ -197,6 +204,9 @@ async function promptText({ title, label = 'Value', value = '', placeholder = '' resolve(field.value); } }); + field.addEventListener('input', () => { + if (typeof onInput === 'function') onInput(field.value); + }); setTimeout(() => field.focus(), 0); }); diff --git a/app/renderer/editor.js b/app/renderer/editor.js index 61f681d..5b33ac1 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -4,6 +4,7 @@ const api = window.stepforge; const dialogs = window.StepForgeDialogs || {}; +const shortcuts = window.StepForgeShortcuts || {}; const clone = (value) => JSON.parse(JSON.stringify(value)); const BLOCK_KIND_ORDER = { text: 0, code: 1, table: 2 }; @@ -105,16 +106,21 @@ function isEditableTarget(target) { ); } -const ZOOM_IN_KEYS = new Set(['=', '+', 'Add']); -const ZOOM_OUT_KEYS = new Set(['-', '_', 'Subtract']); - function zoomShortcutFromEvent(e) { + if (shortcuts.zoomShortcutFromKeyboardEvent) { + return shortcuts.zoomShortcutFromKeyboardEvent(e); + } + if (!(e.ctrlKey || e.metaKey)) return null; - const { key, code } = e; + const { key, code, shiftKey } = e; if (key === '0' || code === 'Digit0' || code === 'Numpad0') return 'fit'; - if (ZOOM_IN_KEYS.has(key) || code === 'Equal' || code === 'NumpadAdd') return 'in'; - if (ZOOM_OUT_KEYS.has(key) || code === 'Minus' || code === 'NumpadSubtract') return 'out'; + if ( + key === '+' || key === '=' || key === 'Add' || key === 'Plus' || + code === 'Equal' || code === 'NumpadAdd' || + (key === '=' && shiftKey) || (code === 'Equal' && shiftKey) + ) return 'in'; + if (key === '-' || key === '_' || key === 'Subtract' || key === 'Minus' || code === 'Minus' || code === 'NumpadSubtract') return 'out'; return null; } @@ -154,6 +160,13 @@ class GuideEditor { this.saveStepDebounced = debounce(() => this.flushStep(), 180); this.saveGuideDebounced = debounce(() => this.flushGuide(), 180); + if (api.editor && typeof api.editor.onZoomShortcut === 'function') { + api.editor.onZoomShortcut((kind) => { + if (!this.active || !this.guide) return; + this.applyZoomShortcut(kind); + }); + } + this.onDocumentKeyDown = this.onDocumentKeyDown.bind(this); document.addEventListener('keydown', this.onDocumentKeyDown, true); } @@ -1368,6 +1381,22 @@ class GuideEditor { if (mode === 1.5) this.dom.zoom150Btn.classList.add('active'); } + applyZoomShortcut(kind) { + if (kind === 'in') { + this.setZoom(Math.min(3, (Number(this.currentZoom) || 1) + 0.25)); + return true; + } + if (kind === 'out') { + this.setZoom(Math.max(0.25, (Number(this.currentZoom) || 1) - 0.25)); + return true; + } + if (kind === 'fit') { + this.setZoom('fit'); + return true; + } + return false; + } + pushCanvasHistory(recordOrLabel = 'change') { if (!this.currentStep) return; const record = recordOrLabel && typeof recordOrLabel === 'object' @@ -2188,19 +2217,38 @@ class GuideEditor { async editAnnotationText(ann) { const step = this.currentStep; if (!step || !ann) return; + const originalText = ann.text ?? ''; + const applyText = (nextText, { persist = true } = {}) => { + const selected = this.canvas.selected(); + if (!selected) return; + selected.text = nextText; + step.annotations = clone(this.canvas.annotations || []); + this.pendingSave = true; + this.canvas.setAnnotations(step.annotations || []); + this.renderAnnotationPanel(); + this.emitMeta(); + if (persist) this.saveStepDebounced(); + }; const value = await dialogs.promptText({ title: ann.type === 'tooltip' ? 'Edit tooltip' : 'Edit text', label: 'Text', - value: ann.text || '', + value: originalText, multiline: true, + onInput: applyText, }); - if (value == null) return; - ann.text = value; - step.annotations = clone(step.annotations || []); - this.pendingSave = true; - this.canvas.setAnnotations(step.annotations || []); - this.renderAnnotationPanel(); - this.emitMeta(); + this.saveStepDebounced.cancel(); + if (value == null) { + const current = this.canvas.selected(); + if ((current?.text ?? '') !== originalText) { + applyText(originalText, { persist: false }); + } + await this.flushStep(step); + return; + } + const current = this.canvas.selected(); + if ((current?.text ?? '') !== value) { + applyText(value, { persist: false }); + } await this.flushStep(step); } @@ -2251,13 +2299,7 @@ class GuideEditor { const zoomShortcut = zoomShortcutFromEvent(e); if (zoomShortcut) { e.preventDefault(); - if (zoomShortcut === 'in') { - this.setZoom(Math.min(3, (Number(this.currentZoom) || 1) + 0.25)); - } else if (zoomShortcut === 'out') { - this.setZoom(Math.max(0.25, (Number(this.currentZoom) || 1) - 0.25)); - } else { - this.setZoom('fit'); - } + this.applyZoomShortcut(zoomShortcut); return; } if ((e.ctrlKey || e.metaKey) && e.key === '/' && !e.shiftKey) { diff --git a/app/renderer/index.html b/app/renderer/index.html index 2dfbeb9..2ced901 100644 --- a/app/renderer/index.html +++ b/app/renderer/index.html @@ -21,6 +21,7 @@
+ diff --git a/app/shortcut-utils.js b/app/shortcut-utils.js new file mode 100644 index 0000000..265c373 --- /dev/null +++ b/app/shortcut-utils.js @@ -0,0 +1,55 @@ +'use strict'; + +(function attachShortcutUtils(root, factory) { + const api = factory(); + if (typeof module !== 'undefined' && module.exports) { + module.exports = api; + } + if (root) { + root.StepForgeShortcuts = api; + } +})(typeof globalThis !== 'undefined' ? globalThis : this, () => { + function hasZoomModifier(source) { + return Boolean(source && (source.ctrlKey || source.metaKey || source.control || source.meta)); + } + + function zoomShortcutFromSource(source) { + if (!hasZoomModifier(source)) return null; + + const key = String(source.key || ''); + const code = String(source.code || ''); + const shiftKey = Boolean(source.shiftKey || source.shift); + + if (key === '0' || code === 'Digit0' || code === 'Numpad0') return 'fit'; + + if ( + key === '+' || key === '=' || key === 'Add' || key === 'Plus' || + code === 'Equal' || code === 'NumpadAdd' || + (key === '=' && shiftKey) || (code === 'Equal' && shiftKey) + ) { + return 'in'; + } + + if ( + key === '-' || key === '_' || key === 'Subtract' || key === 'Minus' || + code === 'Minus' || code === 'NumpadSubtract' + ) { + return 'out'; + } + + return null; + } + + function zoomShortcutFromKeyboardEvent(event) { + return zoomShortcutFromSource(event); + } + + function zoomShortcutFromInputEvent(input) { + return zoomShortcutFromSource(input); + } + + return { + zoomShortcutFromInputEvent, + zoomShortcutFromKeyboardEvent, + }; +}); diff --git a/tests/unit/shortcut-utils.test.js b/tests/unit/shortcut-utils.test.js new file mode 100644 index 0000000..db8823c --- /dev/null +++ b/tests/unit/shortcut-utils.test.js @@ -0,0 +1,23 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const { + zoomShortcutFromInputEvent, + zoomShortcutFromKeyboardEvent, +} = require('../../app/shortcut-utils'); + +test('zoom shortcut helper recognizes zoom in, out, and fit across event shapes', () => { + assert.equal(zoomShortcutFromKeyboardEvent({ ctrlKey: true, key: '=', code: 'Equal' }), 'in'); + assert.equal(zoomShortcutFromKeyboardEvent({ ctrlKey: true, key: '+', code: 'NumpadAdd' }), 'in'); + assert.equal(zoomShortcutFromKeyboardEvent({ ctrlKey: true, key: '-', code: 'Minus' }), 'out'); + assert.equal(zoomShortcutFromKeyboardEvent({ metaKey: true, key: '0', code: 'Digit0' }), 'fit'); + assert.equal(zoomShortcutFromKeyboardEvent({ ctrlKey: true, key: '=', code: 'Equal', shiftKey: true }), 'in'); +}); + +test('zoom shortcut helper recognizes Electron before-input-event payloads', () => { + assert.equal(zoomShortcutFromInputEvent({ control: true, key: '=', code: 'Equal' }), 'in'); + assert.equal(zoomShortcutFromInputEvent({ control: true, key: '-', code: 'Minus' }), 'out'); + assert.equal(zoomShortcutFromInputEvent({ meta: true, key: '0', code: 'Digit0' }), 'fit'); +});