diff --git a/README.md b/README.md index 5012921..d6a105b 100644 --- a/README.md +++ b/README.md @@ -50,8 +50,8 @@ The core workflow: - **Capture engine** — the editor's **Capture ▾** button offers full screen, 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 + OS allows it, or on a 3/5/10 s auto-interval everywhere else. The REC bar + shows the live count and the start/pause control. 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. diff --git a/app/capture.js b/app/capture.js index fb5e293..b380094 100644 --- a/app/capture.js +++ b/app/capture.js @@ -2,7 +2,7 @@ const path = require('node:path'); const { spawn, execFileSync } = require('node:child_process'); -const { desktopCapturer, screen, BrowserWindow, nativeImage, Tray, Menu, Notification } = require('electron'); +const { desktopCapturer, screen, BrowserWindow, nativeImage, Tray, Menu } = require('electron'); const { expandPlaceholders } = require('../core/placeholders'); const raster = require('../core/raster'); const { encodePng } = require('../core/png'); @@ -103,7 +103,13 @@ function hasBinary(name) { } class CaptureService { - constructor({ store, settings, getWindow, notify, screenApi = screen }) { + constructor({ + store, + settings, + getWindow, + notify, + screenApi = screen, + }) { this.store = store; this.settings = settings; this.getWindow = getWindow; @@ -184,7 +190,6 @@ class CaptureService { // the user explicitly presses "Start recording" in the capture bar, so // New Capture never makes the window vanish out from under them. this.session = { guideId, paused: true, count: 0, intervalSec: interval }; - this.sessionNotificationShown = false; if (this.settings.get('capture.captureOutsideClicks') !== false) this.startClickWatcher(); this.applyInterval(); this.notify('capture:state', this.state()); @@ -352,7 +357,6 @@ class CaptureService { const settleMs = Number(this.settings.get('capture.postHideSettleMs')); const run = async () => { if (!this.session || this.session.paused) { this.warmingUp = false; return; } - const startedAt = Date.now(); if (recorderWanted) { // Warm the recorder, but never let a slow backend start (it waits up // to several seconds for the capture stream) keep the window visible @@ -366,14 +370,6 @@ class CaptureService { if (capTimer) clearTimeout(capTimer); if (!this.session || this.session.paused) { this.warmingUp = false; return; } } - // Keep the window visible briefly so the user sees the transition even - // when warmup was instant; warmup time counts toward this. - const minVisibleMs = wantHide ? 400 : 0; - const elapsed = Date.now() - startedAt; - if (elapsed < minVisibleMs) { - await new Promise((r) => setTimeout(r, minVisibleMs - elapsed)); - if (!this.session || this.session.paused) { this.warmingUp = false; return; } - } if (wantHide && win && !win.isDestroyed() && win.isVisible()) { win.hide(); // Let a couple of frames of the now-unobscured screen land before @@ -381,16 +377,6 @@ class CaptureService { // app window that was just dismissed. await new Promise((r) => setTimeout(r, Number.isFinite(settleMs) ? settleMs : 150)); } - // Window hidden and buffer primed — clicks now count. - if (!process.env.STEPFORGE_SCREENSHOT && !this.sessionNotificationShown) { - try { - new Notification({ - title: 'StepForge is recording', - body: 'Use the red tray icon to pause or finish capture.', - }).show(); - this.sessionNotificationShown = true; - } catch { /* notifications unavailable on this desktop */ } - } this.warmingUp = false; }; run().catch(() => { this.warmingUp = false; }); @@ -407,7 +393,6 @@ class CaptureService { this.stopClickFrameBackend(); this.destroySessionTray(); this.session = null; - this.sessionNotificationShown = false; if (this.hiddenForSession) { this.hiddenForSession = false; this.showWindow(); diff --git a/app/main.js b/app/main.js index f929f09..251d5bd 100644 --- a/app/main.js +++ b/app/main.js @@ -494,7 +494,6 @@ function setupIpc() { 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 de97e3b..298a5aa 100644 --- a/app/renderer/app.js +++ b/app/renderer/app.js @@ -191,18 +191,7 @@ class StepForgeApp { const guide = await api.library.create({ title: 'Untitled capture' }); await this.refreshData(); await this.openGuide(guide.guideId); - const state = await api.capture.session({ action: 'start', guideId: guide.guideId }); - this.updateCaptureState(state); - const hotkey = this.state.settings?.capture?.hotkeyCapture; - let how; - if (state.clickCapture) { - how = 'every click will grab a step'; - } else if (state.intervalSec > 0) { - how = `a step will be grabbed every ${state.intervalSec}s`; - } else { - how = hotkey ? `press ${hotkey} to grab steps` : 'use Shoot to grab steps'; - } - toast(`Click "Start recording" in the red bar when you're ready — ${how}. StepForge tucks away; use the red tray icon to pause or finish.`); + await this.armCaptureSession(guide.guideId); } async openExistingWorkspace() { @@ -230,14 +219,26 @@ class StepForgeApp { this.renderTopbar(); } + // Start a paused session, optionally show a reminder, and continue once + // the user acknowledges it. + async armCaptureSession(guideId, reminder = null) { + const state = await api.capture.session({ action: 'start', guideId }); + this.updateCaptureState(state); + if (!reminder) return state; + const acknowledged = await dialogs.showRecordingReminder(reminder); + if (!acknowledged) return state; + const next = await api.capture.session({ action: 'resume', guideId }); + this.updateCaptureState(next); + return next; + } + // Opens a guide and arms (paused) capture for it, so the red REC bar pops // up right away with a "Start recording" option to resume capturing steps. async openGuideAndArmCapture(guideId, stepId = null) { await this.openGuide(guideId, stepId); // Don't restart (and reset the count of) a session already running for this guide. if (this.captureState?.active && this.captureState.guideId === guideId) return; - const state = await api.capture.session({ action: 'start', guideId }); - this.updateCaptureState(state); + await this.armCaptureSession(guideId); } onEditorMeta(meta) { @@ -268,37 +269,21 @@ class StepForgeApp { : 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', title: notStarted ? 'StepForge tucks away and starts capturing' : '', - onClick: () => send({ action: s.paused ? 'resume' : 'pause' }), + onClick: async () => { + if (notStarted) { + const acknowledged = await dialogs.showRecordingReminder(); + if (!acknowledged) return; + } + send({ action: s.paused ? 'resume' : 'pause' }); + }, }, notStarted ? 'Start recording' : s.paused ? 'Resume' : 'Pause'); - const finishBtn = el('button', { - type: 'button', - onClick: () => send({ action: 'finish' }), - }, 'Finish'); - this.captureStatus.append( - el('span', { title: `Capture session — ${trigger}` }, `REC ${s.count || 0} · ${trigger}`), - shootBtn, - autoBtn, + el('span', { title: `Capture session — ${trigger}` }, `Rec - ${trigger}`), pauseBtn, - finishBtn, ); } @@ -317,7 +302,7 @@ class StepForgeApp { const guide = this.editorMeta?.guide; this.topbarContext.append( - el('button', { type: 'button', onClick: () => this.showLibrary() }, 'Back'), + el('button', { type: 'button', onClick: () => this.showLibrary() }, 'Library'), el('button.primary', { type: 'button', title: 'Capture a screenshot step', diff --git a/app/renderer/dialogs.js b/app/renderer/dialogs.js index f037f5d..2b03c1f 100644 --- a/app/renderer/dialogs.js +++ b/app/renderer/dialogs.js @@ -693,6 +693,30 @@ function showInfoDialog(title, bodyText) { }); } +function showRecordingReminder({ + actionLabel = 'Continue', + headline = 'StepForge will hide after you continue.', +} = {}) { + return new Promise((resolve) => { + const { close } = openModal({ + title: 'Before recording starts', + body: el('div.recording-notice', {}, + el('div.recording-notice__badge', {}, 'Recording tip'), + el('div.recording-notice__title', {}, headline), + el('div.recording-notice__text', {}, + 'When you want to pause or stop, use the red tray icon in the system tray.', + ), + ), + footer: [ + el('button.primary', { + onClick: () => { close(); resolve(true); }, + }, actionLabel), + ], + onClose: () => resolve(false), + }); + }); +} + window.StepForgeDialogs = { promptText, showQuickActions, @@ -704,5 +728,6 @@ window.StepForgeDialogs = { showPlaceholdersDialog, showShortcutsDialog, showTemplateManager, + showRecordingReminder, }; })(); diff --git a/app/renderer/editor.js b/app/renderer/editor.js index 310a086..2d9aa10 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -1221,7 +1221,6 @@ class GuideEditor { { label: 'Capture full screen', action: () => this.captureStep('fullscreen') }, { label: 'Capture window', action: () => this.captureStep('window') }, { label: 'Capture region…', action: () => this.captureStep('region') }, - { label: 'Capture after 3 s delay', action: () => this.captureStep('fullscreen', 3000) }, 'sep', { label: 'Paste image as step', action: () => this.pasteClipboardStep() }, { label: 'Import images…', action: () => this.importImageSteps() }, @@ -1322,7 +1321,6 @@ class GuideEditor { async startCaptureSession() { await api.capture.session({ action: 'start', guideId: this.guideId }); - this.onToast('Capture session ready — click "Start recording" in the red bar when you\'re set.'); this.emitMeta(); } diff --git a/app/renderer/style.css b/app/renderer/style.css index 04a5457..7d8febd 100644 --- a/app/renderer/style.css +++ b/app/renderer/style.css @@ -619,6 +619,30 @@ fieldset legend { background: var(--warn); color: var(--text); } +.recording-notice { + display: grid; + gap: 10px; +} +.recording-notice__badge { + width: fit-content; + padding: 4px 10px; + border-radius: 999px; + border: 1px solid var(--border); + background: var(--panel-2); + color: var(--danger); + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.recording-notice__title { + font-size: 18px; + font-weight: 700; +} +.recording-notice__text { + color: var(--muted); + line-height: 1.5; +} #modal-root:not(:empty) { position: fixed; diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 0ac8263..4685d64 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -96,14 +96,14 @@ Initial release. Existing Workspace (guide library), and Settings. The brand button returns to the welcome screen from any view. - 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. + 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. + The REC bar shows the live count and trigger, with the start/pause + control. - Recording sessions tuck the window away once and control everything from a red tray icon (capture now / pause / open / finish) instead of hiding the window for every shot — the app stays reachable @@ -161,7 +161,7 @@ Initial release. session now starts paused and the window only tucks away once the user presses "Start recording" in the capture bar, so the app doesn't vanish out from under you. -- The capture status bar (REC count / Shoot / Auto / Pause / Finish) is +- The capture status bar (REC count / start/pause control) is now shown only in the editor view; it no longer appears over the library when a session is still running in the background. - Click-triggered captures now grab the cursor position at the instant of diff --git a/tests/unit/capture.test.js b/tests/unit/capture.test.js index 8ec842b..51323ca 100644 --- a/tests/unit/capture.test.js +++ b/tests/unit/capture.test.js @@ -601,7 +601,7 @@ test('armRecording warms while visible, then hides and arms the session', async getBounds() { return { x: 0, y: 0, width: 800, height: 600 }; }, }; service.getWindow = () => win; - service.clickCaptureAvailable = () => true; + service.clickCaptureAvailable = () => false; // Stub the recorder so warmup resolves fast without real Electron. service.startClickFrameBackend = async () => {}; service.session = { guideId: 'g-arm', paused: false, count: 0, intervalSec: 0 }; @@ -1176,9 +1176,9 @@ test('a new session starts paused and does not hide the window until "Start reco // User clicks "Start recording" (the resume action). service.togglePause(false); assert.equal(service.session.paused, false); - assert.equal(win.hidden, 0, 'hide is deferred briefly so the user sees it happen'); + assert.equal(win.hidden, 0, 'hide is deferred until the resume path runs'); - await new Promise((r) => setTimeout(r, 450)); + await new Promise((r) => setTimeout(r, 25)); assert.equal(win.hidden, 1, 'window hides once recording actually starts'); } finally { service.finishSession();