diff --git a/.tmp-capture-test.js b/.tmp-capture-test.js new file mode 100644 index 0000000..3c89f31 --- /dev/null +++ b/.tmp-capture-test.js @@ -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(); +}); diff --git a/app/capture.js b/app/capture.js index e211ae9..dcb9764 100644 --- a/app/capture.js +++ b/app/capture.js @@ -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 }; } diff --git a/app/renderer/editor.js b/app/renderer/editor.js index c39a327..4bf72c5 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -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 = `
${escapeHtml(body.value)}
`; 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) { diff --git a/prompt2.md b/prompt2.md new file mode 100644 index 0000000..23abcfa --- /dev/null +++ b/prompt2.md @@ -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="