Make capture sessions continuous: click-capture + interval auto-capture
Template tests / tests (push) Failing after 29s
Template tests / tests (push) Failing after 29s
The session previously only listened for the global hotkey, which is unreliable under WSLg/Wayland — users got one screenshot and nothing more. Sessions now layer three triggers: - click-capture via OS adapters (xinput test-xi2 on X11, PowerShell GetAsyncKeyState polling on Windows), debounced, ignoring clicks on StepForge itself - interval auto-capture (3/5/10 s) as the always-works fallback, enabled by default when click detection is unavailable - the existing global hotkey, plus a manual Shoot button The REC bar now shows live count + active trigger with Shoot / Auto / Pause / Finish. New captures and added steps are selected in the editor (explicit reload(stepId) wins over a surviving selection). Capture self-test hook (STEPFORGE_CAPTURE_SELFTEST) verifies 3x hotkey-path captures and interval capture end-to-end. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -16,6 +16,13 @@ Initial release.
|
||||
- Capture menu in the editor topbar: full screen / window / region /
|
||||
3-second delay, paste image as step, import images, and capture
|
||||
session start/finish — capture no longer requires the global hotkey.
|
||||
- Continuous capture sessions: steps are grabbed on every OS click where
|
||||
the platform supports it (xinput on X11, PowerShell on Windows), with
|
||||
interval auto-capture (3/5/10 s) as the always-works fallback when
|
||||
click detection or global hotkeys are unavailable (e.g. WSLg/Wayland).
|
||||
The REC bar shows the live count and trigger, with Shoot / Auto /
|
||||
Pause / Finish controls.
|
||||
- New captures and newly added steps are now selected in the editor.
|
||||
- The app hides its own window during capture so screenshots show your
|
||||
work, not StepForge; hotkey captures restore the window without
|
||||
stealing focus.
|
||||
|
||||
@@ -48,10 +48,13 @@ The core workflow:
|
||||
- **Guide library** with folders, favorites, title search, full-text search,
|
||||
duplicate/move/delete, and a quick-actions palette (`Ctrl+/`).
|
||||
- **Capture engine** — the editor's **Capture ▾** button offers full screen,
|
||||
active window, and region capture (the app hides itself during the shot)
|
||||
plus delay, pause/resume sessions with global hotkeys, click markers,
|
||||
clipboard paste, and PNG/JPEG/GIF import. The full keyboard shortcut list
|
||||
lives under **More ▾ → Keyboard shortcuts** in the editor.
|
||||
active window, and region capture (the app hides itself during the shot),
|
||||
plus continuous capture sessions that grab a step on every click where the
|
||||
OS allows it, or on a 3/5/10 s auto-interval everywhere else (the REC bar
|
||||
shows the live count with Shoot/Auto/Pause/Finish controls). Delay, global
|
||||
hotkeys, click markers, clipboard paste, and PNG/JPEG/GIF import included.
|
||||
The full keyboard shortcut list lives under **More ▾ → Keyboard
|
||||
shortcuts** in the editor.
|
||||
- **Three-pane editor** — step tree with substeps, statuses
|
||||
(todo/in-progress/done), hidden/skipped steps, focused view (zoom/pan that
|
||||
never mutates the original image), autosave, and command-stack undo/redo.
|
||||
|
||||
+154
-9
@@ -1,49 +1,127 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('node:path');
|
||||
const { spawn, execFileSync } = require('node:child_process');
|
||||
const { desktopCapturer, screen, BrowserWindow, nativeImage } = require('electron');
|
||||
const { expandPlaceholders } = require('../core/placeholders');
|
||||
|
||||
/**
|
||||
* Capture service: full-screen, active-window, and region capture via
|
||||
* Electron's desktopCapturer, plus a click-marker annotation at the cursor
|
||||
* position and a capture session (start/pause/resume/finish) driven by the
|
||||
* global hotkey.
|
||||
* position and a capture session (start/pause/resume/finish).
|
||||
*
|
||||
* A session captures continuously, with three triggers layered by what the
|
||||
* platform supports:
|
||||
* - click-capture via an OS adapter (xinput on X11, PowerShell on Windows),
|
||||
* - a global hotkey (unreliable on some Wayland compositors),
|
||||
* - interval auto-capture as the always-works fallback.
|
||||
*
|
||||
* Note: under Wayland/WSLg, screen capture may require portal support; all
|
||||
* failures surface as { ok: false, reason } instead of crashing.
|
||||
*/
|
||||
|
||||
const CLICK_DEBOUNCE_MS = 700;
|
||||
|
||||
function hasBinary(name) {
|
||||
try {
|
||||
execFileSync('which', [name], { stdio: 'pipe' });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
class CaptureService {
|
||||
constructor({ store, settings, getWindow, notify }) {
|
||||
this.store = store;
|
||||
this.settings = settings;
|
||||
this.getWindow = getWindow;
|
||||
this.notify = notify;
|
||||
this.session = null; // { guideId, paused, count }
|
||||
this.session = null; // { guideId, paused, count, intervalSec }
|
||||
this.intervalTimer = null;
|
||||
this.clickWatcher = null;
|
||||
this.lastClickCapture = 0;
|
||||
this.shooting = false;
|
||||
}
|
||||
|
||||
state() {
|
||||
return this.session
|
||||
? { active: true, paused: this.session.paused, guideId: this.session.guideId, count: this.session.count }
|
||||
: { active: false };
|
||||
? {
|
||||
active: true,
|
||||
paused: this.session.paused,
|
||||
guideId: this.session.guideId,
|
||||
count: this.session.count,
|
||||
intervalSec: this.session.intervalSec || 0,
|
||||
clickCapture: Boolean(this.clickWatcher),
|
||||
clickCaptureAvailable: this.clickCaptureAvailable(),
|
||||
}
|
||||
: { active: false, clickCaptureAvailable: this.clickCaptureAvailable() };
|
||||
}
|
||||
|
||||
startSession(guideId) {
|
||||
this.session = { guideId, paused: false, count: 0 };
|
||||
clickCaptureAvailable() {
|
||||
if (this._clickAvail === undefined) {
|
||||
this._clickAvail = process.platform === 'win32' || (process.platform === 'linux' && hasBinary('xinput'));
|
||||
}
|
||||
return this._clickAvail;
|
||||
}
|
||||
|
||||
startSession(guideId, { intervalSec = null } = {}) {
|
||||
this.finishSession();
|
||||
// Default trigger: clicks when the platform supports it, otherwise an
|
||||
// interval so a session always produces steps even if the global hotkey
|
||||
// never fires (common under Wayland/WSLg).
|
||||
let interval = intervalSec;
|
||||
if (interval == null) {
|
||||
interval = this.clickCaptureAvailable() ? 0 : (this.settings.get('capture.autoIntervalSec') || 5);
|
||||
}
|
||||
this.session = { guideId, paused: false, count: 0, intervalSec: interval };
|
||||
if (this.settings.get('capture.captureOutsideClicks') !== false) this.startClickWatcher();
|
||||
this.applyInterval();
|
||||
this.notify('capture:state', this.state());
|
||||
}
|
||||
|
||||
setInterval(intervalSec) {
|
||||
if (!this.session) return this.state();
|
||||
this.session.intervalSec = Math.max(0, Number(intervalSec) || 0);
|
||||
this.applyInterval();
|
||||
this.notify('capture:state', this.state());
|
||||
return this.state();
|
||||
}
|
||||
|
||||
applyInterval() {
|
||||
if (this.intervalTimer) {
|
||||
clearInterval(this.intervalTimer);
|
||||
this.intervalTimer = null;
|
||||
}
|
||||
const sec = this.session && this.session.intervalSec;
|
||||
if (sec > 0) {
|
||||
this.intervalTimer = setInterval(() => {
|
||||
this.sessionCapture('interval').catch(() => {});
|
||||
}, sec * 1000);
|
||||
}
|
||||
}
|
||||
|
||||
togglePause(force) {
|
||||
if (!this.session) return;
|
||||
this.session.paused = typeof force === 'boolean' ? force : !this.session.paused;
|
||||
this.notify('capture:state', this.state());
|
||||
}
|
||||
|
||||
finishSession() {
|
||||
if (this.intervalTimer) {
|
||||
clearInterval(this.intervalTimer);
|
||||
this.intervalTimer = null;
|
||||
}
|
||||
this.stopClickWatcher();
|
||||
this.session = null;
|
||||
}
|
||||
|
||||
async hotkeyCapture() {
|
||||
/** One capture inside the active session (hotkey/click/interval/manual). */
|
||||
async sessionCapture(trigger = 'hotkey') {
|
||||
if (!this.session || this.session.paused) return { ok: false, reason: 'no active capture session' };
|
||||
if (this.shooting) return { ok: false, reason: 'capture already in progress' };
|
||||
this.shooting = true;
|
||||
try {
|
||||
const mode = this.settings.get('capture.mode') || 'fullscreen';
|
||||
const result = await this.shoot({
|
||||
guideId: this.session.guideId,
|
||||
@@ -53,9 +131,76 @@ class CaptureService {
|
||||
});
|
||||
if (result.ok) {
|
||||
this.session.count += 1;
|
||||
this.notify('capture:added', { guideId: this.session.guideId, step: result.step });
|
||||
this.notify('capture:added', { guideId: this.session.guideId, step: result.step, trigger });
|
||||
this.notify('capture:state', this.state());
|
||||
}
|
||||
return result;
|
||||
} finally {
|
||||
this.shooting = false;
|
||||
}
|
||||
}
|
||||
|
||||
hotkeyCapture() {
|
||||
return this.sessionCapture('hotkey');
|
||||
}
|
||||
|
||||
// ---- click-triggered capture --------------------------------------------
|
||||
|
||||
startClickWatcher() {
|
||||
this.stopClickWatcher();
|
||||
try {
|
||||
if (process.platform === 'linux' && hasBinary('xinput')) {
|
||||
// Stream raw button events from the X server; one capture per press.
|
||||
this.clickWatcher = spawn('xinput', ['test-xi2', '--root'], { stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
let sawPress = false;
|
||||
this.clickWatcher.stdout.on('data', (chunk) => {
|
||||
const text = chunk.toString();
|
||||
if (/RawButtonPress|ButtonPress/.test(text)) sawPress = true;
|
||||
if (sawPress) {
|
||||
sawPress = false;
|
||||
this.onOsClick();
|
||||
}
|
||||
});
|
||||
} else if (process.platform === 'win32') {
|
||||
// Poll the left mouse button via GetAsyncKeyState; print one line per click.
|
||||
const ps = `
|
||||
Add-Type -Namespace W -Name U -MemberDefinition '[DllImport("user32.dll")] public static extern short GetAsyncKeyState(int k);'
|
||||
$down = $false
|
||||
while ($true) {
|
||||
$s = [W.U]::GetAsyncKeyState(0x01) -band 0x8000
|
||||
if ($s -and -not $down) { Write-Output CLICK }
|
||||
$down = [bool]$s
|
||||
Start-Sleep -Milliseconds 40
|
||||
}`;
|
||||
this.clickWatcher = spawn('powershell.exe', ['-NoProfile', '-Command', ps], { stdio: ['ignore', 'pipe', 'ignore'] });
|
||||
this.clickWatcher.stdout.on('data', (chunk) => {
|
||||
if (chunk.toString().includes('CLICK')) this.onOsClick();
|
||||
});
|
||||
}
|
||||
if (this.clickWatcher) {
|
||||
this.clickWatcher.on('error', () => { this.clickWatcher = null; });
|
||||
this.clickWatcher.on('exit', () => { this.clickWatcher = null; });
|
||||
}
|
||||
} catch {
|
||||
this.clickWatcher = null;
|
||||
}
|
||||
}
|
||||
|
||||
stopClickWatcher() {
|
||||
if (this.clickWatcher) {
|
||||
try { this.clickWatcher.kill(); } catch { /* already gone */ }
|
||||
this.clickWatcher = null;
|
||||
}
|
||||
}
|
||||
|
||||
onOsClick() {
|
||||
if (!this.session || this.session.paused) return;
|
||||
// Ignore clicks on StepForge itself (pausing, finishing, editing).
|
||||
if (BrowserWindow.getFocusedWindow()) return;
|
||||
const now = Date.now();
|
||||
if (now - this.lastClickCapture < CLICK_DEBOUNCE_MS) return;
|
||||
this.lastClickCapture = now;
|
||||
this.sessionCapture('click').catch(() => {});
|
||||
}
|
||||
|
||||
autoTitle(mode) {
|
||||
|
||||
+33
-2
@@ -98,6 +98,35 @@ function createWindow() {
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
// Dev-only self-test: exercise the exact hotkey-session capture path
|
||||
// (hide window -> grab -> showInactive) several times, then exit.
|
||||
if (process.env.STEPFORGE_CAPTURE_SELFTEST) {
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const guide = store.createGuide({ title: 'hotkey selftest' });
|
||||
capture.startSession(guide.guideId, { intervalSec: 0 });
|
||||
const results = [];
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const r = await capture.hotkeyCapture();
|
||||
results.push(r.ok ? 'OK' : `FAIL:${r.reason}`);
|
||||
await new Promise((res) => setTimeout(res, 500));
|
||||
}
|
||||
console.log('HOTKEY-SELFTEST', JSON.stringify(results),
|
||||
'steps:', store.getGuide(guide.guideId).stepsOrder.length);
|
||||
|
||||
// Interval auto-capture: 1s timer should add ~3 steps in 3.6s.
|
||||
const guide2 = store.createGuide({ title: 'interval selftest' });
|
||||
capture.startSession(guide2.guideId, { intervalSec: 1 });
|
||||
await new Promise((res) => setTimeout(res, 3600));
|
||||
capture.finishSession();
|
||||
console.log('INTERVAL-SELFTEST steps:', store.getGuide(guide2.guideId).stepsOrder.length);
|
||||
} catch (err) {
|
||||
console.log('SELFTEST ERROR', err.message);
|
||||
} finally {
|
||||
app.quit();
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
});
|
||||
mainWindow.on('closed', () => { mainWindow = null; });
|
||||
}
|
||||
@@ -267,11 +296,13 @@ function setupIpc() {
|
||||
if (result.ok) reindex(guideId);
|
||||
return result;
|
||||
});
|
||||
h('capture:session', ({ action, guideId }) => {
|
||||
if (action === 'start') capture.startSession(guideId);
|
||||
h('capture:session', async ({ action, guideId, intervalSec }) => {
|
||||
if (action === 'start') capture.startSession(guideId, { intervalSec: intervalSec ?? null });
|
||||
else if (action === 'pause') capture.togglePause(true);
|
||||
else if (action === 'resume') capture.togglePause(false);
|
||||
else if (action === 'finish') capture.finishSession();
|
||||
else if (action === 'interval') capture.setInterval(intervalSec);
|
||||
else if (action === 'shoot') await capture.sessionCapture('manual');
|
||||
const state = capture.state();
|
||||
sendToRenderer('capture:state', state);
|
||||
return state;
|
||||
|
||||
+39
-10
@@ -81,6 +81,7 @@ class StepForgeApp {
|
||||
|
||||
async onCaptureAdded(payload) {
|
||||
if (!payload || !payload.guideId) return;
|
||||
this.updateCaptureState(await api.capture.state());
|
||||
if (this.state.view === 'editor' && this.editor.guideId === payload.guideId) {
|
||||
await this.editor.reload(payload.step && payload.step.stepId ? payload.step.stepId : this.editor.selectedStepId);
|
||||
return;
|
||||
@@ -187,7 +188,13 @@ class StepForgeApp {
|
||||
const state = await api.capture.session({ action: 'start', guideId: guide.guideId });
|
||||
this.updateCaptureState(state);
|
||||
const hotkey = this.state.settings?.capture?.hotkeyCapture;
|
||||
toast(hotkey ? `Capture session started — press ${hotkey} to grab a step.` : 'Capture session started.');
|
||||
if (state.clickCapture) {
|
||||
toast('Capture session started — every click outside StepForge grabs a step.');
|
||||
} else if (state.intervalSec > 0) {
|
||||
toast(`Capture session started — auto-capturing every ${state.intervalSec}s (use the REC bar to pause or change).`);
|
||||
} else {
|
||||
toast(hotkey ? `Capture session started — press ${hotkey} or use Shoot in the REC bar.` : 'Capture session started.');
|
||||
}
|
||||
}
|
||||
|
||||
async openExistingWorkspace() {
|
||||
@@ -229,21 +236,43 @@ class StepForgeApp {
|
||||
return;
|
||||
}
|
||||
this.captureStatus.classList.remove('hidden');
|
||||
const s = this.captureState;
|
||||
const send = (payload) => api.capture.session(payload).then((next) => this.updateCaptureState(next));
|
||||
|
||||
// What is currently triggering captures, so the user knows what to do.
|
||||
const trigger = s.paused ? 'paused'
|
||||
: s.clickCapture ? 'on click'
|
||||
: s.intervalSec > 0 ? `every ${s.intervalSec}s`
|
||||
: 'hotkey only';
|
||||
|
||||
const shootBtn = el('button', {
|
||||
type: 'button',
|
||||
title: 'Capture a step now (the app hides itself for the shot)',
|
||||
onClick: () => send({ action: 'shoot' }),
|
||||
}, 'Shoot');
|
||||
|
||||
// Cycle interval auto-capture: off -> 3s -> 5s -> 10s -> off.
|
||||
const nextInterval = { 0: 3, 3: 5, 5: 10, 10: 0 }[s.intervalSec ?? 0] ?? 3;
|
||||
const autoBtn = el('button', {
|
||||
type: 'button',
|
||||
title: 'Automatically capture a step on a timer',
|
||||
onClick: () => send({ action: 'interval', intervalSec: nextInterval }),
|
||||
}, s.intervalSec > 0 ? `Auto ${s.intervalSec}s` : 'Auto off');
|
||||
|
||||
const pauseBtn = el('button', {
|
||||
type: 'button',
|
||||
onClick: () => {
|
||||
const action = this.captureState.paused ? 'resume' : 'pause';
|
||||
api.capture.session({ action, guideId: this.editorMeta?.guide?.id || this.editorMeta?.guide?.guideId || null })
|
||||
.then((next) => this.updateCaptureState(next));
|
||||
},
|
||||
}, this.captureState.paused ? 'Resume' : 'Pause');
|
||||
onClick: () => send({ action: s.paused ? 'resume' : 'pause' }),
|
||||
}, s.paused ? 'Resume' : 'Pause');
|
||||
|
||||
const finishBtn = el('button', {
|
||||
type: 'button',
|
||||
onClick: () => api.capture.session({ action: 'finish', guideId: this.editorMeta?.guide?.id || this.editorMeta?.guide?.guideId || null })
|
||||
.then((next) => this.updateCaptureState(next)),
|
||||
onClick: () => send({ action: 'finish' }),
|
||||
}, 'Finish');
|
||||
|
||||
this.captureStatus.append(
|
||||
el('span', {}, `Capture ${this.captureState.count || 0}`),
|
||||
el('span', { title: `Capture session — ${trigger}` }, `REC ${s.count || 0} · ${trigger}`),
|
||||
shootBtn,
|
||||
autoBtn,
|
||||
pauseBtn,
|
||||
finishBtn,
|
||||
);
|
||||
|
||||
@@ -125,8 +125,12 @@ class GuideEditor {
|
||||
this.steps = steps;
|
||||
this.stepMap = new Map(steps.map((step) => [step.stepId, step]));
|
||||
if (!this.shellMounted) this.mountShell();
|
||||
if (!this.selectedStepId || !this.stepMap.has(this.selectedStepId)) {
|
||||
this.selectedStepId = stepId && this.stepMap.has(stepId) ? stepId : (steps[0] && steps[0].stepId) || null;
|
||||
// An explicitly requested step (new capture, added step, restored
|
||||
// neighbour) wins; otherwise keep the current selection if it survived.
|
||||
if (stepId && this.stepMap.has(stepId)) {
|
||||
this.selectedStepId = stepId;
|
||||
} else if (!this.selectedStepId || !this.stepMap.has(this.selectedStepId)) {
|
||||
this.selectedStepId = (steps[0] && steps[0].stepId) || null;
|
||||
}
|
||||
this.selectedAnnotationId = null;
|
||||
this.renderAll();
|
||||
|
||||
@@ -18,6 +18,7 @@ const DEFAULT_SETTINGS = {
|
||||
hotkeyPauseResume: 'CommandOrControl+Shift+2',
|
||||
captureOutsideClicks: true,
|
||||
confirmSimpleCapture: false,
|
||||
autoIntervalSec: 5, // session fallback when click capture is unavailable
|
||||
},
|
||||
editor: {
|
||||
focusedViewDefaultForNewSteps: false,
|
||||
|
||||
Reference in New Issue
Block a user