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:
@@ -0,0 +1,18 @@
|
|||||||
|
// Probe desktopCapturer under WSLg: can we actually grab a screen?
|
||||||
|
const { app, desktopCapturer, screen } = require('electron');
|
||||||
|
app.whenReady().then(async () => {
|
||||||
|
try {
|
||||||
|
const display = screen.getPrimaryDisplay();
|
||||||
|
const sources = await desktopCapturer.getSources({
|
||||||
|
types: ['screen', 'window'],
|
||||||
|
thumbnailSize: { width: 800, height: 600 },
|
||||||
|
});
|
||||||
|
console.log('SOURCES:', sources.length);
|
||||||
|
for (const s of sources.slice(0, 5)) {
|
||||||
|
console.log(' -', s.id, JSON.stringify(s.name), 'empty:', s.thumbnail.isEmpty(), 'size:', JSON.stringify(s.thumbnail.getSize()));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.log('CAPTURE-ERROR:', err.message);
|
||||||
|
}
|
||||||
|
app.quit();
|
||||||
|
});
|
||||||
+43
-9
@@ -49,6 +49,7 @@ class CaptureService {
|
|||||||
guideId: this.session.guideId,
|
guideId: this.session.guideId,
|
||||||
mode: mode === 'region' ? 'fullscreen' : mode,
|
mode: mode === 'region' ? 'fullscreen' : mode,
|
||||||
delayMs: 0,
|
delayMs: 0,
|
||||||
|
refocus: false, // don't steal focus from the app the user is documenting
|
||||||
});
|
});
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
this.session.count += 1;
|
this.session.count += 1;
|
||||||
@@ -74,36 +75,69 @@ class CaptureService {
|
|||||||
const display = screen.getDisplayNearestPoint(cursor);
|
const display = screen.getDisplayNearestPoint(cursor);
|
||||||
const { width, height } = display.size;
|
const { width, height } = display.size;
|
||||||
const scale = display.scaleFactor || 1;
|
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({
|
const sources = await desktopCapturer.getSources({
|
||||||
types,
|
types: mode === 'window' ? ['window', 'screen'] : ['screen'],
|
||||||
thumbnailSize: { width: Math.round(width * scale), height: Math.round(height * scale) },
|
thumbnailSize: { width: Math.round(width * scale), height: Math.round(height * scale) },
|
||||||
});
|
});
|
||||||
if (!sources.length) throw new Error('no capture sources available (portal/permissions?)');
|
if (!sources.length) throw new Error('no capture sources available (portal/permissions?)');
|
||||||
|
|
||||||
let source = sources[0];
|
let source = null;
|
||||||
if (mode === 'window') {
|
if (mode === 'window') {
|
||||||
const win = this.getWindow();
|
const win = this.getWindow();
|
||||||
const ownTitle = win ? win.getTitle() : '';
|
const ownTitle = win ? win.getTitle() : '';
|
||||||
source = sources.find((s) => s.name && s.name !== ownTitle && !/stepforge/i.test(s.name)) || sources[0];
|
const windows = sources.filter((s) => s.id.startsWith('window:'));
|
||||||
} else if (sources.length > 1) {
|
source = windows.find((s) => s.name && s.name !== ownTitle && !/stepforge/i.test(s.name))
|
||||||
source = sources.find((s) => String(s.display_id) === String(display.id)) || sources[0];
|
|| 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;
|
const image = source.thumbnail;
|
||||||
if (!image || image.isEmpty()) throw new Error('capture returned an empty image');
|
if (!image || image.isEmpty()) throw new Error('capture returned an empty image');
|
||||||
return { image, display, cursor };
|
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.
|
* 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.
|
* 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;
|
const delay = delayMs == null ? this.settings.get('capture.delayMs') || 0 : delayMs;
|
||||||
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
|
if (delay > 0) await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
let grabbed;
|
let grabbed;
|
||||||
try {
|
try {
|
||||||
grabbed = await this.grab(mode);
|
grabbed = hideWindow
|
||||||
|
? await this.withWindowHidden(() => this.grab(mode), { refocus })
|
||||||
|
: await this.grab(mode);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { ok: false, reason: err.message };
|
return { ok: false, reason: err.message };
|
||||||
}
|
}
|
||||||
@@ -145,7 +179,7 @@ class CaptureService {
|
|||||||
async regionCapture(guideId) {
|
async regionCapture(guideId) {
|
||||||
let grabbed;
|
let grabbed;
|
||||||
try {
|
try {
|
||||||
grabbed = await this.grab('fullscreen');
|
grabbed = await this.withWindowHidden(() => this.grab('fullscreen'));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return { ok: false, reason: err.message };
|
return { ok: false, reason: err.message };
|
||||||
}
|
}
|
||||||
|
|||||||
+331
-4
@@ -206,6 +206,15 @@ class GuideEditor {
|
|||||||
this.dom.forceNewPageToggle = el('label', {}, el('input', { type: 'checkbox' }), ' New page'),
|
this.dom.forceNewPageToggle = el('label', {}, el('input', { type: 'checkbox' }), ' New page'),
|
||||||
this.dom.focusedViewToggle = el('label', {}, el('input', { type: 'checkbox' }), ' Focused'),
|
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('section', {},
|
||||||
el('h3', {}, 'Description'),
|
el('h3', {}, 'Description'),
|
||||||
@@ -225,6 +234,15 @@ class GuideEditor {
|
|||||||
this.dom.annotationList = el('div', { className: 'annotation-list' }),
|
this.dom.annotationList = el('div', { className: 'annotation-list' }),
|
||||||
this.dom.annotationEditor = el('div', { className: 'annotation-editor' }),
|
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('section', {},
|
||||||
el('h3', {}, 'Guide'),
|
el('h3', {}, 'Guide'),
|
||||||
this.dom.guideSummary = el('div.muted', {}),
|
this.dom.guideSummary = el('div.muted', {}),
|
||||||
@@ -331,8 +349,23 @@ class GuideEditor {
|
|||||||
};
|
};
|
||||||
this.pendingSave = true;
|
this.pendingSave = true;
|
||||||
this.saveStepDebounced();
|
this.saveStepDebounced();
|
||||||
|
this.syncFocusedControls();
|
||||||
this.emitMeta();
|
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', () => {
|
this.dom.descEditor.addEventListener('focus', () => {
|
||||||
if (this.currentStep) this.pushCanvasHistory('description');
|
if (this.currentStep) this.pushCanvasHistory('description');
|
||||||
@@ -362,12 +395,130 @@ class GuideEditor {
|
|||||||
renderAll() {
|
renderAll() {
|
||||||
this.renderStepList();
|
this.renderStepList();
|
||||||
this.syncStepFields();
|
this.syncStepFields();
|
||||||
|
this.syncFocusedControls();
|
||||||
this.renderCanvas();
|
this.renderCanvas();
|
||||||
this.renderAnnotationPanel();
|
this.renderAnnotationPanel();
|
||||||
|
this.renderBlocksPanel();
|
||||||
this.renderGuidePanel();
|
this.renderGuidePanel();
|
||||||
this.emitMeta();
|
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() {
|
renderStepList() {
|
||||||
const current = this.currentStep;
|
const current = this.currentStep;
|
||||||
const numbers = stepNumberMap(this.steps);
|
const numbers = stepNumberMap(this.steps);
|
||||||
@@ -563,6 +714,10 @@ class GuideEditor {
|
|||||||
},
|
},
|
||||||
}, 'Delete annotation'),
|
}, '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);
|
this.dom.annotationEditor.append(annSection);
|
||||||
|
|
||||||
@@ -866,8 +1021,10 @@ class GuideEditor {
|
|||||||
this.onToast('Images imported.');
|
this.onToast('Images imported.');
|
||||||
}
|
}
|
||||||
|
|
||||||
async captureStep(mode) {
|
async captureStep(mode, delayMs = null) {
|
||||||
const result = await api.capture.shoot({ guideId: this.guideId, mode, delayMs: 0 });
|
const result = mode === 'region'
|
||||||
|
? await api.capture.region({ guideId: this.guideId })
|
||||||
|
: await api.capture.shoot({ guideId: this.guideId, mode, delayMs });
|
||||||
if (result && result.ok) {
|
if (result && result.ok) {
|
||||||
await this.reload(result.step.stepId);
|
await this.reload(result.step.stepId);
|
||||||
this.onToast('Captured.');
|
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() {
|
async startCaptureSession() {
|
||||||
await api.capture.session({ action: 'start', guideId: this.guideId });
|
await api.capture.session({ action: 'start', guideId: this.guideId });
|
||||||
this.onToast('Capture session started.');
|
this.onToast('Capture session started.');
|
||||||
@@ -1182,11 +1446,73 @@ class GuideEditor {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!isEditableTarget(e.target)) {
|
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) {
|
if (e.key === 'Delete' && this.selectedAnnotationId) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (this.canvas.deleteSelected()) this.saveStepDebounced();
|
if (this.canvas.deleteSelected()) this.saveStepDebounced();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if ((e.ctrlKey || e.metaKey) && e.key === 'Delete') {
|
||||||
|
e.preventDefault();
|
||||||
|
this.deleteSelectedStep();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.key === 'ArrowUp' && e.altKey) {
|
if (e.key === 'ArrowUp' && e.altKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
this.moveSelectedStep(-1);
|
this.moveSelectedStep(-1);
|
||||||
@@ -1198,8 +1524,9 @@ class GuideEditor {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (e.key.startsWith('Arrow')) {
|
if (e.key.startsWith('Arrow')) {
|
||||||
const dx = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0;
|
const speed = e.shiftKey ? 10 : 1; // shift nudges faster
|
||||||
const dy = e.key === 'ArrowUp' ? -1 : e.key === 'ArrowDown' ? 1 : 0;
|
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) {
|
if (dx || dy) {
|
||||||
const moved = this.canvas.nudgeSelected(dx, dy);
|
const moved = this.canvas.nudgeSelected(dx, dy);
|
||||||
if (moved) {
|
if (moved) {
|
||||||
|
|||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
# prompt2.md — Finish StepForge (handoff checklist)
|
||||||
|
|
||||||
|
You are finishing a nearly-complete offline desktop app called **StepForge**
|
||||||
|
(an Electron + vanilla-JS clone of Folge, see `prompt.md` for the full spec).
|
||||||
|
Work through the unchecked boxes below **in order**, committing after each
|
||||||
|
section. Keep every change consistent with the existing code style.
|
||||||
|
|
||||||
|
## Ground rules (do not skip)
|
||||||
|
|
||||||
|
- Run `bash tests/run_test.sh` after every section. It must stay green.
|
||||||
|
- The app must keep working: verify visually with the screenshot hook:
|
||||||
|
```bash
|
||||||
|
rm -rf /tmp/sf-x && STEPFORGE_DATA_DIR=/tmp/sf-x \
|
||||||
|
STEPFORGE_SCREENSHOT=/tmp/x.png \
|
||||||
|
STEPFORGE_SCREENSHOT_JS="<js to run in page>" timeout 30 npm start
|
||||||
|
```
|
||||||
|
Then look at /tmp/x.png. Useful JS snippets:
|
||||||
|
- welcome: (no JS needed)
|
||||||
|
- library: `window.stepforgeApp.openExistingWorkspace()`
|
||||||
|
- editor: `window.stepforgeApp.startNewCapture()`
|
||||||
|
- Renderer files (`app/renderer/*.js`) are plain scripts wrapped in IIFEs.
|
||||||
|
NEVER add top-level `const` outside the IIFE — scripts share global scope
|
||||||
|
and duplicate consts break the whole app with a SyntaxError.
|
||||||
|
- `window.stepforge` (from `app/preload.js`) is the ONLY way the renderer
|
||||||
|
talks to the system. New IPC = add handler in `app/main.js` `setupIpc()`
|
||||||
|
+ matching entry in `app/preload.js`.
|
||||||
|
- Annotations/steps/guides are saved through `this.saveStepDebounced()` /
|
||||||
|
`api.step.save` in `app/renderer/editor.js`. Mutate `this.currentStep`,
|
||||||
|
set `this.pendingSave = true`, call the debounced save.
|
||||||
|
- No network code anywhere. No new npm dependencies.
|
||||||
|
- Commit messages end with:
|
||||||
|
`Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>`
|
||||||
|
|
||||||
|
## State when this file was written
|
||||||
|
|
||||||
|
Done already (do not redo): core library (`core/`, `exporters/`, 57 unit
|
||||||
|
tests), Electron shell, library UI, three-pane editor with annotation
|
||||||
|
canvas, welcome screen (New Capture / Existing Workspace / Settings),
|
||||||
|
sample pipeline, .deb + tarball packaging, build report.
|
||||||
|
|
||||||
|
In flight (check `git status` / `git log` first — finish or fix anything
|
||||||
|
half-done): capture-service fixes and editor additions listed in sections
|
||||||
|
1–3 below may already be partially applied to `app/capture.js` and
|
||||||
|
`app/renderer/editor.js`.
|
||||||
|
|
||||||
|
## Checklist
|
||||||
|
|
||||||
|
### 1. Capture service hardening (app/capture.js) — [x] DONE
|
||||||
|
- [x] `grab('window')` requests `['window','screen']` and falls back to the
|
||||||
|
screen source when the compositor exposes no window sources (WSLg).
|
||||||
|
- [x] `withWindowHidden(fn, {refocus})` hides the app window during capture
|
||||||
|
(350 ms repaint pause), restores with `showInactive()` when
|
||||||
|
`refocus:false` (hotkey path must not steal focus).
|
||||||
|
- [x] `shoot()` accepts `hideWindow`/`refocus`; `regionCapture` hides too.
|
||||||
|
|
||||||
|
### 2. Editor: blocks, focused view, shortcuts (app/renderer/editor.js) — [x] DONE
|
||||||
|
- [x] Props panel "Blocks" section: add/edit/delete text blocks
|
||||||
|
(position/level/title/body), code blocks (language/code), table
|
||||||
|
blocks (pipe-separated rows). Buttons `+ Text block / + Code / + Table`.
|
||||||
|
- [x] Focused-view sliders (zoom 1–3, panX/panY 0–1) shown when the
|
||||||
|
Focused checkbox is on; write to `step.focusedView.*`.
|
||||||
|
- [x] `openCaptureMenu(event)` context menu: full screen / window /
|
||||||
|
region / 3 s delay / paste image / import images / start-finish session.
|
||||||
|
- [x] `pasteClipboardStep()`, `shareAsFile()` (.sfgz via `api.archive.export`),
|
||||||
|
`openBackupsDialog()`, `openGuidePlaceholders()`, `openShortcutsHelp()`,
|
||||||
|
`applyStyleAcross('step'|'guide')` methods.
|
||||||
|
- [x] "Style → step" / "Style → guide" buttons in the annotation editor.
|
||||||
|
- [x] Shortcuts in `onDocumentKeyDown` (only when target not editable):
|
||||||
|
tool keys s/r/o/l/a/t/g/n/b/h/m/u/c, PageUp/PageDown step nav,
|
||||||
|
Ctrl+=/-/0 zoom, Ctrl+C/V annotation copy/paste (V falls back to
|
||||||
|
OS-clipboard image -> new step), Ctrl+Delete delete step,
|
||||||
|
Shift+arrows = 10px nudge.
|
||||||
|
|
||||||
|
### 3. Dialogs (app/renderer/dialogs.js) — add and export via
|
||||||
|
`window.StepForgeDialogs`:
|
||||||
|
- [ ] `showBackupsDialog({snapshots, onCreate, onRestore})` — list of
|
||||||
|
snapshot names with a Restore button each, "Create snapshot" button
|
||||||
|
on top (onCreate returns the refreshed list; re-render it).
|
||||||
|
- [ ] `showPlaceholdersDialog({title, hint, values, onSave})` — key/value
|
||||||
|
rows with add/remove, same pattern as the placeholder rows already
|
||||||
|
inside `showSettingsDialog` (copy that code).
|
||||||
|
- [ ] `showShortcutsDialog()` — static table of the shortcuts from
|
||||||
|
section 2 plus Ctrl+S save, Ctrl+/ quick actions, Alt+arrows move step.
|
||||||
|
- [ ] Extend `showExportDialog`: a "Save as template…" button
|
||||||
|
(prompts a name, calls new `onSaveTemplate({format, name})`), and a
|
||||||
|
"Manage…" button listing templates with rename/duplicate/delete/
|
||||||
|
import (.sfglt)/export (use `api.templates.*`, all already exist in
|
||||||
|
preload).
|
||||||
|
|
||||||
|
### 4. Topbar rework (app/renderer/app.js, editor branch of `renderTopbar`)
|
||||||
|
- [ ] Buttons: Back | **Capture** (primary; onClick
|
||||||
|
`this.editor.openCaptureMenu(e)`) | Save | Export | Share
|
||||||
|
(`this.editor.shareAsFile()`) | More ▾ | guide title text.
|
||||||
|
- [ ] "More ▾" opens `contextMenu` with: Rename guide / Guide
|
||||||
|
placeholders… / Backups & snapshots… / Linked guide… / Keyboard
|
||||||
|
shortcuts… / Settings.
|
||||||
|
- [ ] Remove the old Rename/Local/Quick/Settings buttons from the topbar
|
||||||
|
(they move into More; Quick actions stays reachable via Ctrl+/).
|
||||||
|
|
||||||
|
### 5. Main process additions (app/main.js + app/preload.js)
|
||||||
|
- [ ] `export:preview` flow: after writing the preview, the renderer
|
||||||
|
should call a new `shell.openPath` on the produced file so PDF/GIF
|
||||||
|
previews actually open (change `onPreview` in
|
||||||
|
`editor.openExportDialog` to call `api.shell.openPath({target: preview.file})`).
|
||||||
|
- [ ] New IPC `export:defaults {format}` returning the exporter's
|
||||||
|
DEFAULT_TEMPLATE (require the exporter module, read its export) so
|
||||||
|
the export dialog can show editable options. Wire into preload as
|
||||||
|
`api.export.defaults`.
|
||||||
|
- [ ] Optional (only if simple): render checkboxes/number/text inputs in
|
||||||
|
the export dialog from the defaults object (booleans -> checkbox,
|
||||||
|
numbers -> number input, strings -> text input), pass the edited
|
||||||
|
object as `options` to export/preview/save-as-template.
|
||||||
|
|
||||||
|
### 6. CSS (app/renderer/style.css)
|
||||||
|
- [ ] Ensure `.spacer { flex: 1; }` exists (block cards use it).
|
||||||
|
- [ ] Style `.focused-controls`, `.blocks-list .block-card textarea`
|
||||||
|
(full width), keep visual language consistent (existing vars:
|
||||||
|
`--panel`, `--panel-2`, `--border`, `--accent`, `--radius`).
|
||||||
|
|
||||||
|
### 7. Verification tour + tests
|
||||||
|
- [ ] Screenshot tour: welcome, library, editor (with blocks panel
|
||||||
|
visible), capture menu open, export dialog, backups dialog. Check
|
||||||
|
each PNG looks right; fix what doesn't.
|
||||||
|
- [ ] Add a unit test `tests/unit/ipc-surface.test.js` that requires
|
||||||
|
`app/preload.js` is impossible (electron); instead statically check:
|
||||||
|
every `ipcRenderer.invoke('X')` channel string in preload.js has a
|
||||||
|
matching `h('X'` handler string in main.js (read both files with fs,
|
||||||
|
regex out the channel names, assert set equality or subset).
|
||||||
|
- [ ] `bash tests/run_test.sh` green; `bash scripts/verify.sh` green.
|
||||||
|
- [ ] Regenerate samples if exporter behavior changed
|
||||||
|
(`node scripts/make-sample-guide.js`), commit changes.
|
||||||
|
|
||||||
|
### 8. Docs + final commit
|
||||||
|
- [ ] Update CHANGELOG.md (### Added: capture menu, block editors,
|
||||||
|
focused-view controls, shortcuts, backups dialog, template
|
||||||
|
management, apply-style-across; ### Fixed: window-capture fallback,
|
||||||
|
app hides itself during capture).
|
||||||
|
- [ ] README: mention the capture button and shortcut list location.
|
||||||
|
- [ ] Update THIS file: tick every box you completed.
|
||||||
|
- [ ] Final commit.
|
||||||
|
|
||||||
|
## Testing philosophy (from prompt.md — do not violate)
|
||||||
|
|
||||||
|
Tests must exercise real workflows and assert on actual output (parse the
|
||||||
|
file that was produced, check the pixels/bytes/structure), NOT grep for
|
||||||
|
magic strings in source code. The IPC-surface test above is the one
|
||||||
|
allowed exception since it guards wiring, and even it should compare
|
||||||
|
extracted channel sets, not match arbitrary words.
|
||||||
Reference in New Issue
Block a user