Capture hardening, editor blocks/shortcuts, handoff checklist

- capture.js: window-mode falls back to screen under WSLg; app window
  hides during capture (showInactive restore for hotkey path so focus
  is not stolen from the documented app); region capture hides too
- editor: Blocks panel (text/code/table block editors), focused-view
  zoom/pan sliders, capture context menu, paste-image step, share as
  .sfgz, apply-style-across step/guide, annotation copy/paste,
  tool-key shortcuts (s/r/o/l/a/t/g/n/b/h/m/u/c), PageUp/Down step nav,
  Ctrl+=/-/0 zoom, Ctrl+Delete step delete, Shift-arrow fast nudge
- prompt2.md: prescriptive handoff checklist for finishing remaining
  dialogs/topbar/IPC/polish work

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Iisyourdad
2026-06-10 22:01:20 -05:00
parent 6b40396053
commit 382dbc9717
4 changed files with 540 additions and 13 deletions
+43 -9
View File
@@ -49,6 +49,7 @@ class CaptureService {
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;
@@ -74,36 +75,69 @@ class CaptureService {
const display = screen.getDisplayNearestPoint(cursor);
const { width, height } = display.size;
const scale = display.scaleFactor || 1;
const types = mode === 'window' ? ['window'] : ['screen'];
// Ask for both kinds: some compositors (WSLg/Wayland portals) expose no
// individual window sources, so window mode falls back to the screen.
const sources = await desktopCapturer.getSources({
types,
types: mode === 'window' ? ['window', 'screen'] : ['screen'],
thumbnailSize: { width: Math.round(width * scale), height: Math.round(height * scale) },
});
if (!sources.length) throw new Error('no capture sources available (portal/permissions?)');
let source = sources[0];
let source = null;
if (mode === 'window') {
const win = this.getWindow();
const ownTitle = win ? win.getTitle() : '';
source = sources.find((s) => s.name && s.name !== ownTitle && !/stepforge/i.test(s.name)) || sources[0];
} else if (sources.length > 1) {
source = sources.find((s) => String(s.display_id) === String(display.id)) || sources[0];
const windows = sources.filter((s) => s.id.startsWith('window:'));
source = windows.find((s) => s.name && s.name !== ownTitle && !/stepforge/i.test(s.name))
|| windows[0]
|| sources.find((s) => s.id.startsWith('screen:'));
} else {
const screens = sources.filter((s) => s.id.startsWith('screen:'));
source = screens.find((s) => String(s.display_id) === String(display.id)) || screens[0] || sources[0];
}
if (!source) throw new Error('no capture source matched');
const image = source.thumbnail;
if (!image || image.isEmpty()) throw new Error('capture returned an empty image');
return { image, display, cursor };
}
/**
* Hide the app window while `fn` runs so screenshots show the user's work,
* not StepForge itself. Restores visibility afterwards.
*/
async withWindowHidden(fn, { refocus = true } = {}) {
const win = this.getWindow();
const wasVisible = win && !win.isDestroyed() && win.isVisible() && !win.isMinimized();
if (wasVisible) {
win.hide();
await new Promise((r) => setTimeout(r, 350)); // let the compositor repaint
}
try {
return await fn();
} finally {
if (wasVisible && win && !win.isDestroyed()) {
if (refocus) {
win.show();
win.focus();
} else {
win.showInactive();
}
}
}
}
/**
* Take a screenshot and append it to the guide as a new image step.
* Adds a click-marker annotation at the cursor position when enabled.
*/
async shoot({ guideId, mode = 'fullscreen', delayMs = null }) {
async shoot({ guideId, mode = 'fullscreen', delayMs = null, hideWindow = true, refocus = true }) {
const delay = delayMs == null ? this.settings.get('capture.delayMs') || 0 : delayMs;
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
let grabbed;
try {
grabbed = await this.grab(mode);
grabbed = hideWindow
? await this.withWindowHidden(() => this.grab(mode), { refocus })
: await this.grab(mode);
} catch (err) {
return { ok: false, reason: err.message };
}
@@ -145,7 +179,7 @@ class CaptureService {
async regionCapture(guideId) {
let grabbed;
try {
grabbed = await this.grab('fullscreen');
grabbed = await this.withWindowHidden(() => this.grab('fullscreen'));
} catch (err) {
return { ok: false, reason: err.message };
}