diff --git a/app/main.js b/app/main.js index 6655d6e..c2bdc7f 100644 --- a/app/main.js +++ b/app/main.js @@ -76,7 +76,29 @@ function createWindow() { }, }); mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html')); - mainWindow.once('ready-to-show', () => mainWindow.show()); + mainWindow.once('ready-to-show', () => { + mainWindow.show(); + // Dev-only verification hook: optionally navigate, then write a + // screenshot locally and exit. Used by the smoke tooling. + if (process.env.STEPFORGE_SCREENSHOT) { + const target = process.env.STEPFORGE_SCREENSHOT; + const navigate = process.env.STEPFORGE_SCREENSHOT_JS || ''; + setTimeout(async () => { + try { + if (navigate) { + await mainWindow.webContents.executeJavaScript(navigate, true); + await new Promise((r) => setTimeout(r, 900)); + } + const image = await mainWindow.webContents.capturePage(); + fs.writeFileSync(target, image.toPNG()); + } catch (err) { + console.error('screenshot failed:', err.message); + } finally { + app.quit(); + } + }, 1500); + } + }); mainWindow.on('closed', () => { mainWindow = null; }); } diff --git a/app/renderer/app.js b/app/renderer/app.js index 07b56ec..9b112b6 100644 --- a/app/renderer/app.js +++ b/app/renderer/app.js @@ -1,5 +1,7 @@ 'use strict'; +(() => { + const api = window.stepforge; const dialogs = window.StepForgeDialogs || {}; @@ -12,7 +14,7 @@ class StepForgeApp { this.homeBtn = document.getElementById('btn-home'); this.state = { - view: 'library', + view: 'welcome', query: '', folderFilter: 'all', library: { guides: [], folders: [], guideFolders: {} }, @@ -24,9 +26,11 @@ class StepForgeApp { this.libraryRenderToken = 0; this.view.innerHTML = ` -
+
+ `; + this.welcomeHost = document.getElementById('welcome-host'); this.libraryHost = document.getElementById('library-host'); this.editorHost = document.getElementById('editor-host'); @@ -61,7 +65,7 @@ class StepForgeApp { }); this.homeBtn.addEventListener('click', () => { - if (this.state.view === 'editor') this.showLibrary(); + if (this.state.view !== 'welcome') this.showWelcome(); }); document.addEventListener('keydown', (e) => { @@ -85,9 +89,13 @@ class StepForgeApp { } async init() { - await this.refreshData(); - this.updateCaptureState(await api.capture.state()); - this.renderLibrary(); + this.renderWelcome(); + try { + await this.refreshData(); + this.updateCaptureState(await api.capture.state()); + } catch (err) { + console.error(err); + } } async refreshData() { @@ -123,12 +131,73 @@ class StepForgeApp { setView(view) { this.state.view = view; + this.welcomeHost.classList.toggle('hidden', view !== 'welcome'); this.libraryHost.classList.toggle('hidden', view !== 'library'); this.editorHost.classList.toggle('hidden', view !== 'editor'); this.searchInput.classList.toggle('hidden', view !== 'library'); this.renderTopbar(); } + showWelcome() { + this.editor.setActive(false); + this.setView('welcome'); + this.renderWelcome(); + } + + renderWelcome() { + this.setView('welcome'); + clearNode(this.welcomeHost); + this.welcomeHost.append( + el('div.welcome', {}, + el('div.welcome-title', {}, + el('h1', {}, 'StepForge'), + el('p.muted', {}, 'Capture, annotate, and export step-by-step guides — fully offline.'), + ), + el('div.welcome-actions', {}, + el('button.welcome-btn.primary', { + type: 'button', + onClick: () => this.startNewCapture(), + }, + el('span.welcome-btn-label', {}, 'New Capture'), + el('span.welcome-btn-hint', {}, 'Start a guide and capture your screen'), + ), + el('button.welcome-btn', { + type: 'button', + onClick: () => this.openExistingWorkspace(), + }, + el('span.welcome-btn-label', {}, 'Existing Workspace'), + el('span.welcome-btn-hint', {}, 'Browse your guide library'), + ), + el('button.welcome-btn', { + type: 'button', + onClick: () => this.openSettings(), + }, + el('span.welcome-btn-label', {}, 'Settings'), + el('span.welcome-btn-hint', {}, 'Theme, capture, and export options'), + ), + ), + ), + ); + } + + async startNewCapture() { + 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; + toast(hotkey ? `Capture session started — press ${hotkey} to grab a step.` : 'Capture session started.'); + } + + async openExistingWorkspace() { + await this.refreshData(); + this.state.query = ''; + this.searchInput.value = ''; + this.state.folderFilter = 'all'; + await this.showLibrary(); + } + async showLibrary(reason = null) { this.editor.setActive(false); this.setView('library'); @@ -182,6 +251,7 @@ class StepForgeApp { renderTopbar() { clearNode(this.topbarContext); + if (this.state.view === 'welcome') return; if (this.state.view === 'library') { this.topbarContext.append( el('button', { type: 'button', onClick: () => this.createGuide() }, 'New'), @@ -594,3 +664,4 @@ function boot() { } boot(); +})(); diff --git a/app/renderer/canvas.js b/app/renderer/canvas.js index 5c21557..c0383fc 100644 --- a/app/renderer/canvas.js +++ b/app/renderer/canvas.js @@ -1,5 +1,7 @@ 'use strict'; +(() => { + /** * AnnotationCanvas: renders a step's working image plus its normalized * annotation scene graph, and provides editing interactions (create, select, @@ -500,3 +502,4 @@ class AnnotationCanvas { } window.AnnotationCanvas = AnnotationCanvas; +})(); diff --git a/app/renderer/dialogs.js b/app/renderer/dialogs.js index 824484a..19399cc 100644 --- a/app/renderer/dialogs.js +++ b/app/renderer/dialogs.js @@ -1,5 +1,7 @@ 'use strict'; +(() => { + /** * Small modal factories used by the renderer. They stay intentionally plain: * a modal title, a few form rows, and action buttons. No decorative clutter. @@ -438,3 +440,4 @@ window.StepForgeDialogs = { showLinkedGuideDialog, showInfoDialog, }; +})(); diff --git a/app/renderer/editor.js b/app/renderer/editor.js index 756f30f..c39a327 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -1,5 +1,7 @@ 'use strict'; +(() => { + const api = window.stepforge; const dialogs = window.StepForgeDialogs || {}; @@ -317,7 +319,20 @@ class GuideEditor { bindCheckbox(this.dom.hiddenToggle.querySelector('input'), 'hidden'); bindCheckbox(this.dom.skippedToggle.querySelector('input'), 'skipped'); bindCheckbox(this.dom.forceNewPageToggle.querySelector('input'), 'forceNewPage'); - bindCheckbox(this.dom.focusedViewToggle.querySelector('input'), 'focusedViewDefault'); + + // Focused view lives under step.focusedView.enabled, not a flat field. + const focusedInput = this.dom.focusedViewToggle.querySelector('input'); + focusedInput.addEventListener('change', () => { + if (!this.currentStep) return; + this.currentStep.focusedView = { + zoom: 1.5, panX: 0.5, panY: 0.5, + ...(this.currentStep.focusedView || {}), + enabled: focusedInput.checked, + }; + this.pendingSave = true; + this.saveStepDebounced(); + this.emitMeta(); + }); this.dom.descEditor.addEventListener('focus', () => { if (this.currentStep) this.pushCanvasHistory('description'); @@ -511,17 +526,20 @@ class GuideEditor { { value: 'right', label: 'Right' }, ]); - const apply = async (patch) => { + // Light-weight apply: mutate the selected annotation, redraw, and let the + // debounced save flush. Re-rendering the panel here would rebuild the + // inputs and steal focus mid-keystroke, so only structural changes + // (type/tail) pass rerender: true. + const apply = (patch, { rerender = false } = {}) => { const ann = this.canvas.selected(); if (!ann) return; Object.assign(ann, patch); this.beforeCanvasSnapshot = null; + step.annotations = clone(this.canvas.annotations || []); this.pendingSave = true; - this.canvas.setAnnotations(step.annotations || []); - this.canvas.select(ann.id); - await this.flushStep(); - this.renderAnnotationPanel(); - this.renderStepList(); + this.canvas.render(); + this.saveStepDebounced(); + if (rerender) this.renderAnnotationPanel(); this.emitMeta(); }; @@ -548,10 +566,10 @@ class GuideEditor { ); this.dom.annotationEditor.append(annSection); - typeSelect.addEventListener('change', async () => { + typeSelect.addEventListener('change', () => { const ann = this.canvas.selected(); if (!ann) return; - await apply({ type: typeSelect.value }); + apply({ type: typeSelect.value }, { rerender: true }); if (ann.type === 'tooltip') this.editAnnotationText(ann); }); textInput.addEventListener('focus', () => this.pushCanvasHistory('annotation-text')); @@ -671,6 +689,8 @@ class GuideEditor { this.canvasFuture.push(clone(this.currentStep)); const previous = this.canvasHistory.pop(); this.stepMap.set(previous.stepId, previous); + const prevIdx = this.steps.findIndex((s) => s.stepId === previous.stepId); + if (prevIdx >= 0) this.steps[prevIdx] = previous; this.selectedStepId = previous.stepId; await this.flushStep(previous); this.renderAll(); @@ -685,6 +705,8 @@ class GuideEditor { this.canvasHistory.push(clone(this.currentStep)); const next = this.canvasFuture.pop(); this.stepMap.set(next.stepId, next); + const nextIdx = this.steps.findIndex((s) => s.stepId === next.stepId); + if (nextIdx >= 0) this.steps[nextIdx] = next; this.selectedStepId = next.stepId; await this.flushStep(next); this.renderAll(); @@ -695,12 +717,18 @@ class GuideEditor { this.pendingSave = false; const saved = await api.step.save({ guideId: this.guideId, step }); this.stepMap.set(saved.stepId, saved); + // Keep the steps array in sync — it holds the objects the list renders. + const idx = this.steps.findIndex((s) => s.stepId === saved.stepId); + if (idx >= 0) this.steps[idx] = saved; if (this.selectedStepId === saved.stepId) { - this.stepMap.set(saved.stepId, saved); this.renderStepList(); this.syncStepFields(); this.canvas.setAnnotations(saved.annotations || []); - this.renderAnnotationPanel(); + // Rebuilding the annotation editor while the user is typing in one of + // its inputs would steal focus, so skip it in that case. + if (!this.dom.annotationEditor.contains(document.activeElement)) { + this.renderAnnotationPanel(); + } this.emitMeta(); } return saved; @@ -1140,9 +1168,10 @@ class GuideEditor { return; } if (e.key === 'Escape' && !isEditableTarget(e.target)) { - if (this.selectedAnnotationId && this.canvas.deleteSelected()) { + // Escape deselects; Delete is the destructive key. + if (this.selectedAnnotationId) { e.preventDefault(); - this.saveStepDebounced(); + this.canvas.select(null); return; } } @@ -1206,3 +1235,4 @@ function loadImage(src) { } window.GuideEditor = GuideEditor; +})(); diff --git a/app/renderer/style.css b/app/renderer/style.css index 7ffddca..4db7a8e 100644 --- a/app/renderer/style.css +++ b/app/renderer/style.css @@ -671,3 +671,52 @@ fieldset legend { border-top: 1px solid var(--border); } + +/* ---------- welcome screen ---------- */ +#welcome-host { flex: 1; min-height: 0; display: flex; } +.welcome { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + padding: 9vh 32px 7vh; +} +.welcome-title { text-align: center; } +.welcome-title h1 { + margin: 0 0 10px; + font-size: 44px; + font-weight: 700; + letter-spacing: -0.015em; +} +.welcome-title p { font-size: 14px; margin: 0; } +.welcome-actions { + margin-top: auto; + display: flex; + gap: 18px; + width: min(880px, 100%); +} +.welcome-btn { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + gap: 6px; + padding: 26px 18px; + border-radius: var(--radius); + border: 1px solid var(--border); + background: var(--panel); + box-shadow: var(--shadow); + cursor: pointer; +} +.welcome-btn:hover { + border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); + transform: translateY(-2px); +} +.welcome-btn.primary { + background: var(--accent); + border-color: var(--accent); +} +.welcome-btn.primary .welcome-btn-label, +.welcome-btn.primary .welcome-btn-hint { color: var(--accent-fg); } +.welcome-btn-label { font-size: 16px; font-weight: 650; } +.welcome-btn-hint { font-size: 12px; color: var(--muted); }