From 52fd516a5d67505840bba6df783d6592671df666 Mon Sep 17 00:00:00 2001 From: Iisyourdad Date: Wed, 10 Jun 2026 22:33:12 -0500 Subject: [PATCH] Make capture sessions continuous: click-capture + interval auto-capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 7 ++ README.md | 11 ++- app/capture.js | 183 ++++++++++++++++++++++++++++++++++++----- app/main.js | 35 +++++++- app/renderer/app.js | 49 ++++++++--- app/renderer/editor.js | 8 +- core/settings.js | 1 + 7 files changed, 257 insertions(+), 37 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b2b538f..9a32ae4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/README.md b/README.md index 0896e7c..851e35a 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/app/capture.js b/app/capture.js index dcb9764..8d6fdc8 100644 --- a/app/capture.js +++ b/app/capture.js @@ -1,61 +1,206 @@ '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' }; - const mode = this.settings.get('capture.mode') || 'fullscreen'; - const result = await this.shoot({ - guideId: this.session.guideId, - mode: mode === 'region' ? 'fullscreen' : mode, - delayMs: 0, - refocus: false, // don't steal focus from the app the user is documenting - }); - if (result.ok) { - this.session.count += 1; - this.notify('capture:added', { guideId: this.session.guideId, step: result.step }); + 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, + mode: mode === 'region' ? 'fullscreen' : mode, + delayMs: 0, + refocus: false, // don't steal focus from the app the user is documenting + }); + if (result.ok) { + this.session.count += 1; + this.notify('capture:added', { guideId: this.session.guideId, step: result.step, trigger }); + this.notify('capture:state', this.state()); + } + return result; + } finally { + this.shooting = false; } - return result; + } + + 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) { diff --git a/app/main.js b/app/main.js index 7aa547d..5b0c42d 100644 --- a/app/main.js +++ b/app/main.js @@ -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; diff --git a/app/renderer/app.js b/app/renderer/app.js index 31f7302..c3b767a 100644 --- a/app/renderer/app.js +++ b/app/renderer/app.js @@ -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, ); diff --git a/app/renderer/editor.js b/app/renderer/editor.js index 3f4e48d..9b020e3 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -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(); diff --git a/core/settings.js b/core/settings.js index e43043b..82ea8f2 100644 --- a/core/settings.js +++ b/core/settings.js @@ -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,