9 Commits
Author SHA1 Message Date
Tyler 2929d7d5e4 Fix zoom multi
Template tests / tests (push) Successful in 1m52s
2026-07-07 13:09:21 -05:00
Tyler 493f899a11 Fix zoom multi
Template tests / tests (push) Successful in 1m53s
2026-07-07 13:06:19 -05:00
Tyler 68e886c142 Fix zoom
Template tests / tests (push) Successful in 1m52s
2026-07-07 12:59:55 -05:00
Tyler 9e13ea9c40 Fix zoom-in accelerators
Template tests / tests (push) Successful in 1m53s
2026-07-07 12:45:49 -05:00
Tyler 058cdc20ab Restore
Template tests / tests (push) Successful in 1m54s
2026-07-07 12:34:42 -05:00
Tyler 7f68a72e2b Restore
Template tests / tests (push) Successful in 1m51s
2026-07-07 12:27:29 -05:00
Tyler 8e4f3ff951 Fix_selftest_window_guard
Template tests / tests (push) Successful in 2m16s
2026-07-07 12:18:05 -05:00
Tyler 67b5dc5965 Fix
Template tests / tests (push) Failing after 35s
2026-07-07 12:15:27 -05:00
Tyler 9555db9bf5 Fix
Template tests / tests (push) Failing after 4m1s
2026-07-07 12:04:08 -05:00
7 changed files with 269 additions and 22 deletions
+106
View File
@@ -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'));
@@ -65,6 +66,10 @@ let templates;
let capture;
let textIntel;
let mainWindow;
let lastZoomShortcut = null;
let canvasZoomActive = false;
const UI_ZOOM_LEVEL_MIN = -8;
const UI_ZOOM_LEVEL_MAX = 8;
function reindex(guideId) {
try {
@@ -87,6 +92,43 @@ function applyTheme() {
nativeTheme.themeSource = settings.get('appearance') || 'system';
}
function dispatchZoomShortcut(kind) {
sendToRenderer('editor:zoom-shortcut', kind);
}
// Ctrl+=/Ctrl+-/Ctrl+0 zoom the step editor's canvas while a guide is open
// there (dispatchZoomShortcut above); everywhere else — library, welcome,
// dialogs — the same keys scale the whole window's UI like a browser.
function applyUiZoom(kind) {
if (!mainWindow || mainWindow.isDestroyed()) return;
const wc = mainWindow.webContents;
if (kind === 'fit') {
wc.zoomLevel = 0;
return;
}
const delta = kind === 'in' ? 1 : kind === 'out' ? -1 : 0;
if (!delta) return;
wc.zoomLevel = Math.max(UI_ZOOM_LEVEL_MIN, Math.min(UI_ZOOM_LEVEL_MAX, wc.zoomLevel + delta));
}
// A single physical keypress reaches here twice — once via the global
// accelerator registration, once via before-input-event — plus multiple
// accelerator spellings can match the same key on some layouts. Collapse
// same-kind repeats within 50ms so one keypress is one zoom step.
function handleZoomShortcut(kind) {
if (!kind) return;
const now = Date.now();
if (lastZoomShortcut && lastZoomShortcut.kind === kind && (now - lastZoomShortcut.at) < 50) {
return;
}
lastZoomShortcut = { kind, at: now };
if (canvasZoomActive) {
dispatchZoomShortcut(kind);
} else {
applyUiZoom(kind);
}
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1280,
@@ -113,6 +155,16 @@ 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) => {
// Electron reports both the key-down and key-up as separate
// before-input-event calls; only act on the down edge or every tap
// fires the shortcut twice regardless of the dedupe window below.
if (input.type !== 'keyDown') return;
const kind = zoomShortcutFromInputEvent(input);
if (!kind) return;
event.preventDefault();
handleZoomShortcut(kind);
});
mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html'));
mainWindow.once('ready-to-show', () => {
mainWindow.show();
@@ -248,6 +300,19 @@ function createWindow() {
}
};
const waitClickBackendReady = async () => {
for (let i = 0; i < 240; i++) {
const streamReady = Boolean(
capture.streamBackend
&& typeof capture.streamBackend.isActive === 'function'
&& capture.streamBackend.isActive(),
);
if (streamReady || capture.frameLoopRunning) return true;
await new Promise((res) => setTimeout(res, 50));
}
return false;
};
// Third scenario: the real "Start recording" path. armRecording
// warms the recorder while the window is visible and only arms the
// session once it hides; the first click *after* arming must get a
@@ -268,6 +333,12 @@ function createWindow() {
const warmupClicks = store.getGuide(armGuide.guideId).stepsOrder.length;
capture.onOsClick(Date.now(), toPhysical({ x: bounds.x + 100, y: bounds.y + 100 }), 'button-1');
await waitArmed();
if (!await waitClickBackendReady()) {
throw new Error('arm selftest backend never became ready');
}
await new Promise((res) => setTimeout(res, 1500));
if (mainWindow.isVisible()) mainWindow.hide();
await new Promise((res) => setTimeout(res, 200));
const armPoint = {
x: Math.round(bounds.x + bounds.width * 0.4),
y: Math.round(bounds.y + bounds.height * 0.4),
@@ -295,6 +366,12 @@ function createWindow() {
capture.togglePause(false);
await capture.startClickFrameBackend();
await waitArmed();
if (!await waitClickBackendReady()) {
throw new Error('debounce selftest backend never became ready');
}
await new Promise((res) => setTimeout(res, 1500));
if (mainWindow.isVisible()) mainWindow.hide();
await new Promise((res) => setTimeout(res, 200));
await new Promise((res) => setTimeout(res, 300));
const dbPoint = {
x: Math.round(bounds.x + bounds.width * 0.55),
@@ -365,6 +442,27 @@ function createWindow() {
function registerHotkeys() {
globalShortcut.unregisterAll();
const zoomBindings = [
['CommandOrControl+Plus', 'in'],
['CommandOrControl+Shift+=', 'in'],
['CommandOrControl+=', 'in'],
['CommandOrControl+numadd', 'in'],
['CommandOrControl+-', 'out'],
['CommandOrControl+Minus', 'out'],
['CommandOrControl+numsub', 'out'],
['CommandOrControl+0', 'fit'],
['CommandOrControl+num0', 'fit'],
];
for (const [accel, kind] of zoomBindings) {
try {
if (globalShortcut.register(accel, () => handleZoomShortcut(kind))) {
// Keep registering the other spellings so keyboards with different
// plus/minus translations still land on the same action.
}
} catch {
// Invalid accelerators must not break startup.
}
}
const accel = settings.get('capture.hotkeyCapture');
const pauseAccel = settings.get('capture.hotkeyPauseResume');
try {
@@ -399,6 +497,14 @@ function setupIpc() {
getMainWebContents: () => (mainWindow && !mainWindow.isDestroyed() ? mainWindow.webContents : null),
});
const c = security.check;
// The renderer reports whether the step editor (with a guide open) is the
// visible screen, so Ctrl+=/Ctrl+-/Ctrl+0 can pick canvas zoom vs UI zoom.
ipcMain.on('editor:canvas-zoom-active', (event, active) => {
if (!trustedSender(event)) return;
canvasZoomActive = Boolean(active);
});
const IMAGE_BUDGET = 256 * 1024 * 1024; // channels that carry base64 PNGs
const h = (channel, fn, opts = {}) => {
const { maxChars = 2 * 1024 * 1024, validate = null } = opts;
+4
View File
@@ -67,6 +67,10 @@ 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)),
setCanvasZoomActive: (active) => ipcRenderer.send('editor:canvas-zoom-active', Boolean(active)),
},
archive: {
export: invoke('archive:export'),
open: invoke('archive:open'),
+11 -1
View File
@@ -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);
});
+67 -22
View File
@@ -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);
}
@@ -165,6 +178,9 @@ class GuideEditor {
setActive(active) {
this.active = Boolean(active);
if (api.editor && typeof api.editor.setCanvasZoomActive === 'function') {
api.editor.setCanvasZoomActive(this.active);
}
if (!this.active && this.guideId) {
// Leaving the editor: flush pending debounced saves so navigation can
// never drop the last edit (failures keep the dirty state and retry),
@@ -1368,6 +1384,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 +2220,38 @@ class GuideEditor {
async editAnnotationText(ann) {
const step = this.currentStep;
if (!step || !ann) return;
const value = await dialogs.promptText({
title: ann.type === 'tooltip' ? 'Edit tooltip' : 'Edit text',
label: 'Text',
value: ann.text || '',
multiline: true,
});
if (value == null) return;
ann.text = value;
step.annotations = clone(step.annotations || []);
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: originalText,
multiline: true,
onInput: applyText,
});
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 +2302,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) {
+1
View File
@@ -21,6 +21,7 @@
<div id="modal-root"></div>
<div id="toast-root"></div>
<script src="util.js"></script>
<script src="../shortcut-utils.js"></script>
<script src="canvas.js"></script>
<script src="dialogs.js"></script>
<script src="editor.js"></script>
+55
View File
@@ -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,
};
});
+26
View File
@@ -0,0 +1,26 @@
'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: 'Plus', code: 'Equal' }), '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: 'Equal', shift: true }), 'in');
assert.equal(zoomShortcutFromInputEvent({ control: true, key: 'Plus', code: 'Equal', shift: true }), 'in');
assert.equal(zoomShortcutFromInputEvent({ control: true, key: '-', code: 'Minus' }), 'out');
assert.equal(zoomShortcutFromInputEvent({ meta: true, key: '0', code: 'Digit0' }), 'fit');
});