@@ -22,6 +22,7 @@ const { readLock } = require('../core/locks');
|
|||||||
const CaptureService = require('./capture');
|
const CaptureService = require('./capture');
|
||||||
const { TextIntelService } = require('./text-intel');
|
const { TextIntelService } = require('./text-intel');
|
||||||
const { keepProcessesResponsive } = require('./win-power');
|
const { keepProcessesResponsive } = require('./win-power');
|
||||||
|
const { zoomShortcutFromInputEvent } = require('./shortcut-utils');
|
||||||
const security = require('./security');
|
const security = require('./security');
|
||||||
const PACKAGE_JSON = require(path.join(__dirname, '..', 'package.json'));
|
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
|
// away from it and every popup is denied, so no other document can run
|
||||||
// with this window's preload bridge.
|
// with this window's preload bridge.
|
||||||
security.installWindowSecurity(mainWindow, 'main');
|
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.loadFile(path.join(__dirname, 'renderer', 'index.html'));
|
||||||
mainWindow.once('ready-to-show', () => {
|
mainWindow.once('ready-to-show', () => {
|
||||||
mainWindow.show();
|
mainWindow.show();
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ const api = {
|
|||||||
onState: (fn) => ipcRenderer.on('capture:state', (e, payload) => fn(payload)),
|
onState: (fn) => ipcRenderer.on('capture:state', (e, payload) => fn(payload)),
|
||||||
onStepUpdated: (fn) => ipcRenderer.on('step:updated', (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: {
|
archive: {
|
||||||
export: invoke('archive:export'),
|
export: invoke('archive:export'),
|
||||||
open: invoke('archive:open'),
|
open: invoke('archive:open'),
|
||||||
|
|||||||
+11
-1
@@ -169,7 +169,14 @@ function makeHotkeyInput(value = '') {
|
|||||||
return wrap;
|
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) => {
|
return new Promise((resolve) => {
|
||||||
const field = multiline
|
const field = multiline
|
||||||
? el('textarea', { rows: 6, placeholder }, value)
|
? el('textarea', { rows: 6, placeholder }, value)
|
||||||
@@ -197,6 +204,9 @@ async function promptText({ title, label = 'Value', value = '', placeholder = ''
|
|||||||
resolve(field.value);
|
resolve(field.value);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
field.addEventListener('input', () => {
|
||||||
|
if (typeof onInput === 'function') onInput(field.value);
|
||||||
|
});
|
||||||
|
|
||||||
setTimeout(() => field.focus(), 0);
|
setTimeout(() => field.focus(), 0);
|
||||||
});
|
});
|
||||||
|
|||||||
+63
-21
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
const api = window.stepforge;
|
const api = window.stepforge;
|
||||||
const dialogs = window.StepForgeDialogs || {};
|
const dialogs = window.StepForgeDialogs || {};
|
||||||
|
const shortcuts = window.StepForgeShortcuts || {};
|
||||||
|
|
||||||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||||||
const BLOCK_KIND_ORDER = { text: 0, code: 1, table: 2 };
|
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) {
|
function zoomShortcutFromEvent(e) {
|
||||||
|
if (shortcuts.zoomShortcutFromKeyboardEvent) {
|
||||||
|
return shortcuts.zoomShortcutFromKeyboardEvent(e);
|
||||||
|
}
|
||||||
|
|
||||||
if (!(e.ctrlKey || e.metaKey)) return null;
|
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 (key === '0' || code === 'Digit0' || code === 'Numpad0') return 'fit';
|
||||||
if (ZOOM_IN_KEYS.has(key) || code === 'Equal' || code === 'NumpadAdd') return 'in';
|
if (
|
||||||
if (ZOOM_OUT_KEYS.has(key) || code === 'Minus' || code === 'NumpadSubtract') return 'out';
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,6 +160,13 @@ class GuideEditor {
|
|||||||
this.saveStepDebounced = debounce(() => this.flushStep(), 180);
|
this.saveStepDebounced = debounce(() => this.flushStep(), 180);
|
||||||
this.saveGuideDebounced = debounce(() => this.flushGuide(), 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);
|
this.onDocumentKeyDown = this.onDocumentKeyDown.bind(this);
|
||||||
document.addEventListener('keydown', this.onDocumentKeyDown, true);
|
document.addEventListener('keydown', this.onDocumentKeyDown, true);
|
||||||
}
|
}
|
||||||
@@ -1368,6 +1381,22 @@ class GuideEditor {
|
|||||||
if (mode === 1.5) this.dom.zoom150Btn.classList.add('active');
|
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') {
|
pushCanvasHistory(recordOrLabel = 'change') {
|
||||||
if (!this.currentStep) return;
|
if (!this.currentStep) return;
|
||||||
const record = recordOrLabel && typeof recordOrLabel === 'object'
|
const record = recordOrLabel && typeof recordOrLabel === 'object'
|
||||||
@@ -2188,19 +2217,38 @@ class GuideEditor {
|
|||||||
async editAnnotationText(ann) {
|
async editAnnotationText(ann) {
|
||||||
const step = this.currentStep;
|
const step = this.currentStep;
|
||||||
if (!step || !ann) return;
|
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({
|
const value = await dialogs.promptText({
|
||||||
title: ann.type === 'tooltip' ? 'Edit tooltip' : 'Edit text',
|
title: ann.type === 'tooltip' ? 'Edit tooltip' : 'Edit text',
|
||||||
label: 'Text',
|
label: 'Text',
|
||||||
value: ann.text || '',
|
value: originalText,
|
||||||
multiline: true,
|
multiline: true,
|
||||||
|
onInput: applyText,
|
||||||
});
|
});
|
||||||
if (value == null) return;
|
this.saveStepDebounced.cancel();
|
||||||
ann.text = value;
|
if (value == null) {
|
||||||
step.annotations = clone(step.annotations || []);
|
const current = this.canvas.selected();
|
||||||
this.pendingSave = true;
|
if ((current?.text ?? '') !== originalText) {
|
||||||
this.canvas.setAnnotations(step.annotations || []);
|
applyText(originalText, { persist: false });
|
||||||
this.renderAnnotationPanel();
|
}
|
||||||
this.emitMeta();
|
await this.flushStep(step);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const current = this.canvas.selected();
|
||||||
|
if ((current?.text ?? '') !== value) {
|
||||||
|
applyText(value, { persist: false });
|
||||||
|
}
|
||||||
await this.flushStep(step);
|
await this.flushStep(step);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2251,13 +2299,7 @@ class GuideEditor {
|
|||||||
const zoomShortcut = zoomShortcutFromEvent(e);
|
const zoomShortcut = zoomShortcutFromEvent(e);
|
||||||
if (zoomShortcut) {
|
if (zoomShortcut) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (zoomShortcut === 'in') {
|
this.applyZoomShortcut(zoomShortcut);
|
||||||
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');
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if ((e.ctrlKey || e.metaKey) && e.key === '/' && !e.shiftKey) {
|
if ((e.ctrlKey || e.metaKey) && e.key === '/' && !e.shiftKey) {
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
<div id="modal-root"></div>
|
<div id="modal-root"></div>
|
||||||
<div id="toast-root"></div>
|
<div id="toast-root"></div>
|
||||||
<script src="util.js"></script>
|
<script src="util.js"></script>
|
||||||
|
<script src="../shortcut-utils.js"></script>
|
||||||
<script src="canvas.js"></script>
|
<script src="canvas.js"></script>
|
||||||
<script src="dialogs.js"></script>
|
<script src="dialogs.js"></script>
|
||||||
<script src="editor.js"></script>
|
<script src="editor.js"></script>
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user