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 };
}
+331 -4
View File
@@ -206,6 +206,15 @@ class GuideEditor {
this.dom.forceNewPageToggle = el('label', {}, el('input', { type: 'checkbox' }), ' New page'),
this.dom.focusedViewToggle = el('label', {}, el('input', { type: 'checkbox' }), ' Focused'),
),
this.dom.focusedControls = el('div.focused-controls.hidden', {},
el('div.form-row', {}, el('label', {}, 'Zoom'),
this.dom.fvZoom = el('input', { type: 'range', min: 1, max: 3, step: 0.05, value: 1.5 })),
el('div.form-row', {}, el('label', {}, 'Pan X'),
this.dom.fvPanX = el('input', { type: 'range', min: 0, max: 1, step: 0.01, value: 0.5 })),
el('div.form-row', {}, el('label', {}, 'Pan Y'),
this.dom.fvPanY = el('input', { type: 'range', min: 0, max: 1, step: 0.01, value: 0.5 })),
el('div.muted', {}, 'Exports crop to this view; the original image is never modified.'),
),
),
el('section', {},
el('h3', {}, 'Description'),
@@ -225,6 +234,15 @@ class GuideEditor {
this.dom.annotationList = el('div', { className: 'annotation-list' }),
this.dom.annotationEditor = el('div', { className: 'annotation-editor' }),
),
el('section', {},
el('h3', {}, 'Blocks'),
this.dom.blocksList = el('div', { className: 'blocks-list' }),
el('div.row', {},
this.dom.addTextBlockBtn = el('button', { type: 'button' }, '+ Text block'),
this.dom.addCodeBlockBtn = el('button', { type: 'button' }, '+ Code'),
this.dom.addTableBlockBtn = el('button', { type: 'button' }, '+ Table'),
),
),
el('section', {},
el('h3', {}, 'Guide'),
this.dom.guideSummary = el('div.muted', {}),
@@ -331,8 +349,23 @@ class GuideEditor {
};
this.pendingSave = true;
this.saveStepDebounced();
this.syncFocusedControls();
this.emitMeta();
});
const bindFocusedSlider = (node, field) => node.addEventListener('input', () => {
const step = this.currentStep;
if (!step || !step.focusedView) return;
step.focusedView[field] = Number(node.value);
this.pendingSave = true;
this.saveStepDebounced();
});
bindFocusedSlider(this.dom.fvZoom, 'zoom');
bindFocusedSlider(this.dom.fvPanX, 'panX');
bindFocusedSlider(this.dom.fvPanY, 'panY');
this.dom.addTextBlockBtn.addEventListener('click', () => this.addBlock('text'));
this.dom.addCodeBlockBtn.addEventListener('click', () => this.addBlock('code'));
this.dom.addTableBlockBtn.addEventListener('click', () => this.addBlock('table'));
this.dom.descEditor.addEventListener('focus', () => {
if (this.currentStep) this.pushCanvasHistory('description');
@@ -362,12 +395,130 @@ class GuideEditor {
renderAll() {
this.renderStepList();
this.syncStepFields();
this.syncFocusedControls();
this.renderCanvas();
this.renderAnnotationPanel();
this.renderBlocksPanel();
this.renderGuidePanel();
this.emitMeta();
}
syncFocusedControls() {
const fv = this.currentStep?.focusedView;
const enabled = Boolean(fv && fv.enabled);
this.dom.focusedControls.classList.toggle('hidden', !enabled);
if (enabled) {
this.dom.fvZoom.value = fv.zoom || 1.5;
this.dom.fvPanX.value = fv.panX ?? 0.5;
this.dom.fvPanY.value = fv.panY ?? 0.5;
}
}
// ---- text / code / table blocks ----------------------------------------
addBlock(kind) {
const step = this.currentStep;
if (!step) {
this.onToast('Select a step first.', { error: true });
return;
}
const id = `blk-${Date.now().toString(36)}`;
if (kind === 'text') {
step.textBlocks = step.textBlocks || [];
step.textBlocks.push({ id, position: 'after-description', level: 'info', title: '', descriptionHtml: '' });
} else if (kind === 'code') {
step.codeBlocks = step.codeBlocks || [];
step.codeBlocks.push({ id, language: '', code: '' });
} else if (kind === 'table') {
step.tableBlocks = step.tableBlocks || [];
step.tableBlocks.push({ id, rows: [['Column A', 'Column B'], ['', '']] });
}
this.pendingSave = true;
this.saveStepDebounced();
this.renderBlocksPanel();
}
renderBlocksPanel() {
clearNode(this.dom.blocksList);
const step = this.currentStep;
if (!step) {
this.dom.blocksList.append(el('div.muted', {}, 'Select a step to add blocks.'));
return;
}
const save = () => {
this.pendingSave = true;
this.saveStepDebounced();
};
const removeBtn = (onRemove) => el('button.icon.danger', {
type: 'button', title: 'Remove block',
onClick: () => { onRemove(); save(); this.renderBlocksPanel(); },
}, '✕');
for (const tb of step.textBlocks || []) {
const position = makeSelect(tb.position, [
{ value: 'before-title', label: 'Before title' },
{ value: 'after-title', label: 'After title' },
{ value: 'before-image', label: 'Before image' },
{ value: 'after-image', label: 'After image' },
{ value: 'before-description', label: 'Before description' },
{ value: 'after-description', label: 'After description' },
]);
const level = makeSelect(tb.level, [
{ value: 'info', label: 'Note' },
{ value: 'warn', label: 'Warning' },
{ value: 'error', label: 'Important' },
{ value: 'success', label: 'Tip' },
]);
const title = el('input', { type: 'text', value: tb.title || '', placeholder: 'Block title' });
const body = el('textarea', { rows: 2, placeholder: 'Block text' });
body.value = (tb.descriptionHtml || '').replace(/<[^>]+>/g, '');
position.addEventListener('change', () => { tb.position = position.value; save(); });
level.addEventListener('change', () => { tb.level = level.value; save(); });
title.addEventListener('input', () => { tb.title = title.value; save(); });
body.addEventListener('input', () => { tb.descriptionHtml = `<p>${escapeHtml(body.value)}</p>`; save(); });
this.dom.blocksList.append(el('div.block-card', {},
el('div.row', {}, el('strong', {}, 'Text block'), el('span.spacer'),
removeBtn(() => { step.textBlocks = step.textBlocks.filter((b) => b !== tb); })),
el('div.row', {}, level, position),
title, body,
));
}
for (const cb of step.codeBlocks || []) {
const lang = el('input', { type: 'text', value: cb.language || '', placeholder: 'Language (e.g. bash)' });
const code = el('textarea', { rows: 3, placeholder: 'Code', spellcheck: false });
code.value = cb.code || '';
code.style.fontFamily = 'monospace';
lang.addEventListener('input', () => { cb.language = lang.value; save(); });
code.addEventListener('input', () => { cb.code = code.value; save(); });
this.dom.blocksList.append(el('div.block-card', {},
el('div.row', {}, el('strong', {}, 'Code block'), el('span.spacer'),
removeBtn(() => { step.codeBlocks = step.codeBlocks.filter((b) => b !== cb); })),
lang, code,
));
}
for (const tbl of step.tableBlocks || []) {
const grid = el('textarea', { rows: 3, placeholder: 'One row per line, cells separated by |', spellcheck: false });
grid.value = (tbl.rows || []).map((r) => r.join(' | ')).join('\n');
grid.addEventListener('input', () => {
tbl.rows = grid.value.split('\n').filter((l) => l.trim() !== '')
.map((line) => line.split('|').map((c) => c.trim()));
save();
});
this.dom.blocksList.append(el('div.block-card', {},
el('div.row', {}, el('strong', {}, 'Table'), el('span.spacer'),
removeBtn(() => { step.tableBlocks = step.tableBlocks.filter((b) => b !== tbl); })),
el('div.muted', {}, 'First line is the header row.'),
grid,
));
}
if (!(step.textBlocks || []).length && !(step.codeBlocks || []).length && !(step.tableBlocks || []).length) {
this.dom.blocksList.append(el('div.muted', {}, 'Informational text, code, and table blocks render in every export.'));
}
}
renderStepList() {
const current = this.currentStep;
const numbers = stepNumberMap(this.steps);
@@ -563,6 +714,10 @@ class GuideEditor {
},
}, 'Delete annotation'),
),
el('div.row', {},
el('button', { type: 'button', title: 'Copy this style to every annotation of the same type in this step', onClick: () => this.applyStyleAcross('step') }, 'Style → step'),
el('button', { type: 'button', title: 'Copy this style to every annotation of the same type in the whole guide', onClick: () => this.applyStyleAcross('guide') }, 'Style → guide'),
),
);
this.dom.annotationEditor.append(annSection);
@@ -866,8 +1021,10 @@ class GuideEditor {
this.onToast('Images imported.');
}
async captureStep(mode) {
const result = await api.capture.shoot({ guideId: this.guideId, mode, delayMs: 0 });
async captureStep(mode, delayMs = null) {
const result = mode === 'region'
? await api.capture.region({ guideId: this.guideId })
: await api.capture.shoot({ guideId: this.guideId, mode, delayMs });
if (result && result.ok) {
await this.reload(result.step.stepId);
this.onToast('Captured.');
@@ -876,6 +1033,113 @@ class GuideEditor {
}
}
/** Capture menu anchored at a toolbar button. */
async openCaptureMenu(event) {
const rect = event.target.getBoundingClientRect();
const session = (await api.capture.state())?.active;
contextMenu(rect.left, rect.bottom + 4, [
{ 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() },
'sep',
session
? { label: 'Finish capture session', action: () => this.finishCaptureSession() }
: { label: 'Start capture session (hotkey)', action: () => this.startCaptureSession() },
]);
}
async pasteClipboardStep() {
const result = await api.step.fromClipboard({ guideId: this.guideId });
if (result && result.ok) {
await this.reload(result.step.stepId);
this.onToast('Image pasted as a new step.');
} else {
this.onToast(result?.reason || 'Clipboard has no image.', { error: true });
}
}
async shareAsFile() {
const result = await api.archive.export({ guideId: this.guideId });
if (result && result.ok) this.onToast(`Shared to ${result.path}`);
}
async openBackupsDialog() {
if (!this.guideId) return;
const snapshots = await api.snapshots.list({ guideId: this.guideId });
await dialogs.showBackupsDialog({
snapshots,
onCreate: async () => {
await api.snapshots.create({ guideId: this.guideId, label: 'manual' });
this.onToast('Snapshot created.');
return api.snapshots.list({ guideId: this.guideId });
},
onRestore: async (name) => {
const ok = await confirmDialog(
`Restore "${name}"? Current state is snapshotted first, so this is undoable.`,
{ okLabel: 'Restore' },
);
if (!ok) return false;
await api.snapshots.restore({ guideId: this.guideId, name });
await this.reload();
this.onToast('Snapshot restored.');
return true;
},
});
}
async openGuidePlaceholders() {
if (!this.guide) return;
await dialogs.showPlaceholdersDialog({
title: 'Guide placeholders',
hint: 'Use [[Name]] in titles, descriptions, and blocks. Guide values override global ones.',
values: this.guide.placeholders || {},
onSave: async (values) => {
this.guide.placeholders = values;
await api.guide.save({ guide: this.guide });
this.onToast('Placeholders saved.');
},
});
}
openShortcutsHelp() {
dialogs.showShortcutsDialog();
}
/** Copy the selected annotation's style to every annotation of the same type. */
async applyStyleAcross(scope) {
const source = this.canvas.selected();
if (!source) return;
const patch = clone(source.style || {});
if (scope === 'step') {
const step = this.currentStep;
for (const ann of step.annotations || []) {
if (ann.type === source.type && ann.id !== source.id) ann.style = { ...ann.style, ...patch };
}
step.annotations = clone(step.annotations);
await this.flushStep(step);
this.onToast(`Style applied to all ${source.type} annotations in this step.`);
} else {
for (const step of this.steps) {
let touched = false;
for (const ann of step.annotations || []) {
if (ann.type === source.type && ann.id !== source.id) {
ann.style = { ...ann.style, ...patch };
touched = true;
}
}
if (touched || step.stepId === this.currentStep?.stepId) {
await api.step.save({ guideId: this.guideId, step });
}
}
await this.reload(this.selectedStepId);
this.onToast(`Style applied to all ${source.type} annotations in the guide.`);
}
}
async startCaptureSession() {
await api.capture.session({ action: 'start', guideId: this.guideId });
this.onToast('Capture session started.');
@@ -1182,11 +1446,73 @@ class GuideEditor {
return;
}
if (!isEditableTarget(e.target)) {
// Tool palette hotkeys (Folge-style single keys).
const TOOL_KEYS = {
s: 'select', r: 'rect', o: 'oval', l: 'line', a: 'arrow', t: 'text',
g: 'tooltip', n: 'number', b: 'blur', h: 'highlight', m: 'magnify',
u: 'cursor', c: 'crop',
};
if (!e.ctrlKey && !e.metaKey && !e.altKey && TOOL_KEYS[e.key.toLowerCase()]) {
e.preventDefault();
this.setTool(TOOL_KEYS[e.key.toLowerCase()]);
return;
}
if (e.key === 'PageUp' || e.key === 'PageDown') {
e.preventDefault();
const idx = this.steps.findIndex((s) => s.stepId === this.selectedStepId);
const next = this.steps[idx + (e.key === 'PageDown' ? 1 : -1)];
if (next) this.selectStep(next.stepId);
return;
}
if ((e.ctrlKey || e.metaKey) && (e.key === '=' || e.key === '+')) {
e.preventDefault();
this.setZoom(Math.min(3, (Number(this.currentZoom) || 1) + 0.25));
return;
}
if ((e.ctrlKey || e.metaKey) && e.key === '-') {
e.preventDefault();
this.setZoom(Math.max(0.25, (Number(this.currentZoom) || 1) - 0.25));
return;
}
if ((e.ctrlKey || e.metaKey) && e.key === '0') {
e.preventDefault();
this.setZoom('fit');
return;
}
// Copy / paste the selected annotation.
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c' && this.selectedAnnotationId) {
e.preventDefault();
this.annotationClipboard = clone(this.canvas.selected());
this.onToast('Annotation copied.');
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') {
e.preventDefault();
if (this.annotationClipboard && this.currentStep?.image) {
const copy = clone(this.annotationClipboard);
copy.id = `ann-${Date.now().toString(36)}`;
copy.x = Math.min(0.92, copy.x + 0.03);
copy.y = Math.min(0.92, copy.y + 0.03);
this.currentStep.annotations.push(copy);
this.canvas.setAnnotations(this.currentStep.annotations);
this.canvas.select(copy.id);
this.pendingSave = true;
this.saveStepDebounced();
} else {
this.pasteClipboardStep(); // OS clipboard image -> new step
}
return;
}
if (e.key === 'Delete' && this.selectedAnnotationId) {
e.preventDefault();
if (this.canvas.deleteSelected()) this.saveStepDebounced();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key === 'Delete') {
e.preventDefault();
this.deleteSelectedStep();
return;
}
if (e.key === 'ArrowUp' && e.altKey) {
e.preventDefault();
this.moveSelectedStep(-1);
@@ -1198,8 +1524,9 @@ class GuideEditor {
return;
}
if (e.key.startsWith('Arrow')) {
const dx = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0;
const dy = e.key === 'ArrowUp' ? -1 : e.key === 'ArrowDown' ? 1 : 0;
const speed = e.shiftKey ? 10 : 1; // shift nudges faster
const dx = (e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0) * speed;
const dy = (e.key === 'ArrowUp' ? -1 : e.key === 'ArrowDown' ? 1 : 0) * speed;
if (dx || dy) {
const moved = this.canvas.nudgeSelected(dx, dy);
if (moved) {