Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b16531321 | ||
|
|
6c2c837a83 | ||
|
|
356334dd3e | ||
|
|
8c2229633f | ||
|
|
4311225d50 | ||
|
|
c7038a151a | ||
|
|
f0611a50b5 | ||
|
|
dfd0139dc5 | ||
|
|
1a0fda71f5 | ||
|
|
e46f01885b | ||
|
|
85b1f6f143 | ||
|
|
e8f6e4cd09 |
@@ -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.
|
||||
|
||||
+8
-23
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
+24
-39
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
+5
-5
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user