diff --git a/app/capture.js b/app/capture.js new file mode 100644 index 0000000..e211ae9 --- /dev/null +++ b/app/capture.js @@ -0,0 +1,211 @@ +'use strict'; + +const path = require('node:path'); +const { desktopCapturer, screen, BrowserWindow, nativeImage } = require('electron'); +const { expandPlaceholders } = require('../core/placeholders'); + +/** + * Capture service: full-screen, active-window, and region capture via + * Electron's desktopCapturer, plus a click-marker annotation at the cursor + * position and a capture session (start/pause/resume/finish) driven by the + * global hotkey. + * + * Note: under Wayland/WSLg, screen capture may require portal support; all + * failures surface as { ok: false, reason } instead of crashing. + */ + +class CaptureService { + constructor({ store, settings, getWindow, notify }) { + this.store = store; + this.settings = settings; + this.getWindow = getWindow; + this.notify = notify; + this.session = null; // { guideId, paused, count } + } + + state() { + return this.session + ? { active: true, paused: this.session.paused, guideId: this.session.guideId, count: this.session.count } + : { active: false }; + } + + startSession(guideId) { + this.session = { guideId, paused: false, count: 0 }; + } + + togglePause(force) { + if (!this.session) return; + this.session.paused = typeof force === 'boolean' ? force : !this.session.paused; + } + + finishSession() { + this.session = null; + } + + async hotkeyCapture() { + if (!this.session || this.session.paused) return { ok: false, reason: 'no active capture session' }; + const mode = this.settings.get('capture.mode') || 'fullscreen'; + const result = await this.shoot({ + guideId: this.session.guideId, + mode: mode === 'region' ? 'fullscreen' : mode, + delayMs: 0, + }); + if (result.ok) { + this.session.count += 1; + this.notify('capture:added', { guideId: this.session.guideId, step: result.step }); + } + return result; + } + + autoTitle(mode) { + const tplStr = this.settings.get('editor.autoTitleTemplate') || '[[Mode]] capture [[Time]]'; + const now = new Date(); + const pad = (n) => String(n).padStart(2, '0'); + return expandPlaceholders(tplStr, { + Mode: { fullscreen: 'Screen', window: 'Window', region: 'Region' }[mode] || 'Screen', + Time: `${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`, + Date: `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}`, + }); + } + + /** Grab the screen/window image as { image, display } or throw. */ + async grab(mode) { + const cursor = screen.getCursorScreenPoint(); + const display = screen.getDisplayNearestPoint(cursor); + const { width, height } = display.size; + const scale = display.scaleFactor || 1; + const types = mode === 'window' ? ['window'] : ['screen']; + const sources = await desktopCapturer.getSources({ + types, + 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]; + 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 image = source.thumbnail; + if (!image || image.isEmpty()) throw new Error('capture returned an empty image'); + return { image, display, cursor }; + } + + /** + * 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 }) { + 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); + } catch (err) { + return { ok: false, reason: err.message }; + } + const { image, display, cursor } = grabbed; + const size = image.getSize(); + const annotations = []; + if (mode !== 'window' && this.settings.get('capture.clickMarker')) { + const fx = (cursor.x - display.bounds.x) / display.bounds.width; + const fy = (cursor.y - display.bounds.y) / display.bounds.height; + if (fx >= 0 && fx <= 1 && fy >= 0 && fy <= 1) { + const d = 0.035; + annotations.push({ + type: 'oval', + x: fx - d / 2, y: fy - (d * size.width / size.height) / 2, + w: d, h: d * size.width / size.height, + style: { + stroke: this.settings.get('capture.clickMarkerColor') || '#E5484D', + strokeWidth: 4, fill: 'transparent', + }, + }); + } + } + + const step = this.store.addStep(guideId, { + title: this.autoTitle(mode), + annotations, + focusedView: { + enabled: Boolean(this.settings.get('editor.focusedViewDefaultForNewSteps')), + zoom: 1, panX: 0.5, panY: 0.5, + }, + }, image.toPNG(), size); + return { ok: true, step }; + } + + /** + * Region capture: shoot the full screen, then let the user drag a + * rectangle in a fullscreen overlay; the crop becomes the step image. + */ + async regionCapture(guideId) { + let grabbed; + try { + grabbed = await this.grab('fullscreen'); + } catch (err) { + return { ok: false, reason: err.message }; + } + const { image, display } = grabbed; + const rect = await this.pickRegion(display, image); + if (!rect) return { ok: false, reason: 'selection cancelled' }; + + const cropped = image.crop(rect); + const size = cropped.getSize(); + if (!size.width || !size.height) return { ok: false, reason: 'empty selection' }; + const step = this.store.addStep(guideId, { title: this.autoTitle('region') }, + cropped.toPNG(), size); + return { ok: true, step }; + } + + /** Fullscreen overlay window that resolves with a crop rect (image px). */ + pickRegion(display, image) { + return new Promise((resolve) => { + const overlay = new BrowserWindow({ + x: display.bounds.x, + y: display.bounds.y, + width: display.bounds.width, + height: display.bounds.height, + frame: false, + transparent: true, + alwaysOnTop: true, + fullscreen: true, + skipTaskbar: true, + webPreferences: { + preload: path.join(__dirname, 'region-preload.js'), + contextIsolation: true, + }, + }); + let settled = false; + const finish = (rect) => { + if (settled) return; + settled = true; + if (!overlay.isDestroyed()) overlay.close(); + resolve(rect); + }; + const { ipcMain } = require('electron'); + const onPick = (event, rect) => { + if (event.sender !== overlay.webContents) return; + ipcMain.removeListener('region:picked', onPick); + if (!rect) return finish(null); + const imgSize = image.getSize(); + const sx = imgSize.width / display.bounds.width; + const sy = imgSize.height / display.bounds.height; + finish({ + x: Math.round(rect.x * sx), + y: Math.round(rect.y * sy), + width: Math.round(rect.w * sx), + height: Math.round(rect.h * sy), + }); + }; + ipcMain.on('region:picked', onPick); + overlay.on('closed', () => finish(null)); + overlay.loadFile(path.join(__dirname, 'renderer', 'region.html')); + }); + } +} + +module.exports = CaptureService; diff --git a/app/main.js b/app/main.js new file mode 100644 index 0000000..6655d6e --- /dev/null +++ b/app/main.js @@ -0,0 +1,430 @@ +'use strict'; + +const path = require('node:path'); +const fs = require('node:fs'); +const os = require('node:os'); +const { + app, BrowserWindow, ipcMain, dialog, shell, nativeTheme, globalShortcut, + clipboard, nativeImage, screen, +} = require('electron'); + +const { GuideStore } = require('../core/store'); +const { Settings } = require('../core/settings'); +const { SearchIndex } = require('../core/search'); +const { TemplateManager, FORMATS } = require('../core/templates'); +const { buildRenderAst } = require('../core/renderast'); +const { runExport, EXPORTERS } = require('../exporters'); +const { exportGuideArchive, importGuideArchive, saveLinkedGuide, readArchive } = require('../core/archive'); +const { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots'); +const { readLock } = require('../core/locks'); +const CaptureService = require('./capture'); + +/** + * StepForge main process. Zero network code: no telemetry, no updates, no + * remote anything. The renderer is sandboxed; everything below is the full + * privileged surface. + */ + +function resolveDataDir() { + if (process.env.STEPFORGE_DATA_DIR) return process.env.STEPFORGE_DATA_DIR; + if (process.platform === 'win32') { + return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'stepforge'); + } + const xdg = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local', 'share'); + return path.join(xdg, 'stepforge'); +} + +let store; +let settings; +let searchIndex; +let templates; +let capture; +let mainWindow; + +function reindex(guideId) { + try { + searchIndex.indexGuide(store.getGuide(guideId), store.listSteps(guideId)); + } catch { + // index failures must never block saves + } +} + +function orderedSteps(guideId) { + const guide = store.getGuide(guideId); + const steps = store.listSteps(guideId); + return guide.stepsOrder.map((id) => steps.get(id)).filter(Boolean); +} + +function applyTheme() { + nativeTheme.themeSource = settings.get('appearance') || 'system'; +} + +function createWindow() { + mainWindow = new BrowserWindow({ + width: 1280, + height: 820, + minWidth: 880, + minHeight: 560, + show: false, + autoHideMenuBar: true, + backgroundColor: nativeTheme.shouldUseDarkColors ? '#111827' : '#ffffff', + webPreferences: { + preload: path.join(__dirname, 'preload.js'), + contextIsolation: true, + nodeIntegration: false, + spellcheck: Boolean(settings.get('spellcheck')), + }, + }); + mainWindow.loadFile(path.join(__dirname, 'renderer', 'index.html')); + mainWindow.once('ready-to-show', () => mainWindow.show()); + mainWindow.on('closed', () => { mainWindow = null; }); +} + +function registerHotkeys() { + globalShortcut.unregisterAll(); + const accel = settings.get('capture.hotkeyCapture'); + const pauseAccel = settings.get('capture.hotkeyPauseResume'); + try { + if (accel) { + globalShortcut.register(accel, () => { + capture.hotkeyCapture().catch(() => {}); + }); + } + if (pauseAccel) { + globalShortcut.register(pauseAccel, () => { + capture.togglePause(); + sendToRenderer('capture:state', capture.state()); + }); + } + } catch { + // invalid accelerator strings must not crash the app + } +} + +function sendToRenderer(channel, payload) { + if (mainWindow && !mainWindow.isDestroyed()) mainWindow.webContents.send(channel, payload); +} + +// ---- IPC ------------------------------------------------------------------ + +function setupIpc() { + const h = (channel, fn) => ipcMain.handle(channel, async (event, args = {}) => fn(args)); + + // library + h('library:list', () => ({ + guides: store.listGuides().map((g) => ({ + ...g, + stepCount: g.stepsOrder.length, + locked: g.linkedSource ? Boolean(readLock(g.linkedSource.path)) : false, + })), + folders: store.loadFolders(), + })); + h('library:create', ({ title }) => { + const guide = store.createGuide({ + title: title || 'Untitled guide', + flags: { focusedViewDefault: Boolean(settings.get('editor.focusedViewDefaultForNewSteps')) }, + }); + reindex(guide.guideId); + return guide; + }); + h('library:duplicate', ({ guideId }) => { + const copy = store.duplicateGuide(guideId); + reindex(copy.guideId); + return copy; + }); + h('library:delete', ({ guideId }) => { + store.deleteGuide(guideId); + searchIndex.removeGuide(guideId); + return true; + }); + h('library:setFavorite', ({ guideId, favorite }) => store.setFavorite(guideId, favorite)); + h('library:trash:list', () => store.listTrash()); + h('library:trash:restore', ({ name }) => { + const id = store.restoreFromTrash(name); + reindex(id); + return id; + }); + h('library:trash:purge', () => store.purgeTrash()); + h('folders:create', ({ name, parentId }) => store.createFolder(name, parentId || null)); + h('folders:rename', ({ folderId, name }) => store.renameFolder(folderId, name)); + h('folders:delete', ({ folderId }) => store.deleteFolder(folderId)); + h('folders:moveGuide', ({ guideId, folderId }) => store.moveGuideToFolder(guideId, folderId || null)); + + // guide + steps + h('guide:get', ({ guideId }) => ({ + guide: store.getGuide(guideId), + steps: orderedSteps(guideId), + })); + h('guide:save', ({ guide }) => { + const saved = store.saveGuide(guide); + reindex(guide.guideId); + return saved; + }); + h('step:add', ({ guideId, fields, imageBase64, size, position }) => { + const buf = imageBase64 ? Buffer.from(imageBase64, 'base64') : null; + const step = store.addStep(guideId, fields || {}, buf, size || null, { position }); + reindex(guideId); + return step; + }); + h('step:save', ({ guideId, step }) => { + const saved = store.saveStep(guideId, step); + reindex(guideId); + return saved; + }); + h('step:delete', ({ guideId, stepId }) => { + store.deleteStep(guideId, stepId); + reindex(guideId); + return true; + }); + h('steps:reorder', ({ guideId, order }) => store.reorderSteps(guideId, order)); + h('step:imagePath', ({ guideId, stepId, which }) => { + const p = store.stepImagePath(guideId, stepId, which || 'working'); + return p && fs.existsSync(p) ? `file://${p}?v=${fs.statSync(p).mtimeMs}` : null; + }); + h('step:setWorkingImage', ({ guideId, stepId, pngBase64, size }) => + store.setWorkingImage(guideId, stepId, Buffer.from(pngBase64, 'base64'), size)); + h('step:resetWorkingImage', ({ guideId, stepId }) => { + const p = store.stepImagePath(guideId, stepId, 'original'); + const img = nativeImage.createFromPath(p); + const { width, height } = img.getSize(); + return store.resetWorkingImage(guideId, stepId, { width, height }); + }); + h('step:fromClipboard', ({ guideId, position }) => { + const img = clipboard.readImage(); + if (img.isEmpty()) return { ok: false, reason: 'clipboard has no image' }; + const { width, height } = img.getSize(); + const step = store.addStep(guideId, { + title: 'Pasted image', + focusedView: { enabled: false, zoom: 1, panX: 0.5, panY: 0.5 }, + }, img.toPNG(), { width, height }, { position }); + reindex(guideId); + return { ok: true, step }; + }); + h('step:importImage', async ({ guideId }) => { + const res = await dialog.showOpenDialog(mainWindow, { + title: 'Import images as steps', + filters: [{ name: 'Images', extensions: ['png', 'jpg', 'jpeg', 'gif', 'webp'] }], + properties: ['openFile', 'multiSelections'], + }); + if (res.canceled) return { ok: false }; + const steps = []; + for (const file of res.filePaths) { + const img = nativeImage.createFromPath(file); + if (img.isEmpty()) continue; + const { width, height } = img.getSize(); + steps.push(store.addStep(guideId, { title: path.basename(file, path.extname(file)) }, + img.toPNG(), { width, height })); + } + reindex(guideId); + return { ok: true, steps }; + }); + + // search + h('search:query', ({ q, guideId }) => searchIndex.search(q, { guideId: guideId || null })); + h('search:titles', ({ q }) => searchIndex.searchTitles(q)); + + // settings + placeholders + h('settings:all', () => settings.data); + h('settings:set', ({ keyPath, value }) => { + settings.set(keyPath, value); + if (keyPath === 'appearance') applyTheme(); + if (keyPath.startsWith('capture.hotkey')) registerHotkeys(); + return settings.data; + }); + h('placeholders:globals:get', () => settings.getGlobalPlaceholders()); + h('placeholders:globals:set', ({ values }) => settings.setGlobalPlaceholders(values)); + + // capture + h('capture:shoot', async ({ guideId, mode, delayMs }) => { + const result = await capture.shoot({ guideId, mode, delayMs }); + if (result.ok) reindex(guideId); + return result; + }); + h('capture:region', async ({ guideId }) => { + const result = await capture.regionCapture(guideId); + if (result.ok) reindex(guideId); + return result; + }); + h('capture:session', ({ action, guideId }) => { + if (action === 'start') capture.startSession(guideId); + else if (action === 'pause') capture.togglePause(true); + else if (action === 'resume') capture.togglePause(false); + else if (action === 'finish') capture.finishSession(); + const state = capture.state(); + sendToRenderer('capture:state', state); + return state; + }); + h('capture:state', () => capture.state()); + + // archives & linked guides + h('archive:export', async ({ guideId }) => { + const guide = store.getGuide(guideId); + const res = await dialog.showSaveDialog(mainWindow, { + title: 'Share guide as file', + defaultPath: `${guide.title.replace(/[/\\:]+/g, '-')}.sfgz`, + filters: [{ name: 'StepForge guide archive', extensions: ['sfgz'] }], + }); + if (res.canceled) return { ok: false }; + exportGuideArchive(store, guideId, res.filePath); + return { ok: true, path: res.filePath }; + }); + h('archive:open', async ({ mode }) => { + const res = await dialog.showOpenDialog(mainWindow, { + title: 'Open guide archive', + filters: [{ name: 'StepForge guide archive', extensions: ['sfgz'] }], + properties: ['openFile'], + }); + if (res.canceled) return { ok: false }; + try { + const guide = importGuideArchive(store, res.filePaths[0], { mode: mode || 'copy' }); + reindex(guide.guideId); + return { ok: true, guide }; + } catch (err) { + return { ok: false, reason: err.message }; + } + }); + h('archive:peek', ({ file }) => { + const { manifest } = readArchive(file); + return manifest; + }); + h('archive:saveLinked', ({ guideId, force }) => saveLinkedGuide(store, guideId, { force: Boolean(force) })); + + // snapshots + h('snapshots:list', ({ guideId }) => listSnapshots(store, guideId)); + h('snapshots:create', ({ guideId, label }) => + createSnapshot(store, guideId, { label: label || 'manual', keepLast: settings.get('backups.keepLast') })); + h('snapshots:restore', ({ guideId, name }) => { + const guide = restoreSnapshot(store, guideId, name); + reindex(guideId); + return guide; + }); + + // templates + h('templates:list', ({ format }) => templates.list(format)); + h('templates:load', ({ format, name }) => templates.load(format, name)); + h('templates:save', ({ format, name, options }) => templates.save(format, name, options)); + h('templates:delete', ({ format, name }) => templates.remove(format, name)); + h('templates:rename', ({ format, name, newName }) => templates.rename(format, name, newName)); + h('templates:duplicate', ({ format, name }) => templates.duplicate(format, name)); + h('templates:export', async ({ format, name }) => { + const res = await dialog.showSaveDialog(mainWindow, { + defaultPath: `${name}.sfglt`, + filters: [{ name: 'StepForge template', extensions: ['sfglt'] }], + }); + if (res.canceled) return { ok: false }; + templates.exportTemplate(format, name, res.filePath); + return { ok: true }; + }); + h('templates:import', async () => { + const res = await dialog.showOpenDialog(mainWindow, { + filters: [{ name: 'StepForge template', extensions: ['sfglt'] }], + properties: ['openFile'], + }); + if (res.canceled) return { ok: false }; + return { ok: true, ...templates.importTemplate(res.filePaths[0]) }; + }); + + // export + preview + h('export:formats', () => FORMATS.filter((f) => EXPORTERS[f])); + h('export:run', async ({ guideId, format, options, outDir }) => { + let dir = outDir || settings.get(`exports.lastOutputDirs.${format}`); + if (!dir) { + const res = await dialog.showOpenDialog(mainWindow, { + title: 'Choose output folder', properties: ['openDirectory', 'createDirectory'], + }); + if (res.canceled) return { ok: false }; + dir = res.filePaths[0]; + } + settings.set(`exports.lastOutputDirs.${format}`, dir); + const ast = buildRenderAst(store, guideId, { globals: settings.getGlobalPlaceholders() }); + const result = runExport(format, ast, dir, options || {}); + if (settings.get('exports.openFolderAfterExport')) shell.showItemInFolder(result.file); + return { ok: true, ...result }; + }); + h('export:chooseDir', async ({ format }) => { + const res = await dialog.showOpenDialog(mainWindow, { + title: 'Choose output folder', properties: ['openDirectory', 'createDirectory'], + }); + if (res.canceled) return null; + settings.set(`exports.lastOutputDirs.${format}`, res.filePaths[0]); + return res.filePaths[0]; + }); + h('export:preview', ({ guideId, format, options }) => { + const previewDir = path.join(store.tempDir, `preview-${guideId}-${format}`); + fs.rmSync(previewDir, { recursive: true, force: true }); + const ast = buildRenderAst(store, guideId, { + globals: settings.getGlobalPlaceholders(), + maxSteps: settings.get('exports.previewStepCount') || 3, + }); + const result = runExport(format, ast, previewDir, options || {}); + return { ok: true, file: result.file, fileUrl: `file://${result.file}` }; + }); + h('preview:cleanup', () => { + for (const entry of fs.readdirSync(store.tempDir)) { + if (entry.startsWith('preview-')) { + fs.rmSync(path.join(store.tempDir, entry), { recursive: true, force: true }); + } + } + return true; + }); + + // shell helpers + h('shell:openPath', ({ target }) => shell.openPath(target)); + h('shell:showItemInFolder', ({ target }) => shell.showItemInFolder(target)); + h('app:info', () => ({ + version: app.getVersion(), + dataDir: store.root, + platform: process.platform, + })); +} + +// ---- lifecycle -------------------------------------------------------------- + +const gotLock = app.requestSingleInstanceLock(); +if (!gotLock) { + app.quit(); +} else { + app.on('second-instance', () => { + if (mainWindow) { + if (mainWindow.isMinimized()) mainWindow.restore(); + mainWindow.focus(); + } + }); + + app.whenReady().then(() => { + const dataDir = resolveDataDir(); + store = new GuideStore(dataDir); + settings = new Settings(store.settingsDir); + searchIndex = new SearchIndex(store.indexDir); + templates = new TemplateManager(store.templatesDir); + capture = new CaptureService({ + store, + settings, + getWindow: () => mainWindow, + notify: sendToRenderer, + }); + + applyTheme(); + setupIpc(); + createWindow(); + registerHotkeys(); + + app.on('activate', () => { + if (BrowserWindow.getAllWindows().length === 0) createWindow(); + }); + }); + + app.on('will-quit', () => { + globalShortcut.unregisterAll(); + // clean preview temp files on close + try { + for (const entry of fs.readdirSync(store.tempDir)) { + fs.rmSync(path.join(store.tempDir, entry), { recursive: true, force: true }); + } + } catch { /* best effort */ } + }); + + app.on('window-all-closed', () => { + if (process.platform !== 'darwin') app.quit(); + }); +} diff --git a/app/preload.js b/app/preload.js new file mode 100644 index 0000000..47d179c --- /dev/null +++ b/app/preload.js @@ -0,0 +1,98 @@ +'use strict'; + +const { contextBridge, ipcRenderer } = require('electron'); + +/** + * The complete privileged API exposed to the sandboxed renderer. Every call + * is an explicit invoke; no raw ipcRenderer or Node access leaks through. + */ + +const invoke = (channel) => (args) => ipcRenderer.invoke(channel, args); + +const api = { + library: { + list: invoke('library:list'), + create: invoke('library:create'), + duplicate: invoke('library:duplicate'), + delete: invoke('library:delete'), + setFavorite: invoke('library:setFavorite'), + trashList: invoke('library:trash:list'), + trashRestore: invoke('library:trash:restore'), + trashPurge: invoke('library:trash:purge'), + }, + folders: { + create: invoke('folders:create'), + rename: invoke('folders:rename'), + delete: invoke('folders:delete'), + moveGuide: invoke('folders:moveGuide'), + }, + guide: { + get: invoke('guide:get'), + save: invoke('guide:save'), + }, + step: { + add: invoke('step:add'), + save: invoke('step:save'), + delete: invoke('step:delete'), + reorder: invoke('steps:reorder'), + imagePath: invoke('step:imagePath'), + setWorkingImage: invoke('step:setWorkingImage'), + resetWorkingImage: invoke('step:resetWorkingImage'), + fromClipboard: invoke('step:fromClipboard'), + importImage: invoke('step:importImage'), + }, + search: { + query: invoke('search:query'), + titles: invoke('search:titles'), + }, + settings: { + all: invoke('settings:all'), + set: invoke('settings:set'), + globalPlaceholders: invoke('placeholders:globals:get'), + setGlobalPlaceholders: invoke('placeholders:globals:set'), + }, + capture: { + shoot: invoke('capture:shoot'), + region: invoke('capture:region'), + session: invoke('capture:session'), + state: invoke('capture:state'), + onAdded: (fn) => ipcRenderer.on('capture:added', (e, payload) => fn(payload)), + onState: (fn) => ipcRenderer.on('capture:state', (e, payload) => fn(payload)), + }, + archive: { + export: invoke('archive:export'), + open: invoke('archive:open'), + saveLinked: invoke('archive:saveLinked'), + }, + snapshots: { + list: invoke('snapshots:list'), + create: invoke('snapshots:create'), + restore: invoke('snapshots:restore'), + }, + templates: { + list: invoke('templates:list'), + load: invoke('templates:load'), + save: invoke('templates:save'), + delete: invoke('templates:delete'), + rename: invoke('templates:rename'), + duplicate: invoke('templates:duplicate'), + export: invoke('templates:export'), + import: invoke('templates:import'), + }, + export: { + formats: invoke('export:formats'), + run: invoke('export:run'), + chooseDir: invoke('export:chooseDir'), + preview: invoke('export:preview'), + cleanupPreviews: invoke('preview:cleanup'), + }, + shell: { + openPath: invoke('shell:openPath'), + showItemInFolder: invoke('shell:showItemInFolder'), + }, + app: { + info: invoke('app:info'), + }, +}; + +contextBridge.exposeInMainWorld('stepforge', api); diff --git a/app/region-preload.js b/app/region-preload.js new file mode 100644 index 0000000..df08886 --- /dev/null +++ b/app/region-preload.js @@ -0,0 +1,7 @@ +'use strict'; + +const { contextBridge, ipcRenderer } = require('electron'); + +contextBridge.exposeInMainWorld('regionPicker', { + done: (rect) => ipcRenderer.send('region:picked', rect), +}); diff --git a/app/renderer/app.js b/app/renderer/app.js new file mode 100644 index 0000000..07b56ec --- /dev/null +++ b/app/renderer/app.js @@ -0,0 +1,596 @@ +'use strict'; + +const api = window.stepforge; +const dialogs = window.StepForgeDialogs || {}; + +class StepForgeApp { + constructor() { + this.view = document.getElementById('view'); + this.topbarContext = document.getElementById('topbar-context'); + this.searchInput = document.getElementById('global-search'); + this.captureStatus = document.getElementById('capture-status'); + this.homeBtn = document.getElementById('btn-home'); + + this.state = { + view: 'library', + query: '', + folderFilter: 'all', + library: { guides: [], folders: [], guideFolders: {} }, + trash: [], + settings: null, + info: null, + }; + this.editorMeta = null; + this.libraryRenderToken = 0; + + this.view.innerHTML = ` +
+ + `; + this.libraryHost = document.getElementById('library-host'); + this.editorHost = document.getElementById('editor-host'); + + this.editor = new GuideEditor({ + root: this.editorHost, + onMetaChange: (meta) => this.onEditorMeta(meta), + onToast: (msg, opts) => toast(msg, opts), + onBack: async (reason) => { + if (reason === 'new') { + await this.createGuide(); + return; + } + await this.showLibrary(); + }, + }); + + this.searchInput.addEventListener('input', debounce(() => { + this.state.query = this.searchInput.value.trim(); + if (this.state.view === 'library') this.renderLibrary(); + }, 80)); + + this.searchInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') { + e.preventDefault(); + if (this.state.view === 'library') this.openQuickActions(); + } + if (e.key === 'Escape') { + this.searchInput.value = ''; + this.state.query = ''; + if (this.state.view === 'library') this.renderLibrary(); + } + }); + + this.homeBtn.addEventListener('click', () => { + if (this.state.view === 'editor') this.showLibrary(); + }); + + document.addEventListener('keydown', (e) => { + if ((e.ctrlKey || e.metaKey) && e.key === '/' && !e.shiftKey) { + e.preventDefault(); + this.openQuickActions(); + } + }); + + api.capture.onAdded((payload) => this.onCaptureAdded(payload)); + api.capture.onState((payload) => this.updateCaptureState(payload)); + } + + async onCaptureAdded(payload) { + if (!payload || !payload.guideId) return; + if (this.state.view === 'editor' && this.editor.guideId === payload.guideId) { + await this.editor.reload(payload.step && payload.step.stepId ? payload.step.stepId : this.editor.selectedStepId); + return; + } + await this.refreshLibrary(); + } + + async init() { + await this.refreshData(); + this.updateCaptureState(await api.capture.state()); + this.renderLibrary(); + } + + async refreshData() { + const [info, settings, library, trash] = await Promise.all([ + api.app.info(), + api.settings.all(), + api.library.list(), + api.library.trashList(), + ]); + this.state.info = info; + this.state.settings = settings; + this.state.library = { + guides: library.guides || [], + folders: library.folders?.folders || [], + guideFolders: library.folders?.guideFolders || {}, + }; + this.state.trash = trash; + } + + async refreshLibrary({ keepFilter = true } = {}) { + const folderFilter = keepFilter ? this.state.folderFilter : 'all'; + await this.refreshData(); + if (!this.folderExists(folderFilter) && !['all', 'favorites', 'trash'].includes(folderFilter)) { + this.state.folderFilter = 'all'; + } + if (this.state.view === 'library') this.renderLibrary(); + else this.renderTopbar(); + } + + folderExists(folderId) { + return (this.state.library.folders || []).some((f) => f.id === folderId); + } + + setView(view) { + this.state.view = view; + this.libraryHost.classList.toggle('hidden', view !== 'library'); + this.editorHost.classList.toggle('hidden', view !== 'editor'); + this.searchInput.classList.toggle('hidden', view !== 'library'); + this.renderTopbar(); + } + + async showLibrary(reason = null) { + this.editor.setActive(false); + this.setView('library'); + if (reason === 'new') { + await this.createGuide(); + return; + } + this.renderLibrary(); + } + + async openGuide(guideId, stepId = null) { + this.setView('editor'); + this.editor.setActive(true); + await this.editor.open(guideId, stepId); + this.renderTopbar(); + } + + onEditorMeta(meta) { + this.editorMeta = meta; + if (this.state.view === 'editor') this.renderTopbar(); + this.updateCaptureState(this.captureState || null); + } + + updateCaptureState(state) { + this.captureState = state || { active: false }; + clearNode(this.captureStatus); + if (!this.captureState.active) { + this.captureStatus.classList.add('hidden'); + return; + } + this.captureStatus.classList.remove('hidden'); + const pauseBtn = el('button', { + type: 'button', + onClick: () => { + const action = this.captureState.paused ? 'resume' : 'pause'; + api.capture.session({ action, guideId: this.editorMeta?.guide?.id || this.editorMeta?.guide?.guideId || null }) + .then((next) => this.updateCaptureState(next)); + }, + }, this.captureState.paused ? 'Resume' : 'Pause'); + const finishBtn = el('button', { + type: 'button', + onClick: () => api.capture.session({ action: 'finish', guideId: this.editorMeta?.guide?.id || this.editorMeta?.guide?.guideId || null }) + .then((next) => this.updateCaptureState(next)), + }, 'Finish'); + this.captureStatus.append( + el('span', {}, `Capture ${this.captureState.count || 0}`), + pauseBtn, + finishBtn, + ); + } + + renderTopbar() { + clearNode(this.topbarContext); + if (this.state.view === 'library') { + this.topbarContext.append( + el('button', { type: 'button', onClick: () => this.createGuide() }, 'New'), + el('button', { type: 'button', onClick: () => this.importArchive('copy') }, 'Import'), + el('button', { type: 'button', onClick: () => this.importArchive('linked') }, 'Linked'), + el('button', { type: 'button', onClick: () => this.openSettings() }, 'Settings'), + ); + return; + } + + const guide = this.editorMeta?.guide; + this.topbarContext.append( + el('button', { type: 'button', onClick: () => this.showLibrary() }, 'Back'), + el('button', { type: 'button', onClick: () => this.renameGuide() }, 'Rename'), + el('button', { type: 'button', onClick: () => this.editor.saveAll() }, 'Save'), + el('button', { type: 'button', onClick: () => this.editor.openExportDialog() }, 'Export'), + el('button', { type: 'button', onClick: () => this.editor.openLinkedGuide() }, guide && guide.linkedSource ? 'Linked' : 'Local'), + el('button', { type: 'button', onClick: () => this.editor.openQuickActions() }, 'Quick'), + el('button', { type: 'button', onClick: () => this.openSettings() }, 'Settings'), + el('span.muted', { style: { marginLeft: '8px' } }, guide ? `${guide.title} · ${this.editorMeta?.stepCount || 0} steps` : ''), + ); + } + + async renderLibrary() { + this.setView('library'); + this.editor.setActive(false); + clearNode(this.libraryHost); + const q = this.state.query.trim(); + const folderLabel = this.filterLabel(); + const body = el('div.library', {}, + el('aside.lib-side', {}, + el('h3', {}, 'Library'), + this.libraryNavItem('all', 'All guides', this.state.library.guides.length), + this.libraryNavItem('favorites', 'Favorites', this.state.library.guides.filter((g) => g.favorite).length), + this.libraryNavItem('trash', 'Trash', this.state.trash.length), + el('h3', {}, 'Folders'), + ...this.renderFolderItems(this.state.library.folders || [], null, 0), + el('div', { style: { marginTop: '8px' } }, + el('button', { type: 'button', onClick: () => this.createFolder() }, 'Add folder'), + ), + ), + el('main.lib-main', {}, + el('div.lib-actions', {}, + el('button.primary', { type: 'button', onClick: () => this.createGuide() }, 'New guide'), + el('button', { type: 'button', onClick: () => this.importArchive('copy') }, 'Import archive'), + el('button', { type: 'button', onClick: () => this.importArchive('linked') }, 'Open linked'), + el('button', { type: 'button', onClick: () => this.openQuickActions() }, 'Quick actions'), + el('button', { type: 'button', onClick: () => this.openSettings() }, 'Settings'), + ), + el('div.row', { style: { justifyContent: 'space-between', marginBottom: '14px' } }, + el('div', {}, + el('div', { style: { fontWeight: 650 } }, folderLabel), + q ? el('div.muted', {}, `Search: ${q}`) : el('div.muted', {}, `${this.state.library.guides.length} guides`), + ), + el('div.muted', {}, this.state.info ? `StepForge ${this.state.info.version}` : ''), + ), + this.domLibraryResults = el('div', {}), + ), + ); + this.libraryHost.append(body); + + if (q) { + await this.renderSearchResults(); + } else if (this.state.folderFilter === 'trash') { + this.renderTrashView(); + } else { + this.renderGuideGrid(); + } + this.renderTopbar(); + } + + libraryNavItem(id, label, count) { + const props = { + className: `nav-item${this.state.folderFilter === id ? ' active' : ''}`, + onClick: () => { this.state.folderFilter = id; this.renderLibrary(); }, + }; + if (!['all', 'favorites', 'trash'].includes(id)) { + props.onContextMenu = (e) => this.folderContextMenu(e, id); + } + return el('div.nav-item', props, + el('span', {}, label), + el('span.count', {}, count)); + } + + renderFolderItems(folders, parentId = null, depth = 0) { + const out = []; + const children = folders + .filter((folder) => (folder.parentId || null) === parentId) + .sort((a, b) => a.name.localeCompare(b.name)); + for (const folder of children) { + const count = Object.entries(this.state.library.guideFolders || {}) + .filter(([, fid]) => fid === folder.id).length; + out.push(el('div.nav-item', { + className: `nav-item${this.state.folderFilter === folder.id ? ' active' : ''}`, + style: { paddingLeft: `${8 + depth * 12}px` }, + onClick: () => { this.state.folderFilter = folder.id; this.renderLibrary(); }, + onContextMenu: (e) => this.folderContextMenu(e, folder.id), + }, + el('span', {}, folder.name), + el('span.count', {}, count))); + out.push(...this.renderFolderItems(folders, folder.id, depth + 1)); + } + return out; + } + + folderContextMenu(event, folderId) { + event.preventDefault(); + const folder = (this.state.library.folders || []).find((f) => f.id === folderId); + if (!folder) return; + contextMenu(event.clientX, event.clientY, [ + { label: 'Rename folder', action: () => this.renameFolder(folderId) }, + { label: 'Delete folder', danger: true, action: () => this.deleteFolder(folderId) }, + ]); + } + + filterLabel() { + if (this.state.folderFilter === 'all') return 'All guides'; + if (this.state.folderFilter === 'favorites') return 'Favorites'; + if (this.state.folderFilter === 'trash') return 'Trash'; + const folder = (this.state.library.folders || []).find((f) => f.id === this.state.folderFilter); + return folder ? folder.name : 'All guides'; + } + + scopeGuide(guide) { + if (this.state.folderFilter === 'all') return true; + if (this.state.folderFilter === 'favorites') return Boolean(guide.favorite); + if (this.state.folderFilter === 'trash') return false; + return (this.state.library.guideFolders || {})[guide.guideId] === this.state.folderFilter; + } + + async renderSearchResults() { + const token = ++this.libraryRenderToken; + const results = await api.search.query({ q: this.state.query }); + if (token !== this.libraryRenderToken) return; + const guidesById = new Map(this.state.library.guides.map((g) => [g.guideId, g])); + const filtered = results.filter((r) => { + const guide = guidesById.get(r.guideId); + if (!guide) return false; + return this.scopeGuide(guide); + }); + clearNode(this.domLibraryResults); + if (!filtered.length) { + this.domLibraryResults.append(el('div.empty-state', {}, el('div.big', {}, 'Search'), 'No results for this query.')); + return; + } + this.domLibraryResults.append( + el('div.guide-grid', {}, + ...filtered.map((result) => { + const guide = guidesById.get(result.guideId); + const isStep = Boolean(result.stepId); + return this.resultCard(result, guide, isStep); + }), + ), + ); + } + + renderGuideGrid() { + const guides = this.state.library.guides.filter((guide) => this.scopeGuide(guide)); + clearNode(this.domLibraryResults); + if (!guides.length) { + this.domLibraryResults.append( + el('div.empty-state', {}, + el('div.big', {}, '∅'), + this.state.folderFilter === 'trash' + ? 'Trash is empty.' + : 'No guides in this section yet.', + ), + ); + return; + } + this.domLibraryResults.append(el('div.guide-grid', {}, ...guides.map((guide) => this.guideCard(guide)))); + } + + renderTrashView() { + clearNode(this.domLibraryResults); + if (!this.state.trash.length) { + this.domLibraryResults.append(el('div.empty-state', {}, el('div.big', {}, 'Trash'), 'Nothing deleted yet.')); + return; + } + const items = this.state.trash.map((name) => el('div.guide-card', { + onContextMenu: (e) => { + e.preventDefault(); + contextMenu(e.clientX, e.clientY, [ + { label: 'Restore', action: () => this.restoreTrashItem(name) }, + { label: 'Empty trash', danger: true, action: () => this.purgeTrashItem() }, + ]); + }, + }, + el('h4', {}, name), + el('div.meta', {}, 'Deleted guide archive'))); + this.domLibraryResults.append(el('div.guide-grid', {}, ...items)); + } + + guideCard(guide) { + const folderId = (this.state.library.guideFolders || {})[guide.guideId] || null; + const folder = (this.state.library.folders || []).find((f) => f.id === folderId); + const badgeText = guide.linkedSource ? 'Linked' : guide.favorite ? 'Favorite' : 'Local'; + const card = el('div.guide-card', { + onClick: () => this.openGuide(guide.guideId), + onContextMenu: (e) => { + e.preventDefault(); + this.guideContextMenu(e, guide); + }, + }, + el('div.fav', { + className: `fav${guide.favorite ? ' on' : ''}`, + onClick: async (e) => { + e.stopPropagation(); + await api.library.setFavorite({ guideId: guide.guideId, favorite: !guide.favorite }); + await this.refreshLibrary(); + }, + }, '★'), + el('h4', {}, guide.title || 'Untitled guide'), + el('div.meta', {}, + el('span.badge', {}, badgeText), + el('span', {}, `${guide.stepCount || 0} steps`), + folder ? el('span', {}, folder.name) : null, + guide.locked ? el('span.badge', {}, 'Locked') : null, + ), + el('div.muted', {}, fmtDate(guide.updatedAt))); + return card; + } + + resultCard(result, guide, isStep) { + return el('div.guide-card', { + onClick: () => this.openGuide(result.guideId, result.stepId || null), + }, + el('h4', {}, isStep ? `${guide.title} · ${result.title}` : result.title), + el('div.meta', {}, + el('span.badge', {}, isStep ? 'Step' : 'Guide'), + el('span', {}, guide.favorite ? 'Favorite' : 'Local'), + ), + el('div.muted', {}, result.snippet || '')); + } + + guideContextMenu(event, guide) { + const folderItems = (this.state.library.folders || []).map((folder) => ({ + label: `Move to ${folder.name}`, + action: () => this.moveGuideToFolder(guide.guideId, folder.id), + })); + const moveItems = folderItems.length ? ['sep', ...folderItems] : []; + contextMenu(event.clientX, event.clientY, [ + { label: 'Open guide', action: () => this.openGuide(guide.guideId) }, + { label: guide.favorite ? 'Unfavorite' : 'Favorite', action: () => this.toggleFavorite(guide) }, + { label: 'Duplicate guide', action: () => this.duplicateGuide(guide.guideId) }, + { label: 'Export', action: () => this.openGuideExport(guide.guideId) }, + ...moveItems, + { label: 'Move to no folder', action: () => this.moveGuideToFolder(guide.guideId, null) }, + 'sep', + { label: 'Delete guide', danger: true, action: () => this.deleteGuide(guide.guideId) }, + ]); + } + + async createGuide() { + const title = await dialogs.promptText({ + title: 'New Guide', + label: 'Title', + value: 'Untitled guide', + placeholder: 'Untitled guide', + }); + if (title == null) return; + const guide = await api.library.create({ title: title.trim() || 'Untitled guide' }); + await this.refreshLibrary(); + await this.openGuide(guide.guideId); + } + + async createFolder() { + const name = await dialogs.promptText({ title: 'New folder', label: 'Folder name', value: '' }); + if (name == null || !name.trim()) return; + await api.folders.create({ name: name.trim(), parentId: null }); + await this.refreshLibrary(); + } + + async renameFolder(folderId) { + const folder = (this.state.library.folders || []).find((f) => f.id === folderId); + if (!folder) return; + const name = await dialogs.promptText({ title: 'Rename folder', label: 'Folder name', value: folder.name }); + if (name == null || !name.trim()) return; + await api.folders.rename({ folderId, name: name.trim() }); + await this.refreshLibrary(); + } + + async deleteFolder(folderId) { + const folder = (this.state.library.folders || []).find((f) => f.id === folderId); + if (!folder) return; + const ok = await confirmDialog(`Delete the folder “${folder.name}”? Guides stay in the library.`); + if (!ok) return; + await api.folders.delete({ folderId }); + await this.refreshLibrary(); + } + + async moveGuideToFolder(guideId, folderId) { + await api.folders.moveGuide({ guideId, folderId }); + await this.refreshLibrary(); + } + + async toggleFavorite(guide) { + await api.library.setFavorite({ guideId: guide.guideId, favorite: !guide.favorite }); + await this.refreshLibrary(); + } + + async duplicateGuide(guideId) { + await api.library.duplicate({ guideId }); + await this.refreshLibrary(); + } + + async deleteGuide(guideId) { + const guide = this.state.library.guides.find((g) => g.guideId === guideId); + if (!guide) return; + const ok = await confirmDialog(`Delete “${guide.title}”?`, { danger: true, okLabel: 'Delete' }); + if (!ok) return; + await api.library.delete({ guideId }); + await this.refreshLibrary(); + } + + async restoreTrashItem(name) { + await api.library.trashRestore({ name }); + await this.refreshLibrary(); + } + + async purgeTrashItem() { + const ok = await confirmDialog('Permanently empty the trash?', { danger: true, okLabel: 'Empty trash' }); + if (!ok) return; + await api.library.trashPurge(); + await this.refreshLibrary(); + } + + async openGuideExport(guideId) { + const previous = this.editor.guideId; + await this.openGuide(guideId); + await this.editor.openExportDialog(); + if (previous && previous !== guideId) { + // keep the newly opened guide active + } + } + + async renameGuide() { + const guide = this.editorMeta?.guide; + if (!guide) return; + const title = await dialogs.promptText({ title: 'Rename guide', label: 'Title', value: guide.title }); + if (title == null || !title.trim()) return; + guide.title = title.trim(); + await api.guide.save({ guide }); + await this.editor.reload(this.editor.selectedStepId); + await this.refreshLibrary(); + } + + async importArchive(mode = 'copy') { + const result = await api.archive.open({ mode }); + if (!result || !result.ok) return; + await this.refreshLibrary(); + await this.openGuide(result.guide.guideId); + } + + async openSettings() { + const settings = await api.settings.all(); + const placeholders = await api.settings.globalPlaceholders(); + await dialogs.showSettingsDialog({ + settings, + placeholders, + onSave: async (next) => { + await api.settings.set({ keyPath: 'appearance', value: next.appearance }); + await api.settings.set({ keyPath: 'spellcheck', value: next.spellcheck }); + await api.settings.set({ keyPath: 'capture', value: next.capture }); + await api.settings.set({ keyPath: 'editor', value: next.editor }); + await api.settings.set({ keyPath: 'exports', value: next.exports }); + await api.settings.set({ keyPath: 'backups', value: next.backups }); + await api.settings.setGlobalPlaceholders(next.placeholders || {}); + this.state.settings = await api.settings.all(); + }, + }); + await this.refreshData(); + this.renderTopbar(); + if (this.state.view === 'library') this.renderLibrary(); + } + + async openQuickActions() { + if (this.state.view === 'editor') { + await this.editor.openQuickActions(); + return; + } + const commands = [ + { kind: 'cmd', label: 'New guide', description: 'Create a blank guide', action: () => this.createGuide() }, + { kind: 'cmd', label: 'Import archive', description: 'Open a .sfgz guide archive', action: () => this.importArchive('copy') }, + { kind: 'cmd', label: 'Open linked archive', description: 'Import a linked guide from .sfgz', action: () => this.importArchive('linked') }, + { kind: 'cmd', label: 'Settings', description: 'Open application settings', action: () => this.openSettings() }, + { kind: 'cmd', label: 'Refresh library', description: 'Reload guides and folders', action: () => this.refreshLibrary() }, + ]; + await dialogs.showQuickActions({ + commands, + searchFn: async (query) => { + const results = await api.search.query({ q: query }); + return results.map((result) => ({ + kind: result.stepId ? 'step' : 'guide', + label: result.stepId ? `${result.title}` : result.title, + description: result.snippet || '', + action: () => this.openGuide(result.guideId, result.stepId || null), + })); + }, + }); + } +} + +window.StepForgeApp = StepForgeApp; + +function boot() { + const app = new StepForgeApp(); + app.init(); + window.stepforgeApp = app; +} + +boot(); diff --git a/app/renderer/canvas.js b/app/renderer/canvas.js new file mode 100644 index 0000000..5c21557 --- /dev/null +++ b/app/renderer/canvas.js @@ -0,0 +1,502 @@ +'use strict'; + +/** + * AnnotationCanvas: renders a step's working image plus its normalized + * annotation scene graph, and provides editing interactions (create, select, + * move, resize, nudge, crop). Geometry rules mirror core/raster.js so the + * editor shows what exports produce. + */ + +const DRAW_ORDER = { blur: 0, highlight: 1, magnify: 2, rect: 3, oval: 3, line: 3, arrow: 3, cursor: 4, number: 5, text: 6, tooltip: 7 }; +const POINT_TOOLS = new Set(['line', 'arrow']); +const HANDLE_SIZE = 8; + +class AnnotationCanvas { + constructor(canvasEl, callbacks = {}) { + this.canvas = canvasEl; + this.ctx = canvasEl.getContext('2d'); + this.cb = callbacks; // { onChange, onSelect, onCrop, onRequestText } + this.image = null; // HTMLImageElement + this.imgW = 0; + this.imgH = 0; + this.annotations = []; + this.tool = 'select'; + this.zoomMode = 'fit'; + this.scale = 1; + this.selectedId = null; + this.drag = null; + this.cropRect = null; + + canvasEl.addEventListener('pointerdown', (e) => this.onDown(e)); + canvasEl.addEventListener('pointermove', (e) => this.onMove(e)); + canvasEl.addEventListener('pointerup', (e) => this.onUp(e)); + canvasEl.addEventListener('dblclick', (e) => this.onDblClick(e)); + } + + setImage(image, w, h) { + this.image = image; + this.imgW = w || 0; + this.imgH = h || 0; + this.cropRect = null; + if (!image || !this.imgW || !this.imgH) { + this.canvas.width = 1; + this.canvas.height = 1; + this.render(); + return; + } + this.applyZoom(); + } + + setAnnotations(annotations) { + this.annotations = annotations || []; + if (!this.annotations.some((a) => a.id === this.selectedId)) this.selectedId = null; + this.render(); + } + + setTool(tool) { + this.tool = tool; + this.cropRect = null; + if (tool !== 'select') this.select(null); + this.render(); + } + + setZoom(mode) { + this.zoomMode = mode; + this.applyZoom(); + } + + applyZoom() { + if (!this.image) return; + const wrap = this.canvas.parentElement; + if (this.zoomMode === 'fit') { + const availW = Math.max(100, wrap.clientWidth - 40); + const availH = Math.max(100, wrap.clientHeight - 40); + this.scale = Math.min(availW / this.imgW, availH / this.imgH, 1); + } else { + this.scale = Number(this.zoomMode) || 1; + } + this.canvas.width = Math.round(this.imgW * this.scale); + this.canvas.height = Math.round(this.imgH * this.scale); + this.render(); + } + + select(id) { + this.selectedId = id; + if (this.cb.onSelect) this.cb.onSelect(this.annotations.find((a) => a.id === id) || null); + this.render(); + } + + selected() { + return this.annotations.find((a) => a.id === this.selectedId) || null; + } + + changed() { + if (this.cb.onChange) this.cb.onChange(this.annotations); + this.render(); + } + + // ---- coordinate helpers ---- + toNorm(e) { + const rect = this.canvas.getBoundingClientRect(); + return { + x: (e.clientX - rect.left) / rect.width, + y: (e.clientY - rect.top) / rect.height, + }; + } + + px(ann) { + return { + x: ann.x * this.canvas.width, + y: ann.y * this.canvas.height, + w: ann.w * this.canvas.width, + h: ann.h * this.canvas.height, + }; + } + + // ---- rendering ---- + render() { + const { ctx, canvas } = this; + ctx.clearRect(0, 0, canvas.width, canvas.height); + if (!this.image) return; + ctx.imageSmoothingEnabled = true; + ctx.drawImage(this.image, 0, 0, canvas.width, canvas.height); + + const ordered = [...this.annotations].sort((a, b) => (DRAW_ORDER[a.type] ?? 3) - (DRAW_ORDER[b.type] ?? 3)); + for (const ann of ordered) this.drawAnnotation(ann); + + const sel = this.selected(); + if (sel) this.drawSelection(sel); + if (this.cropRect) this.drawCropOverlay(); + } + + strokePx(ann) { + return Math.max(1, ((ann.style && ann.style.strokeWidth) || 3) * this.canvas.width / 1000); + } + + fontPx(ann) { + return Math.max(9, ((ann.style && ann.style.fontSize) || 0.022) * this.canvas.height); + } + + drawAnnotation(ann) { + const { ctx } = this; + const { x, y, w, h } = this.px(ann); + const style = ann.style || {}; + const stroke = style.stroke || '#E5484D'; + const fill = style.fill && style.fill !== 'transparent' ? style.fill : null; + ctx.save(); + ctx.lineWidth = this.strokePx(ann); + ctx.strokeStyle = stroke; + + switch (ann.type) { + case 'rect': + if (fill) { ctx.fillStyle = fill; ctx.fillRect(x, y, w, h); } + ctx.strokeRect(x, y, w, h); + break; + case 'oval': + ctx.beginPath(); + ctx.ellipse(x + w / 2, y + h / 2, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2); + if (fill) { ctx.fillStyle = fill; ctx.fill(); } + ctx.stroke(); + break; + case 'line': + ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(x + w, y + h); ctx.stroke(); + break; + case 'arrow': { + const len = Math.hypot(w, h) || 1; + const head = Math.min(len * 0.4, Math.max(10, ctx.lineWidth * 4)); + const ux = w / len, uy = h / len; + const bx = x + w - ux * head, by = y + h - uy * head; + ctx.beginPath(); ctx.moveTo(x, y); ctx.lineTo(bx, by); ctx.stroke(); + ctx.beginPath(); + ctx.moveTo(x + w, y + h); + ctx.lineTo(bx - uy * head * 0.5, by + ux * head * 0.5); + ctx.lineTo(bx + uy * head * 0.5, by - ux * head * 0.5); + ctx.closePath(); + ctx.fillStyle = stroke; ctx.fill(); + break; + } + case 'blur': { + // preview: pixelate the region by down/up-scaling + const f = Math.max(6, (ann.radius || 8)); + try { + ctx.imageSmoothingEnabled = true; + const tw = Math.max(1, Math.round(w / f)), th = Math.max(1, Math.round(h / f)); + const off = document.createElement('canvas'); + off.width = tw; off.height = th; + off.getContext('2d').drawImage(this.canvas, x, y, w, h, 0, 0, tw, th); + ctx.imageSmoothingEnabled = true; + ctx.drawImage(off, 0, 0, tw, th, x, y, w, h); + } catch { /* region may be degenerate while dragging */ } + break; + } + case 'highlight': + ctx.fillStyle = 'rgba(255, 235, 59, 0.41)'; + ctx.fillRect(x, y, w, h); + break; + case 'magnify': { + const zoom = ann.zoom || 2; + ctx.save(); + ctx.beginPath(); + ctx.ellipse(x + w / 2, y + h / 2, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2); + ctx.clip(); + const sw = w / zoom, sh = h / zoom; + ctx.drawImage( + this.image, + (x + w / 2 - sw / 2) / this.scale, (y + h / 2 - sh / 2) / this.scale, + sw / this.scale, sh / this.scale, + x, y, w, h + ); + ctx.restore(); + ctx.beginPath(); + ctx.ellipse(x + w / 2, y + h / 2, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2); + ctx.stroke(); + break; + } + case 'text': { + ctx.font = `600 ${this.fontPx(ann)}px system-ui, sans-serif`; + ctx.fillStyle = stroke; + ctx.textBaseline = 'top'; + let ty = y; + for (const line of String(ann.text || 'Text').split('\n')) { + ctx.fillText(line, x, ty); + ty += this.fontPx(ann) * 1.25; + } + break; + } + case 'tooltip': { + const bg = fill || '#1F2937'; + const ts = Math.max(6, Math.min(Math.abs(w), Math.abs(h)) * 0.25); + ctx.fillStyle = bg; + ctx.beginPath(); + const r = 6; + ctx.roundRect(x, y, w, h, r); + ctx.fill(); + const tail = style.tail || 'bottom'; + ctx.beginPath(); + if (tail === 'bottom') { ctx.moveTo(x + w / 2 - ts, y + h); ctx.lineTo(x + w / 2 + ts, y + h); ctx.lineTo(x + w / 2, y + h + ts * 1.4); } + if (tail === 'top') { ctx.moveTo(x + w / 2 - ts, y); ctx.lineTo(x + w / 2 + ts, y); ctx.lineTo(x + w / 2, y - ts * 1.4); } + if (tail === 'left') { ctx.moveTo(x, y + h / 2 - ts); ctx.lineTo(x, y + h / 2 + ts); ctx.lineTo(x - ts * 1.4, y + h / 2); } + if (tail === 'right') { ctx.moveTo(x + w, y + h / 2 - ts); ctx.lineTo(x + w, y + h / 2 + ts); ctx.lineTo(x + w + ts * 1.4, y + h / 2); } + ctx.closePath(); ctx.fill(); + ctx.fillStyle = style.textColor || '#fff'; + ctx.font = `600 ${this.fontPx(ann)}px system-ui, sans-serif`; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(String(ann.text || '…'), x + w / 2, y + h / 2, Math.abs(w) - 8); + break; + } + case 'number': { + const rr = Math.max(8, Math.min(Math.abs(w), Math.abs(h)) / 2); + ctx.fillStyle = stroke; + ctx.beginPath(); + ctx.arc(x + w / 2, y + h / 2, rr, 0, Math.PI * 2); + ctx.fill(); + ctx.fillStyle = style.textColor || '#fff'; + ctx.font = `700 ${rr}px system-ui, sans-serif`; + ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; + ctx.fillText(String(ann.value ?? '?'), x + w / 2, y + h / 2 + 1); + break; + } + case 'cursor': { + const s = Math.max(12, Math.min(Math.abs(w), Math.abs(h))); + ctx.fillStyle = '#fff'; + ctx.strokeStyle = '#111827'; + ctx.lineWidth = Math.max(1, s / 12); + ctx.beginPath(); + ctx.moveTo(x, y); ctx.lineTo(x, y + s); ctx.lineTo(x + s * 0.28, y + s * 0.75); + ctx.lineTo(x + s * 0.45, y + s * 1.05); ctx.lineTo(x + s * 0.58, y + s * 0.98); + ctx.lineTo(x + s * 0.42, y + s * 0.68); ctx.lineTo(x + s * 0.72, y + s * 0.68); + ctx.closePath(); + ctx.fill(); ctx.stroke(); + break; + } + default: break; + } + ctx.restore(); + } + + drawSelection(ann) { + const { ctx } = this; + const { x, y, w, h } = this.px(ann); + ctx.save(); + ctx.strokeStyle = '#2563eb'; + ctx.setLineDash([5, 4]); + ctx.lineWidth = 1.2; + ctx.strokeRect(Math.min(x, x + w) - 3, Math.min(y, y + h) - 3, Math.abs(w) + 6, Math.abs(h) + 6); + ctx.setLineDash([]); + ctx.fillStyle = '#2563eb'; + for (const hd of this.handles(ann)) { + ctx.fillRect(hd.px - HANDLE_SIZE / 2, hd.py - HANDLE_SIZE / 2, HANDLE_SIZE, HANDLE_SIZE); + } + ctx.restore(); + } + + handles(ann) { + const { x, y, w, h } = this.px(ann); + if (POINT_TOOLS.has(ann.type)) { + return [ + { id: 'p1', px: x, py: y }, + { id: 'p2', px: x + w, py: y + h }, + ]; + } + return [ + { id: 'nw', px: x, py: y }, { id: 'n', px: x + w / 2, py: y }, { id: 'ne', px: x + w, py: y }, + { id: 'w', px: x, py: y + h / 2 }, { id: 'e', px: x + w, py: y + h / 2 }, + { id: 'sw', px: x, py: y + h }, { id: 's', px: x + w / 2, py: y + h }, { id: 'se', px: x + w, py: y + h }, + ]; + } + + drawCropOverlay() { + const { ctx, canvas } = this; + const r = this.cropRect; + const x = Math.min(r.x0, r.x1) * canvas.width; + const y = Math.min(r.y0, r.y1) * canvas.height; + const w = Math.abs(r.x1 - r.x0) * canvas.width; + const h = Math.abs(r.y1 - r.y0) * canvas.height; + ctx.save(); + ctx.fillStyle = 'rgba(0,0,0,0.5)'; + ctx.beginPath(); + ctx.rect(0, 0, canvas.width, canvas.height); + ctx.rect(x, y, w, h); + ctx.fill('evenodd'); + ctx.strokeStyle = '#fff'; + ctx.setLineDash([6, 4]); + ctx.strokeRect(x, y, w, h); + ctx.restore(); + } + + // ---- interactions ---- + hitTest(pt) { + // topmost first (reverse draw order) + const ordered = [...this.annotations].sort((a, b) => (DRAW_ORDER[b.type] ?? 3) - (DRAW_ORDER[a.type] ?? 3)); + for (const ann of ordered) { + const x0 = Math.min(ann.x, ann.x + ann.w) - 0.008; + const y0 = Math.min(ann.y, ann.y + ann.h) - 0.008; + const x1 = Math.max(ann.x, ann.x + ann.w) + 0.008; + const y1 = Math.max(ann.y, ann.y + ann.h) + 0.008; + if (pt.x >= x0 && pt.x <= x1 && pt.y >= y0 && pt.y <= y1) return ann; + } + return null; + } + + handleAt(e) { + const sel = this.selected(); + if (!sel) return null; + const rect = this.canvas.getBoundingClientRect(); + const px = e.clientX - rect.left; + const py = e.clientY - rect.top; + for (const hd of this.handles(sel)) { + if (Math.abs(px - hd.px) <= HANDLE_SIZE && Math.abs(py - hd.py) <= HANDLE_SIZE) return hd.id; + } + return null; + } + + onDown(e) { + if (!this.image) return; + this.canvas.setPointerCapture(e.pointerId); + const pt = this.toNorm(e); + + if (this.tool === 'crop') { + this.cropRect = { x0: pt.x, y0: pt.y, x1: pt.x, y1: pt.y }; + this.drag = { kind: 'crop' }; + return; + } + if (this.tool === 'select') { + const handle = this.handleAt(e); + if (handle) { + this.drag = { kind: 'resize', handle, start: pt, orig: { ...this.selected() } }; + return; + } + const hit = this.hitTest(pt); + this.select(hit ? hit.id : null); + if (hit) this.drag = { kind: 'move', start: pt, orig: { ...hit } }; + return; + } + + // creation tools + const ann = { + id: `ann-${Date.now().toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`, + type: this.tool, + x: pt.x, y: pt.y, w: 0, h: 0, + text: this.tool === 'tooltip' ? 'Tooltip' : this.tool === 'text' ? 'Text' : '', + style: this.cb.defaultStyle ? this.cb.defaultStyle(this.tool) : {}, + }; + if (this.tool === 'number') ann.value = this.cb.nextNumber ? this.cb.nextNumber() : 1; + if (this.tool === 'magnify') ann.zoom = 2; + if (this.tool === 'blur') ann.radius = 8; + this.annotations.push(ann); + this.selectedId = ann.id; + this.drag = { kind: 'create', start: pt, ann }; + } + + onMove(e) { + if (!this.drag) return; + const pt = this.toNorm(e); + const d = this.drag; + + if (d.kind === 'crop') { + this.cropRect.x1 = pt.x; + this.cropRect.y1 = pt.y; + this.render(); + return; + } + if (d.kind === 'create') { + d.ann.w = pt.x - d.start.x; + d.ann.h = pt.y - d.start.y; + this.render(); + return; + } + const sel = this.selected(); + if (!sel) return; + if (d.kind === 'move') { + sel.x = d.orig.x + (pt.x - d.start.x); + sel.y = d.orig.y + (pt.y - d.start.y); + this.render(); + } else if (d.kind === 'resize') { + this.resizeBy(sel, d, pt); + this.render(); + } + } + + resizeBy(ann, d, pt) { + const dx = pt.x - d.start.x; + const dy = pt.y - d.start.y; + const o = d.orig; + const h = d.handle; + if (h === 'p1') { ann.x = o.x + dx; ann.y = o.y + dy; ann.w = o.w - dx; ann.h = o.h - dy; return; } + if (h === 'p2') { ann.w = o.w + dx; ann.h = o.h + dy; return; } + if (h.includes('w')) { ann.x = o.x + dx; ann.w = o.w - dx; } + if (h.includes('e')) { ann.w = o.w + dx; } + if (h.includes('n')) { ann.y = o.y + dy; ann.h = o.h - dy; } + if (h.includes('s')) { ann.h = o.h + dy; } + } + + onUp(e) { + const d = this.drag; + this.drag = null; + if (!d) return; + if (d.kind === 'crop') { + const r = this.cropRect; + this.cropRect = null; + const rect = { + x: Math.min(r.x0, r.x1), y: Math.min(r.y0, r.y1), + w: Math.abs(r.x1 - r.x0), h: Math.abs(r.y1 - r.y0), + }; + this.render(); + if (rect.w > 0.02 && rect.h > 0.02 && this.cb.onCrop) this.cb.onCrop(rect); + return; + } + if (d.kind === 'create') { + // degenerate drags get a sensible default size + if (Math.abs(d.ann.w) < 0.01 && Math.abs(d.ann.h) < 0.01) { + const defaults = { number: [0.05, 0.08], text: [0.2, 0.05], tooltip: [0.18, 0.07], cursor: [0.04, 0.06] }; + const [dw, dh] = defaults[d.ann.type] || [0.15, 0.1]; + d.ann.w = dw; d.ann.h = dh; + } + this.normalizeRect(d.ann); + this.changed(); + this.select(d.ann.id); + if ((d.ann.type === 'text' || d.ann.type === 'tooltip') && this.cb.onRequestText) { + this.cb.onRequestText(d.ann); + } + return; + } + if (d.kind === 'move' || d.kind === 'resize') { + const sel = this.selected(); + if (sel) this.normalizeRect(sel); + this.changed(); + } + } + + normalizeRect(ann) { + if (POINT_TOOLS.has(ann.type)) return; // lines keep direction + if (ann.w < 0) { ann.x += ann.w; ann.w = -ann.w; } + if (ann.h < 0) { ann.y += ann.h; ann.h = -ann.h; } + } + + onDblClick(e) { + const hit = this.hitTest(this.toNorm(e)); + if (hit && (hit.type === 'text' || hit.type === 'tooltip') && this.cb.onRequestText) { + this.select(hit.id); + this.cb.onRequestText(hit); + } + } + + nudgeSelected(dx, dy) { + const sel = this.selected(); + if (!sel) return false; + sel.x += dx / this.canvas.width; + sel.y += dy / this.canvas.height; + this.changed(); + return true; + } + + deleteSelected() { + if (!this.selectedId) return false; + this.annotations = this.annotations.filter((a) => a.id !== this.selectedId); + this.select(null); + this.changed(); + return true; + } +} + +window.AnnotationCanvas = AnnotationCanvas; diff --git a/app/renderer/dialogs.js b/app/renderer/dialogs.js new file mode 100644 index 0000000..824484a --- /dev/null +++ b/app/renderer/dialogs.js @@ -0,0 +1,440 @@ +'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. + */ + +function labeledRow(labelText, control, { stacked = false } = {}) { + return el(stacked ? 'div.form-row.stacked' : 'div.form-row', {}, + el('label', {}, labelText), + control + ); +} + +function makeInput(value = '', type = 'text', attrs = {}) { + return el('input', { type, value, ...attrs }); +} + +function makeSelect(value, options) { + return el('select', {}, + options.map((opt) => el('option', { value: opt.value, selected: opt.value === value }, opt.label)) + ); +} + +async function promptText({ title, label = 'Value', value = '', placeholder = '', multiline = false } = {}) { + return new Promise((resolve) => { + const field = multiline + ? el('textarea', { rows: 6, placeholder }, value) + : el('input', { type: 'text', value, placeholder }); + + const { close } = openModal({ + title, + body: labeledRow(label, field, { stacked: multiline }), + footer: [ + el('button', { onClick: () => { close(); resolve(null); } }, 'Cancel'), + el('button.primary', { onClick: () => { close(); resolve(field.value); } }, 'OK'), + ], + onClose: () => resolve(null), + }); + + field.addEventListener('keydown', (e) => { + if (!multiline && e.key === 'Enter') { + e.preventDefault(); + close(); + resolve(field.value); + } + }); + + setTimeout(() => field.focus(), 0); + }); +} + +function showQuickActions({ query = '', commands = [], searchFn, onOpenItem, onClose } = {}) { + return new Promise((resolve) => { + const input = el('input', { + type: 'search', + value: query, + placeholder: 'Search guides, steps, and commands', + autocomplete: 'off', + spellcheck: false, + }); + const results = el('div.qa-results'); + const hint = el('div.muted', {}, 'Type to search, arrows to move, Enter to open.'); + let items = []; + let active = 0; + + function renderItems() { + clearNode(results); + if (!items.length) { + results.append(el('div.muted', { style: { padding: '8px 2px' } }, 'No matches.')); + return; + } + items.forEach((item, idx) => { + results.append(el('div.qa-item', { + className: `qa-item${idx === active ? ' active' : ''}`, + onMouseenter: () => { active = idx; renderItems(); }, + onClick: () => choose(idx), + }, + el('span.kind', {}, item.kind || 'cmd'), + el('div', {}, + el('div', { style: { fontWeight: 600 } }, item.label), + item.description ? el('div.snippet', {}, item.description) : null, + ))); + }); + } + + function choose(idx = active) { + const item = items[idx]; + if (!item) return; + close(); + if (item.action) item.action(); + if (onOpenItem) onOpenItem(item); + resolve(item); + } + + async function refresh() { + const q = input.value.trim(); + const commandMatches = commands.filter((cmd) => { + if (!q) return true; + const needle = q.toLowerCase(); + return `${cmd.label} ${cmd.description || ''}`.toLowerCase().includes(needle); + }).map((cmd) => ({ ...cmd, kind: cmd.kind || 'cmd' })); + const searchResults = q && searchFn ? await searchFn(q) : []; + items = [...commandMatches, ...searchResults]; + if (active >= items.length) active = 0; + renderItems(); + } + + const { close } = openModal({ + title: 'Quick Actions', + body: el('div.quick-actions', {}, + input, + hint, + results, + ), + wide: true, + footer: [ + el('button', { onClick: () => { close(); resolve(null); } }, 'Close'), + ], + onClose: () => { + if (onClose) onClose(); + resolve(null); + }, + }); + + const debounced = debounce(refresh, 60); + input.addEventListener('input', debounced); + input.addEventListener('keydown', (e) => { + if (e.key === 'ArrowDown') { e.preventDefault(); active = Math.min(items.length - 1, active + 1); renderItems(); } + else if (e.key === 'ArrowUp') { e.preventDefault(); active = Math.max(0, active - 1); renderItems(); } + else if (e.key === 'Enter') { e.preventDefault(); choose(); } + else if (e.key === 'Escape') { e.preventDefault(); close(); resolve(null); } + }); + + refresh(); + setTimeout(() => input.focus(), 0); + }); +} + +function showSettingsDialog({ + settings, + placeholders = {}, + onSave, +} = {}) { + return new Promise((resolve) => { + const form = el('form', { className: 'settings-form' }); + + const appearance = makeSelect(settings.appearance || 'system', [ + { value: 'system', label: 'System' }, + { value: 'light', label: 'Light' }, + { value: 'dark', label: 'Dark' }, + ]); + const spellcheck = el('input', { type: 'checkbox', checked: Boolean(settings.spellcheck) }); + const delayMs = makeInput(settings.capture?.delayMs ?? 0, 'number', { min: 0, step: 50 }); + const captureMode = makeSelect(settings.capture?.mode || 'fullscreen', [ + { value: 'fullscreen', label: 'Fullscreen' }, + { value: 'window', label: 'Window' }, + { value: 'region', label: 'Region' }, + ]); + const clickMarker = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.clickMarker) }); + const captureHotkey = makeInput(settings.capture?.hotkeyCapture || '', 'text'); + const pauseHotkey = makeInput(settings.capture?.hotkeyPauseResume || '', 'text'); + const focusedDefault = el('input', { type: 'checkbox', checked: Boolean(settings.editor?.focusedViewDefaultForNewSteps) }); + const previewCount = makeInput(settings.exports?.previewStepCount ?? 3, 'number', { min: 1, step: 1 }); + const openFolder = el('input', { type: 'checkbox', checked: Boolean(settings.exports?.openFolderAfterExport) }); + const captureOutside = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.captureOutsideClicks) }); + const confirmSimple = el('input', { type: 'checkbox', checked: Boolean(settings.capture?.confirmSimpleCapture) }); + const keepLast = makeInput(settings.backups?.keepLast ?? 10, 'number', { min: 0, step: 1 }); + + const placeholderRows = el('div', { className: 'placeholder-rows' }); + const rows = []; + const addPlaceholderRow = (key = '', value = '') => { + const keyInput = makeInput(key); + const valueInput = makeInput(value); + const removeBtn = el('button.icon', { + type: 'button', + title: 'Remove placeholder', + onClick: () => { + row.remove(); + rows.splice(rows.indexOf(row), 1); + }, + }, '−'); + const row = el('div.placeholder-row', {}, + keyInput, + valueInput, + removeBtn, + ); + rows.push(row); + placeholderRows.append(row); + return row; + }; + Object.entries(placeholders || {}).forEach(([k, v]) => addPlaceholderRow(k, v)); + + const addPlaceholderBtn = el('button', { + type: 'button', + onClick: () => addPlaceholderRow(), + }, 'Add placeholder'); + + form.append( + el('fieldset', {}, + el('legend', {}, 'Appearance'), + labeledRow('Theme', appearance), + labeledRow('Spellcheck', spellcheck), + labeledRow('Open folder after export', openFolder), + ), + el('fieldset', {}, + el('legend', {}, 'Capture'), + labeledRow('Default mode', captureMode), + labeledRow('Delay (ms)', delayMs), + labeledRow('Click marker', clickMarker), + labeledRow('Capture outside clicks', captureOutside), + labeledRow('Confirm simple capture', confirmSimple), + labeledRow('Capture hotkey', captureHotkey), + labeledRow('Pause / resume hotkey', pauseHotkey), + ), + el('fieldset', {}, + el('legend', {}, 'Editor'), + labeledRow('Focused view for new steps', focusedDefault), + labeledRow('Preview step count', previewCount), + ), + el('fieldset', {}, + el('legend', {}, 'Backups'), + labeledRow('Keep last snapshots', keepLast), + ), + el('fieldset', {}, + el('legend', {}, 'Global placeholders'), + placeholderRows, + el('div.row', { style: { justifyContent: 'flex-start' } }, addPlaceholderBtn), + ), + ); + + const { close } = openModal({ + title: 'Settings', + body: form, + wide: true, + footer: [ + el('button', { type: 'button', onClick: () => { close(); resolve(false); } }, 'Cancel'), + el('button.primary', { + type: 'submit', + onClick: async (e) => { + e.preventDefault(); + const next = { + appearance: appearance.value, + spellcheck: spellcheck.checked, + capture: { + ...settings.capture, + delayMs: Number(delayMs.value || 0), + mode: captureMode.value, + clickMarker: clickMarker.checked, + hotkeyCapture: captureHotkey.value.trim(), + hotkeyPauseResume: pauseHotkey.value.trim(), + captureOutsideClicks: captureOutside.checked, + confirmSimpleCapture: confirmSimple.checked, + }, + editor: { + ...settings.editor, + focusedViewDefaultForNewSteps: focusedDefault.checked, + }, + exports: { + ...settings.exports, + previewStepCount: Number(previewCount.value || 3), + openFolderAfterExport: openFolder.checked, + }, + backups: { + ...settings.backups, + keepLast: Number(keepLast.value || 0), + }, + placeholders: rows.reduce((acc, row) => { + const inputs = row.querySelectorAll('input'); + const key = inputs[0].value.trim(); + const value = inputs[1].value; + if (key) acc[key] = value; + return acc; + }, {}), + }; + await onSave(next); + close(); + resolve(true); + }, + }, 'Save'), + ], + onClose: () => resolve(false), + }); + + form.addEventListener('submit', (e) => e.preventDefault()); + }); +} + +function showExportDialog({ + formats, + templatesByFormat = {}, + defaultFormat = 'pdf', + defaultOutDir = '', + onChooseDir, + onExport, + onPreview, +} = {}) { + return new Promise((resolve) => { + const formatOptions = (formats || []).map((f) => { + if (typeof f === 'string') return { value: f, label: f }; + return { value: f.id || f.value || f.name, label: f.label || f.id || f.value || f.name }; + }); + const formatSelect = makeSelect(defaultFormat, formatOptions); + const templateSelect = makeSelect('', [{ value: '', label: 'Default template' }]); + const outDirInput = makeInput(defaultOutDir, 'text', { placeholder: 'Choose an output folder' }); + const info = el('div.muted', {}, 'Templates are optional. If no template is selected, exporter defaults are used.'); + + function refreshTemplates() { + const list = templatesByFormat[formatSelect.value] || []; + clearNode(templateSelect); + templateSelect.append(el('option', { value: '' }, 'Default template')); + for (const name of list) templateSelect.append(el('option', { value: name }, name)); + } + + formatSelect.addEventListener('change', refreshTemplates); + refreshTemplates(); + + const body = el('div.export-dialog', {}, + labeledRow('Format', formatSelect), + labeledRow('Template', templateSelect), + labeledRow('Output folder', el('div.row', {}, outDirInput, el('button', { + type: 'button', + disabled: typeof onChooseDir !== 'function', + onClick: async () => { + if (typeof onChooseDir !== 'function') return; + const chosen = await onChooseDir(formatSelect.value); + if (chosen) outDirInput.value = chosen; + }, + }, 'Choose…'))), + info, + ); + + const { close } = openModal({ + title: 'Export', + body, + footer: [ + el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'), + el('button', { + onClick: async () => { + if (typeof onPreview !== 'function') return; + const ok = await onPreview({ + format: formatSelect.value, + templateName: templateSelect.value || null, + outDir: outDirInput.value.trim() || null, + }); + if (ok !== false) { + close(); + resolve(true); + } + }, + }, 'Preview'), + el('button.primary', { + onClick: async () => { + if (typeof onExport !== 'function') return; + const ok = await onExport({ + format: formatSelect.value, + templateName: templateSelect.value || null, + outDir: outDirInput.value.trim() || null, + }); + if (ok !== false) { + close(); + resolve(true); + } + }, + }, 'Export'), + ], + wide: true, + onClose: () => resolve(false), + }); + }); +} + +function showLinkedGuideDialog({ guide, lock, onSave, onForceSave, onOpenArchive } = {}) { + return new Promise((resolve) => { + const linked = guide.linkedSource || {}; + const conflict = lock && !lock.acquired; + const conflictInfo = lock && lock.conflict ? lock.conflict : {}; + const lockInfo = conflict + ? `Locked by ${conflictInfo.user || 'another user'}@${conflictInfo.host || 'another host'}` + : 'No active conflict'; + + const body = el('div', { className: 'linked-guide' }, + el('div', { className: 'card-list' }, + el('div.row', {}, el('span.muted', {}, 'Archive'), el('strong', {}, linked.path || 'Not linked')), + el('div.row', {}, el('span.muted', {}, 'Opened'), el('span', {}, fmtDate(linked.openedAt) || 'Unknown')), + el('div.row', {}, el('span.muted', {}, 'Last saved'), el('span', {}, fmtDate(linked.lastSavedAt) || 'Never')), + el('div.row', {}, el('span.muted', {}, 'Lock'), el('span', {}, lockInfo)), + ), + conflict ? el('div', { className: 'warn-banner' }, 'Another editor has the archive locked. You can force-save if you intend to overwrite it.') : null, + ); + + const { close } = openModal({ + title: 'Linked Guide', + body, + footer: [ + el('button', { onClick: () => { close(); resolve(false); } }, 'Close'), + el('button', { + onClick: async () => { + await onOpenArchive?.(guide); + }, + }, 'Show file'), + conflict ? el('button.primary', { + onClick: async () => { + await onForceSave?.(guide); + close(); + resolve(true); + }, + }, 'Force save') : el('button.primary', { + onClick: async () => { + await onSave?.(guide); + close(); + resolve(true); + }, + }, 'Save now'), + ], + wide: true, + onClose: () => resolve(false), + }); + }); +} + +function showInfoDialog(title, bodyText) { + return new Promise((resolve) => { + const { close } = openModal({ + title, + body: el('div', {}, bodyText), + footer: [el('button.primary', { onClick: () => { close(); resolve(true); } }, 'OK')], + onClose: () => resolve(false), + }); + }); +} + +window.StepForgeDialogs = { + promptText, + showQuickActions, + showSettingsDialog, + showExportDialog, + showLinkedGuideDialog, + showInfoDialog, +}; diff --git a/app/renderer/editor.js b/app/renderer/editor.js new file mode 100644 index 0000000..756f30f --- /dev/null +++ b/app/renderer/editor.js @@ -0,0 +1,1208 @@ +'use strict'; + +const api = window.stepforge; +const dialogs = window.StepForgeDialogs || {}; + +const clone = (value) => JSON.parse(JSON.stringify(value)); + +function stepNumberMap(steps) { + const numbers = new Map(); + const childCounts = new Map(); + let top = 0; + for (const step of steps) { + let number; + if (step.parentStepId && numbers.has(step.parentStepId)) { + const parent = numbers.get(step.parentStepId); + const next = (childCounts.get(step.parentStepId) || 0) + 1; + childCounts.set(step.parentStepId, next); + number = `${parent}.${next}`; + } else { + top += 1; + number = String(top); + } + numbers.set(step.stepId, number); + } + return numbers; +} + +function isEditableTarget(target) { + return target && ( + target.tagName === 'INPUT' || + target.tagName === 'TEXTAREA' || + target.isContentEditable + ); +} + +class GuideEditor { + constructor({ root, onMetaChange = () => {}, onToast = toast, onBack = () => {} } = {}) { + this.root = root; + this.onMetaChange = onMetaChange; + this.onToast = onToast; + this.onBack = onBack; + + this.guideId = null; + this.guide = null; + this.steps = []; + this.stepMap = new Map(); + this.selectedStepId = null; + this.selectedAnnotationId = null; + this.currentTool = 'select'; + this.currentZoom = 'fit'; + this.pendingSave = false; + this.pendingGuideSave = false; + this.canvasHistory = []; + this.canvasFuture = []; + this.beforeCanvasSnapshot = null; + this.stepLoadToken = 0; + this.imageLoadToken = 0; + this.shellMounted = false; + this.linkedConflict = false; + this.descriptionDirty = false; + this.titleDirty = false; + this.active = true; + + this.saveStepDebounced = debounce(() => this.flushStep(), 180); + this.saveGuideDebounced = debounce(() => this.flushGuide(), 180); + + this.onDocumentKeyDown = this.onDocumentKeyDown.bind(this); + document.addEventListener('keydown', this.onDocumentKeyDown, true); + } + + destroy() { + document.removeEventListener('keydown', this.onDocumentKeyDown, true); + if (this.resizeObserver) this.resizeObserver.disconnect(); + } + + setActive(active) { + this.active = Boolean(active); + } + + get currentStep() { + return this.stepMap.get(this.selectedStepId) || null; + } + + get currentStepNumber() { + if (!this.currentStep) return ''; + return stepNumberMap(this.steps).get(this.currentStep.stepId) || ''; + } + + getMeta() { + return { + guide: this.guide ? clone(this.guide) : null, + step: this.currentStep ? clone(this.currentStep) : null, + stepCount: this.steps.length, + selectedStepId: this.selectedStepId, + selectedAnnotationId: this.selectedAnnotationId, + linked: Boolean(this.guide && this.guide.linkedSource), + dirty: this.pendingSave || this.pendingGuideSave || this.descriptionDirty || this.titleDirty, + view: 'editor', + }; + } + + emitMeta() { + this.onMetaChange(this.getMeta()); + } + + async open(guideId, stepId = null) { + this.guideId = guideId; + this.selectedStepId = stepId; + this.selectedAnnotationId = null; + this.canvasHistory = []; + this.canvasFuture = []; + this.pendingSave = false; + this.pendingGuideSave = false; + this.setActive(true); + await this.reload(stepId); + } + + async reload(stepId = this.selectedStepId) { + const token = ++this.stepLoadToken; + const { guide, steps } = await api.guide.get({ guideId: this.guideId }); + if (token !== this.stepLoadToken) return; + this.guide = guide; + this.steps = steps; + this.stepMap = new Map(steps.map((step) => [step.stepId, step])); + if (!this.shellMounted) this.mountShell(); + if (!this.selectedStepId || !this.stepMap.has(this.selectedStepId)) { + this.selectedStepId = stepId && this.stepMap.has(stepId) ? stepId : (steps[0] && steps[0].stepId) || null; + } + this.selectedAnnotationId = null; + this.renderAll(); + } + + mountShell() { + this.shellMounted = true; + this.root.innerHTML = ''; + const toolButtons = [ + ['select', 'Select'], + ['rect', 'Rect'], + ['oval', 'Oval'], + ['line', 'Line'], + ['arrow', 'Arrow'], + ['text', 'Text'], + ['tooltip', 'Tip'], + ['number', '#'], + ['blur', 'Blur'], + ['highlight', 'Hi'], + ['magnify', 'Mag'], + ['cursor', 'Cursor'], + ['crop', 'Crop'], + ]; + + this.dom = {}; + this.dom.root = el('div.editor', {}, + el('aside.pane-steps', {}, + el('div.pane-head', {}, + el('div', {}, + el('div.eyebrow', {}, 'Steps'), + this.dom.stepCount = el('div.muted', {}, '0 steps'), + ), + el('div.row', {}, + this.dom.addStepBtn = el('button.primary', { type: 'button' }, 'Add'), + this.dom.importBtn = el('button', { type: 'button' }, 'Import'), + ), + ), + this.dom.stepsList = el('div.steps-list'), + el('div.pane-foot', {}, + this.dom.moveUpBtn = el('button.icon', { type: 'button', title: 'Move step up' }, '↑'), + this.dom.moveDownBtn = el('button.icon', { type: 'button', title: 'Move step down' }, '↓'), + this.dom.duplicateBtn = el('button', { type: 'button' }, 'Duplicate'), + this.dom.deleteBtn = el('button.danger', { type: 'button' }, 'Delete'), + ), + ), + el('section.pane-canvas', {}, + el('div.canvas-toolbar', {}, + ...toolButtons.map(([tool, label]) => this.dom[`tool-${tool}`] = el('button.tool', { type: 'button', dataset: { tool } }, label)), + el('span.sep'), + this.dom.zoomFitBtn = el('button.tool', { type: 'button' }, 'Fit'), + this.dom.zoom100Btn = el('button.tool', { type: 'button' }, '100%'), + this.dom.zoom125Btn = el('button.tool', { type: 'button' }, '125%'), + this.dom.zoom150Btn = el('button.tool', { type: 'button' }, '150%'), + el('span.sep'), + this.dom.undoBtn = el('button.tool', { type: 'button' }, 'Undo'), + this.dom.redoBtn = el('button.tool', { type: 'button' }, 'Redo'), + ), + this.dom.canvasWrap = el('div.canvas-wrap', {}, + this.dom.canvas = el('canvas', { width: 1, height: 1 }), + this.dom.canvasEmpty = el('div.canvas-empty', {}, 'Select an image step to edit annotations.'), + ), + ), + el('aside.pane-props', {}, + el('section', {}, + el('h3', {}, 'Step'), + this.dom.titleInput = el('input', { type: 'text', placeholder: 'Step title' }), + this.dom.statusSelect = makeSelect('todo', [ + { value: 'todo', label: 'Todo' }, + { value: 'in-progress', label: 'In progress' }, + { value: 'done', label: 'Done' }, + ]), + el('div.row', {}, + this.dom.hiddenToggle = el('label', {}, el('input', { type: 'checkbox' }), ' Hidden'), + this.dom.skippedToggle = el('label', {}, el('input', { type: 'checkbox' }), ' Skipped'), + ), + el('div.row', {}, + this.dom.forceNewPageToggle = el('label', {}, el('input', { type: 'checkbox' }), ' New page'), + this.dom.focusedViewToggle = el('label', {}, el('input', { type: 'checkbox' }), ' Focused'), + ), + ), + el('section', {}, + el('h3', {}, 'Description'), + this.dom.richToolbar = el('div.rich-toolbar', {}, + this.toolbarBtn('bold', 'Bold'), + this.toolbarBtn('italic', 'Italic'), + this.toolbarBtn('insertUnorderedList', 'Bullet'), + this.toolbarBtn('insertOrderedList', 'Number'), + this.toolbarBtn('formatBlock', 'Quote', 'blockquote'), + this.toolbarBtn('createLink', 'Link'), + this.toolbarBtn('removeFormat', 'Clear'), + ), + this.dom.descEditor = el('div.rich-editor', { contentEditable: 'true', spellcheck: true }), + ), + el('section', {}, + el('h3', {}, 'Annotations'), + this.dom.annotationList = el('div', { className: 'annotation-list' }), + this.dom.annotationEditor = el('div', { className: 'annotation-editor' }), + ), + el('section', {}, + el('h3', {}, 'Guide'), + this.dom.guideSummary = el('div.muted', {}), + this.dom.linkedBtn = el('button', { type: 'button' }, 'Linked guide'), + this.dom.saveNowBtn = el('button.primary', { type: 'button' }, 'Save now'), + this.dom.snapshotBtn = el('button', { type: 'button' }, 'Snapshot'), + ), + ), + ); + this.root.append(this.dom.root); + + // canvas interactions need to snapshot the current step before the drag + // mutates it, so undo can restore the pre-edit annotations. + this.dom.canvas.addEventListener('pointerdown', () => { + if (this.currentStep) this.beforeCanvasSnapshot = clone(this.currentStep); + }, true); + + this.canvas = new AnnotationCanvas(this.dom.canvas, { + onChange: (annotations) => this.onCanvasChange(annotations), + onSelect: (ann) => this.onCanvasSelect(ann), + onCrop: (rect) => this.onCanvasCrop(rect), + onRequestText: (ann) => this.editAnnotationText(ann), + defaultStyle: (tool) => this.defaultStyleForTool(tool), + nextNumber: () => this.nextAnnotationNumber(), + }); + + this.resizeObserver = new ResizeObserver(() => this.canvas.applyZoom()); + this.resizeObserver.observe(this.dom.canvasWrap); + + this.bindShellEvents(); + } + + toolbarBtn(action, label, block = null) { + return el('button', { + type: 'button', + onClick: () => this.formatDescription(action, block), + }, label); + } + + bindShellEvents() { + this.dom.addStepBtn.addEventListener('click', () => this.addEmptyStep()); + this.dom.importBtn.addEventListener('click', () => this.importImageSteps()); + this.dom.moveUpBtn.addEventListener('click', () => this.moveSelectedStep(-1)); + this.dom.moveDownBtn.addEventListener('click', () => this.moveSelectedStep(1)); + this.dom.duplicateBtn.addEventListener('click', () => this.duplicateSelectedStep()); + this.dom.deleteBtn.addEventListener('click', () => this.deleteSelectedStep()); + this.dom.saveNowBtn.addEventListener('click', () => this.saveAll()); + this.dom.snapshotBtn.addEventListener('click', () => this.createSnapshot()); + this.dom.linkedBtn.addEventListener('click', () => this.openLinkedGuide()); + this.dom.zoomFitBtn.addEventListener('click', () => this.setZoom('fit')); + this.dom.zoom100Btn.addEventListener('click', () => this.setZoom(1)); + this.dom.zoom125Btn.addEventListener('click', () => this.setZoom(1.25)); + this.dom.zoom150Btn.addEventListener('click', () => this.setZoom(1.5)); + this.dom.undoBtn.addEventListener('click', () => this.undo()); + this.dom.redoBtn.addEventListener('click', () => this.redo()); + + Object.entries(this.dom).forEach(([key, value]) => { + if (key.startsWith('tool-')) { + value.addEventListener('click', () => this.setTool(value.dataset.tool)); + } + }); + + this.dom.titleInput.addEventListener('focus', () => { + if (this.currentStep) this.pushCanvasHistory('title'); + }); + this.dom.titleInput.addEventListener('input', () => { + if (!this.currentStep) return; + this.currentStep.title = this.dom.titleInput.value; + this.pendingSave = true; + this.saveStepDebounced(); + this.renderStepList(); + this.emitMeta(); + }); + + this.dom.statusSelect.addEventListener('change', () => { + if (!this.currentStep) return; + this.currentStep.status = this.dom.statusSelect.value; + this.pendingSave = true; + this.saveStepDebounced(); + this.renderStepList(); + this.emitMeta(); + }); + + const bindCheckbox = (node, field) => node.addEventListener('change', () => { + if (!this.currentStep) return; + this.currentStep[field] = node.checked; + this.pendingSave = true; + this.saveStepDebounced(); + this.renderStepList(); + this.emitMeta(); + }); + 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'); + + this.dom.descEditor.addEventListener('focus', () => { + if (this.currentStep) this.pushCanvasHistory('description'); + }); + this.dom.descEditor.addEventListener('input', () => { + if (!this.currentStep) return; + this.currentStep.descriptionHtml = this.dom.descEditor.innerHTML; + this.pendingSave = true; + this.saveStepDebounced(); + this.emitMeta(); + }); + + this.dom.descEditor.addEventListener('paste', (e) => { + // Keep pasted text simple; backend sanitization will handle the rest. + e.preventDefault(); + const text = e.clipboardData.getData('text/plain'); + document.execCommand('insertText', false, text); + }); + + this.dom.annotationList.addEventListener('click', (e) => { + const item = e.target.closest('[data-ann-id]'); + if (!item) return; + this.canvas.select(item.dataset.annId); + }); + } + + renderAll() { + this.renderStepList(); + this.syncStepFields(); + this.renderCanvas(); + this.renderAnnotationPanel(); + this.renderGuidePanel(); + this.emitMeta(); + } + + renderStepList() { + const current = this.currentStep; + const numbers = stepNumberMap(this.steps); + clearNode(this.dom.stepsList); + this.dom.stepCount.textContent = `${this.steps.length} step${this.steps.length === 1 ? '' : 's'}`; + for (const step of this.steps) { + const number = numbers.get(step.stepId) || ''; + let depth = 0; + let parent = step.parentStepId; + while (parent && this.stepMap.has(parent)) { + depth += 1; + parent = this.stepMap.get(parent).parentStepId; + } + const selected = current && current.stepId === step.stepId; + const item = el('div.step-item', { + className: `step-item${selected ? ' selected' : ''}${depth ? ' sub' : ''}${step.skipped ? ' skipped' : ''}${step.hidden ? ' hiddenstep' : ''}`, + dataset: { stepId: step.stepId }, + onClick: () => this.selectStep(step.stepId), + onContextMenu: (e) => { + e.preventDefault(); + this.selectStep(step.stepId); + contextMenu(e.clientX, e.clientY, [ + { label: 'Add substep', action: () => this.addSubstep(step.stepId) }, + { label: 'Duplicate step', action: () => this.duplicateSelectedStep() }, + 'sep', + { label: 'Move up', action: () => this.moveSelectedStep(-1) }, + { label: 'Move down', action: () => this.moveSelectedStep(1) }, + 'sep', + { label: 'Delete step', danger: true, action: () => this.deleteSelectedStep() }, + ]); + }, + }, + el('span.status-dot', { className: `status-dot status-${step.status}` }), + el('span.num', {}, number || '•'), + el('span.t', {}, step.title || 'Untitled step'), + el('span.flags', {}, [ + step.parentStepId ? 'sub' : '', + step.hidden ? 'hidden' : '', + step.skipped ? 'skipped' : '', + ].filter(Boolean).join(' · '))); + this.dom.stepsList.append(item); + } + if (!this.steps.length) { + this.dom.stepsList.append(el('div.empty-state', { style: { marginTop: '40px' } }, 'No steps yet.')); + } + } + + syncStepFields() { + const step = this.currentStep; + const guide = this.guide; + if (!step) { + this.dom.titleInput.value = ''; + this.dom.descEditor.innerHTML = ''; + this.dom.statusSelect.value = 'todo'; + this.dom.hiddenToggle.querySelector('input').checked = false; + this.dom.skippedToggle.querySelector('input').checked = false; + this.dom.forceNewPageToggle.querySelector('input').checked = false; + this.dom.focusedViewToggle.querySelector('input').checked = false; + this.dom.guideSummary.textContent = guide ? guide.title : ''; + return; + } + if (document.activeElement !== this.dom.titleInput) this.dom.titleInput.value = step.title || ''; + if (document.activeElement !== this.dom.descEditor) this.dom.descEditor.innerHTML = step.descriptionHtml || ''; + this.dom.statusSelect.value = step.status || 'todo'; + this.dom.hiddenToggle.querySelector('input').checked = Boolean(step.hidden); + this.dom.skippedToggle.querySelector('input').checked = Boolean(step.skipped); + this.dom.forceNewPageToggle.querySelector('input').checked = Boolean(step.forceNewPage); + this.dom.focusedViewToggle.querySelector('input').checked = Boolean(step.focusedView?.enabled); + this.dom.guideSummary.textContent = guide + ? `${guide.title} · ${guide.linkedSource ? 'linked' : 'local'} · ${this.steps.length} steps` + : ''; + } + + async renderCanvas() { + const step = this.currentStep; + const token = ++this.imageLoadToken; + this.canvas.setTool(this.currentTool); + this.canvas.setZoom(this.currentZoom); + if (!step || !step.image) { + this.canvas.setImage(null, 0, 0); + this.dom.canvasEmpty.classList.remove('hidden'); + return; + } + this.dom.canvasEmpty.classList.add('hidden'); + const src = await api.step.imagePath({ + guideId: this.guideId, + stepId: step.stepId, + which: 'working', + }); + if (token !== this.imageLoadToken || !src) return; + const img = new Image(); + img.onload = () => { + if (token !== this.imageLoadToken) return; + this.canvas.setImage(img, img.naturalWidth || img.width, img.naturalHeight || img.height); + this.canvas.setAnnotations(step.annotations || []); + this.canvas.setTool(this.currentTool); + this.canvas.setZoom(this.currentZoom); + }; + img.onerror = () => { + if (token !== this.imageLoadToken) return; + this.canvas.setImage(null, 0, 0); + this.dom.canvasEmpty.classList.remove('hidden'); + }; + img.src = src; + } + + renderAnnotationPanel() { + clearNode(this.dom.annotationList); + const step = this.currentStep; + if (!step) { + this.dom.annotationList.append(el('div.muted', {}, 'No step selected.')); + clearNode(this.dom.annotationEditor); + this.dom.annotationEditor.append(el('div.muted', {}, 'Select a step to edit annotations.')); + return; + } + + const anns = step.annotations || []; + if (!anns.length) { + this.dom.annotationList.append(el('div.muted', {}, 'No annotations yet. Pick a tool and drag on the canvas.')); + } else { + for (const ann of anns) { + const selected = this.canvas.selected() && this.canvas.selected().id === ann.id; + this.dom.annotationList.append(el('div.block-card', { + dataset: { annId: ann.id }, + style: { cursor: 'pointer', borderColor: selected ? 'var(--accent)' : '' }, + }, + el('div.row', {}, el('strong', {}, ann.type), el('span.muted', {}, ann.text || ann.value || '')), + el('div.muted', {}, `${ann.x.toFixed(3)}, ${ann.y.toFixed(3)} · ${ann.w.toFixed(3)} × ${ann.h.toFixed(3)}`))); + } + } + + const selected = this.canvas.selected(); + clearNode(this.dom.annotationEditor); + if (!selected) { + this.dom.annotationEditor.append(el('div.muted', {}, 'Select an annotation to edit its style.')); + return; + } + + const style = selected.style || {}; + const typeSelect = makeSelect(selected.type, [ + 'rect', 'oval', 'line', 'arrow', 'text', 'tooltip', 'number', 'blur', 'highlight', 'magnify', 'cursor', + ].map((type) => ({ value: type, label: type }))); + const textInput = el('input', { type: 'text', value: selected.text || '', placeholder: 'Annotation text' }); + const valueInput = el('input', { type: 'number', value: Number.isFinite(selected.value) ? selected.value : '', placeholder: 'Value' }); + const strokeInput = el('input', { type: 'color', value: style.stroke || '#E5484D' }); + const fillInput = el('input', { type: 'color', value: style.fill && style.fill !== 'transparent' ? style.fill : '#ffffff' }); + const strokeWidthInput = el('input', { type: 'number', min: 1, step: 1, value: style.strokeWidth || 3 }); + const fontSizeInput = el('input', { type: 'number', min: 0.01, step: 0.001, value: style.fontSize || 0.022 }); + const textColorInput = el('input', { type: 'color', value: style.textColor || '#ffffff' }); + const zoomInput = el('input', { type: 'number', min: 1, step: 0.1, value: selected.zoom || 2 }); + const radiusInput = el('input', { type: 'number', min: 1, step: 1, value: selected.radius || 8 }); + const tailInput = makeSelect(style.tail || 'bottom', [ + { value: 'bottom', label: 'Bottom' }, + { value: 'top', label: 'Top' }, + { value: 'left', label: 'Left' }, + { value: 'right', label: 'Right' }, + ]); + + const apply = async (patch) => { + const ann = this.canvas.selected(); + if (!ann) return; + Object.assign(ann, patch); + this.beforeCanvasSnapshot = null; + this.pendingSave = true; + this.canvas.setAnnotations(step.annotations || []); + this.canvas.select(ann.id); + await this.flushStep(); + this.renderAnnotationPanel(); + this.renderStepList(); + this.emitMeta(); + }; + + const annSection = el('div', { className: 'annotation-editor-inner' }, + labeledRow('Type', typeSelect), + labeledRow('Text', textInput), + labeledRow('Value', valueInput), + labeledRow('Stroke', strokeInput), + labeledRow('Fill', fillInput), + labeledRow('Stroke width', strokeWidthInput), + labeledRow('Font size', fontSizeInput), + labeledRow('Text color', textColorInput), + labeledRow('Zoom', zoomInput), + labeledRow('Radius', radiusInput), + labeledRow('Tail', tailInput), + el('div.row', {}, + el('button', { + type: 'button', + onClick: () => { + this.canvas.deleteSelected(); + }, + }, 'Delete annotation'), + ), + ); + this.dom.annotationEditor.append(annSection); + + typeSelect.addEventListener('change', async () => { + const ann = this.canvas.selected(); + if (!ann) return; + await apply({ type: typeSelect.value }); + if (ann.type === 'tooltip') this.editAnnotationText(ann); + }); + textInput.addEventListener('focus', () => this.pushCanvasHistory('annotation-text')); + textInput.addEventListener('input', async () => { + const ann = this.canvas.selected(); + if (!ann) return; + await apply({ text: textInput.value }); + }); + valueInput.addEventListener('input', async () => { + const ann = this.canvas.selected(); + if (!ann) return; + const next = valueInput.value === '' ? null : Number(valueInput.value); + await apply({ value: Number.isFinite(next) ? next : null }); + }); + strokeInput.addEventListener('input', async () => { + const ann = this.canvas.selected(); + if (!ann) return; + await apply({ style: { ...ann.style, stroke: strokeInput.value } }); + }); + fillInput.addEventListener('input', async () => { + const ann = this.canvas.selected(); + if (!ann) return; + await apply({ style: { ...ann.style, fill: fillInput.value } }); + }); + strokeWidthInput.addEventListener('input', async () => { + const ann = this.canvas.selected(); + if (!ann) return; + await apply({ style: { ...ann.style, strokeWidth: Number(strokeWidthInput.value || 1) } }); + }); + fontSizeInput.addEventListener('input', async () => { + const ann = this.canvas.selected(); + if (!ann) return; + await apply({ style: { ...ann.style, fontSize: Number(fontSizeInput.value || 0.022) } }); + }); + textColorInput.addEventListener('input', async () => { + const ann = this.canvas.selected(); + if (!ann) return; + await apply({ style: { ...ann.style, textColor: textColorInput.value } }); + }); + zoomInput.addEventListener('input', async () => { + const ann = this.canvas.selected(); + if (!ann) return; + await apply({ zoom: Number(zoomInput.value || 2) }); + }); + radiusInput.addEventListener('input', async () => { + const ann = this.canvas.selected(); + if (!ann) return; + await apply({ radius: Number(radiusInput.value || 8) }); + }); + tailInput.addEventListener('change', async () => { + const ann = this.canvas.selected(); + if (!ann) return; + await apply({ style: { ...ann.style, tail: tailInput.value } }); + }); + } + + renderGuidePanel() { + if (!this.guide) return; + this.dom.linkedBtn.textContent = this.guide.linkedSource ? 'Linked guide' : 'Local guide'; + this.dom.linkedBtn.disabled = !this.guide.linkedSource; + this.dom.snapshotBtn.textContent = 'Snapshot'; + } + + defaultStyleForTool(tool) { + switch (tool) { + case 'highlight': return { fill: '#ffe066', stroke: '#ffbf00', strokeWidth: 1 }; + case 'tooltip': return { fill: '#111827', textColor: '#ffffff', stroke: '#111827', tail: 'bottom' }; + case 'number': return { fill: '#1f6feb', stroke: '#1f6feb', textColor: '#ffffff' }; + case 'blur': return { fill: 'transparent', stroke: '#9ca3af', strokeWidth: 2 }; + case 'cursor': return { fill: '#ffffff', stroke: '#111827', strokeWidth: 2 }; + default: return { fill: 'transparent', stroke: '#E5484D', strokeWidth: 3, textColor: '#ffffff' }; + } + } + + nextAnnotationNumber() { + const step = this.currentStep; + if (!step) return 1; + const nums = (step.annotations || []).filter((ann) => ann.type === 'number').map((ann) => Number(ann.value) || 0); + return (nums.length ? Math.max(...nums) : 0) + 1; + } + + setTool(tool) { + this.currentTool = tool; + this.canvas.setTool(tool); + for (const [key, node] of Object.entries(this.dom)) { + if (!key.startsWith('tool-')) continue; + node.classList.toggle('active', node.dataset.tool === tool); + } + } + + setZoom(mode) { + this.currentZoom = mode; + this.canvas.setZoom(mode); + this.canvas.applyZoom(); + const buttons = [this.dom.zoomFitBtn, this.dom.zoom100Btn, this.dom.zoom125Btn, this.dom.zoom150Btn]; + buttons.forEach((btn) => btn.classList.remove('active')); + if (mode === 'fit') this.dom.zoomFitBtn.classList.add('active'); + if (mode === 1) this.dom.zoom100Btn.classList.add('active'); + if (mode === 1.25) this.dom.zoom125Btn.classList.add('active'); + if (mode === 1.5) this.dom.zoom150Btn.classList.add('active'); + } + + pushCanvasHistory(label = 'change') { + if (!this.currentStep) return; + this.canvasHistory.push(clone(this.currentStep)); + if (this.canvasHistory.length > 40) this.canvasHistory.shift(); + this.canvasFuture.length = 0; + this.beforeCanvasSnapshot = null; + } + + async undo() { + if (!this.currentStep) return; + if (!this.canvasHistory.length) { + this.onToast('Nothing to undo.'); + return; + } + this.canvasFuture.push(clone(this.currentStep)); + const previous = this.canvasHistory.pop(); + this.stepMap.set(previous.stepId, previous); + this.selectedStepId = previous.stepId; + await this.flushStep(previous); + this.renderAll(); + } + + async redo() { + if (!this.currentStep) return; + if (!this.canvasFuture.length) { + this.onToast('Nothing to redo.'); + return; + } + this.canvasHistory.push(clone(this.currentStep)); + const next = this.canvasFuture.pop(); + this.stepMap.set(next.stepId, next); + this.selectedStepId = next.stepId; + await this.flushStep(next); + this.renderAll(); + } + + async flushStep(step = this.currentStep) { + if (!step) return; + this.pendingSave = false; + const saved = await api.step.save({ guideId: this.guideId, step }); + this.stepMap.set(saved.stepId, saved); + if (this.selectedStepId === saved.stepId) { + this.stepMap.set(saved.stepId, saved); + this.renderStepList(); + this.syncStepFields(); + this.canvas.setAnnotations(saved.annotations || []); + this.renderAnnotationPanel(); + this.emitMeta(); + } + return saved; + } + + async flushGuide() { + if (!this.guide) return; + this.pendingGuideSave = false; + await api.guide.save({ guide: this.guide }); + this.emitMeta(); + } + + async saveAll() { + if (this.currentStep) await this.flushStep(); + if (this.guide) await this.flushGuide(); + this.onToast('Saved.'); + } + + async createSnapshot() { + if (!this.guideId) return; + await api.snapshots.create({ guideId: this.guideId, label: 'manual' }); + this.onToast('Snapshot created.'); + } + + async selectStep(stepId) { + if (!this.stepMap.has(stepId)) return; + this.selectedStepId = stepId; + this.selectedAnnotationId = null; + this.canvas.select(null); + this.syncStepFields(); + this.renderStepList(); + this.renderCanvas(); + this.renderAnnotationPanel(); + this.emitMeta(); + } + + async addEmptyStep() { + const title = await dialogs.promptText({ + title: 'Add Step', + label: 'Step title', + value: '', + placeholder: 'Untitled step', + }); + if (title == null) return; + const step = await api.step.add({ + guideId: this.guideId, + fields: { + kind: 'empty', + title: title.trim() || 'Untitled step', + status: 'todo', + }, + position: this.steps.length, + }); + await this.reload(step.stepId); + this.onToast('Step added.'); + } + + async addSubstep(parentStepId = this.selectedStepId) { + if (!parentStepId) return; + const title = await dialogs.promptText({ + title: 'Add Substep', + label: 'Substep title', + value: '', + placeholder: 'Untitled substep', + }); + if (title == null) return; + const parent = this.stepMap.get(parentStepId); + const parentIndex = this.steps.findIndex((s) => s.stepId === parentStepId); + const step = await api.step.add({ + guideId: this.guideId, + fields: { + kind: 'empty', + title: title.trim() || 'Untitled substep', + parentStepId, + status: 'todo', + }, + position: parentIndex + 1, + }); + await this.reload(step.stepId); + this.onToast(parent ? 'Substep added.' : 'Step added.'); + } + + async duplicateSelectedStep() { + const step = this.currentStep; + if (!step) return; + const copy = clone(step); + copy.stepId = undefined; + copy.title = copy.title ? `${copy.title} copy` : 'Untitled step copy'; + const image = await this.currentStepImageToBase64(); + const newStep = await api.step.add({ + guideId: this.guideId, + fields: { + ...copy, + image: undefined, + }, + imageBase64: image ? image.base64 : null, + size: image ? image.size : null, + position: this.steps.findIndex((s) => s.stepId === step.stepId) + 1, + }); + await this.reload(newStep.stepId); + this.onToast('Step duplicated.'); + } + + async deleteSelectedStep() { + const step = this.currentStep; + if (!step) return; + const ok = await confirmDialog(`Delete “${step.title || 'Untitled step'}”?`, { danger: true, okLabel: 'Delete' }); + if (!ok) return; + await api.step.delete({ guideId: this.guideId, stepId: step.stepId }); + const next = this.steps[this.steps.findIndex((s) => s.stepId === step.stepId) + 1] + || this.steps[this.steps.findIndex((s) => s.stepId === step.stepId) - 1] + || null; + await this.reload(next && next.stepId); + this.onToast('Step deleted.'); + } + + async moveSelectedStep(delta) { + const step = this.currentStep; + if (!step) return; + const idx = this.steps.findIndex((s) => s.stepId === step.stepId); + const nextIdx = idx + delta; + if (nextIdx < 0 || nextIdx >= this.steps.length) return; + const order = this.steps.map((s) => s.stepId); + const [item] = order.splice(idx, 1); + order.splice(nextIdx, 0, item); + await api.step.reorder({ guideId: this.guideId, order }); + await this.reload(step.stepId); + } + + async importImageSteps() { + const result = await api.step.importImage({ guideId: this.guideId }); + if (!result || !result.ok) return; + const last = result.steps && result.steps[result.steps.length - 1]; + await this.reload(last ? last.stepId : this.selectedStepId); + this.onToast('Images imported.'); + } + + async captureStep(mode) { + const result = await api.capture.shoot({ guideId: this.guideId, mode, delayMs: 0 }); + if (result && result.ok) { + await this.reload(result.step.stepId); + this.onToast('Captured.'); + } else if (result && result.reason) { + this.onToast(result.reason, { error: true }); + } + } + + async startCaptureSession() { + await api.capture.session({ action: 'start', guideId: this.guideId }); + this.onToast('Capture session started.'); + this.emitMeta(); + } + + async pauseCaptureSession() { + await api.capture.session({ action: 'pause', guideId: this.guideId }); + this.onToast('Capture paused.'); + this.emitMeta(); + } + + async resumeCaptureSession() { + await api.capture.session({ action: 'resume', guideId: this.guideId }); + this.onToast('Capture resumed.'); + this.emitMeta(); + } + + async finishCaptureSession() { + await api.capture.session({ action: 'finish', guideId: this.guideId }); + this.onToast('Capture session finished.'); + this.emitMeta(); + } + + async openSettings() { + const settings = await api.settings.all(); + const placeholders = await api.settings.globalPlaceholders(); + await dialogs.showSettingsDialog({ + settings, + placeholders, + onSave: async (next) => { + await api.settings.set({ keyPath: 'appearance', value: next.appearance }); + await api.settings.set({ keyPath: 'spellcheck', value: next.spellcheck }); + await api.settings.set({ keyPath: 'capture', value: next.capture }); + await api.settings.set({ keyPath: 'editor', value: next.editor }); + await api.settings.set({ keyPath: 'exports', value: next.exports }); + await api.settings.set({ keyPath: 'backups', value: next.backups }); + await api.settings.setGlobalPlaceholders(next.placeholders || {}); + }, + }); + } + + async openExportDialog() { + const formats = (await api.export.formats()).map((id) => ({ id, label: id.replace(/-/g, ' ') })); + const templatesByFormat = {}; + for (const format of formats) { + templatesByFormat[format.id] = await api.templates.list({ format: format.id }); + } + const settings = await api.settings.all(); + await dialogs.showExportDialog({ + formats, + templatesByFormat, + defaultFormat: 'pdf', + defaultOutDir: settings.exports?.lastOutputDirs?.pdf || '', + onChooseDir: async (format) => api.export.chooseDir({ format }), + onPreview: async ({ format, templateName, outDir }) => { + const options = templateName ? await api.templates.load({ format, name: templateName }) : {}; + const preview = await api.export.preview({ guideId: this.guideId, format, options }); + if (preview && preview.file) await api.shell.showItemInFolder({ target: preview.file }); + this.onToast(`Preview written to ${preview.file}`); + return true; + }, + onExport: async ({ format, templateName, outDir }) => { + const options = templateName ? await api.templates.load({ format, name: templateName }) : {}; + const result = await api.export.run({ guideId: this.guideId, format, options, outDir }); + if (result && result.file) { + this.onToast(`Exported ${format}`); + } + return true; + }, + }); + } + + async openLinkedGuide() { + if (!this.guide || !this.guide.linkedSource) { + await dialogs.showInfoDialog('Linked Guide', 'This guide is stored locally and is not linked to a shared archive.'); + return; + } + const library = await api.library.list(); + const guideMeta = library.guides.find((g) => g.guideId === this.guideId) || this.guide; + const locked = Boolean(guideMeta.locked); + await dialogs.showLinkedGuideDialog({ + guide: guideMeta, + lock: locked ? { acquired: false } : { acquired: true }, + onSave: async () => { + const result = await api.archive.saveLinked({ guideId: this.guideId, force: false }); + if (result.saved) this.onToast('Linked archive saved.'); + else this.onToast('Could not save linked archive.', { error: true }); + }, + onForceSave: async () => { + const result = await api.archive.saveLinked({ guideId: this.guideId, force: true }); + if (result.saved) this.onToast('Linked archive force-saved.'); + else this.onToast('Could not save linked archive.', { error: true }); + }, + onOpenArchive: async () => { + await api.shell.showItemInFolder({ target: this.guide.linkedSource.path }); + }, + }); + } + + async openQuickActions() { + const commands = [ + { kind: 'cmd', label: 'New guide', description: 'Create a blank guide', action: () => this.onBack('new') }, + { kind: 'cmd', label: 'Export', description: 'Export the current guide', action: () => this.openExportDialog() }, + { kind: 'cmd', label: 'Settings', description: 'Open application settings', action: () => this.openSettings() }, + { kind: 'cmd', label: 'Linked guide', description: 'Show linked archive details', action: () => this.openLinkedGuide() }, + { kind: 'cmd', label: 'Start capture session', description: 'Enable hotkey capture for this guide', action: () => this.startCaptureSession() }, + ]; + + await dialogs.showQuickActions({ + commands, + searchFn: async (query) => { + const results = await api.search.query({ q: query }); + return results.map((r) => ({ + kind: r.stepId ? 'step' : 'guide', + label: r.title || '(untitled)', + description: r.snippet || '', + action: () => this.openSearchResult(r), + })); + }, + }); + } + + async openSearchResult(result) { + if (!result) return; + if (result.stepId) { + await this.onBack(); + await this.open(result.guideId, result.stepId); + } else { + await this.onBack(); + await this.open(result.guideId, null); + } + } + + async currentStepImageToBase64() { + const step = this.currentStep; + if (!step || !step.image) return null; + const file = await api.step.imagePath({ guideId: this.guideId, stepId: step.stepId, which: 'working' }); + if (!file) return null; + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = img.naturalWidth || img.width; + canvas.height = img.naturalHeight || img.height; + const ctx = canvas.getContext('2d'); + ctx.drawImage(img, 0, 0); + const data = canvas.toDataURL('image/png').split(',')[1]; + resolve({ base64: data, size: { width: canvas.width, height: canvas.height } }); + }; + img.onerror = () => resolve(null); + img.src = file; + }); + } + + async onCanvasChange(annotations) { + const step = this.currentStep; + if (!step) return; + if (this.beforeCanvasSnapshot) { + this.canvasHistory.push(this.beforeCanvasSnapshot); + if (this.canvasHistory.length > 40) this.canvasHistory.shift(); + this.canvasFuture.length = 0; + this.beforeCanvasSnapshot = null; + } + step.annotations = clone(annotations || []); + this.pendingSave = true; + this.saveStepDebounced(); + this.renderAnnotationPanel(); + this.renderStepList(); + this.emitMeta(); + } + + onCanvasSelect(ann) { + this.selectedAnnotationId = ann ? ann.id : null; + this.renderAnnotationPanel(); + this.emitMeta(); + } + + async onCanvasCrop(rect) { + const step = this.currentStep; + if (!step || !step.image) return; + const ok = await confirmDialog('Crop the working image to the selected area?'); + if (!ok) return; + const src = await api.step.imagePath({ guideId: this.guideId, stepId: step.stepId, which: 'working' }); + if (!src) return; + const img = await loadImage(src); + if (!img) return; + const crop = rect; + const canvas = document.createElement('canvas'); + canvas.width = Math.max(1, Math.round(crop.w * img.naturalWidth)); + canvas.height = Math.max(1, Math.round(crop.h * img.naturalHeight)); + const ctx = canvas.getContext('2d'); + ctx.drawImage( + img, + Math.round(crop.x * img.naturalWidth), + Math.round(crop.y * img.naturalHeight), + Math.round(crop.w * img.naturalWidth), + Math.round(crop.h * img.naturalHeight), + 0, + 0, + canvas.width, + canvas.height, + ); + const nextAnnotations = (step.annotations || []).map((ann) => { + const next = clone(ann); + next.x = (ann.x - crop.x) / crop.w; + next.y = (ann.y - crop.y) / crop.h; + next.w = ann.w / crop.w; + next.h = ann.h / crop.h; + return next; + }); + await api.step.setWorkingImage({ + guideId: this.guideId, + stepId: step.stepId, + pngBase64: canvas.toDataURL('image/png').split(',')[1], + size: { width: canvas.width, height: canvas.height }, + }); + step.image.size = { width: canvas.width, height: canvas.height }; + step.annotations = nextAnnotations; + await this.flushStep(step); + await this.reload(step.stepId); + this.onToast('Image cropped.'); + } + + async editAnnotationText(ann) { + const step = this.currentStep; + if (!step || !ann) return; + const value = await dialogs.promptText({ + title: ann.type === 'tooltip' ? 'Edit tooltip' : 'Edit text', + label: 'Text', + value: ann.text || '', + multiline: true, + }); + if (value == null) return; + ann.text = value; + step.annotations = clone(step.annotations || []); + this.pendingSave = true; + await this.flushStep(step); + this.renderAnnotationPanel(); + this.emitMeta(); + } + + formatDescription(command, block = null) { + const editor = this.dom.descEditor; + editor.focus(); + switch (command) { + case 'bold': + document.execCommand('bold'); + break; + case 'italic': + document.execCommand('italic'); + break; + case 'insertUnorderedList': + document.execCommand('insertUnorderedList'); + break; + case 'insertOrderedList': + document.execCommand('insertOrderedList'); + break; + case 'formatBlock': + document.execCommand('formatBlock', false, block || 'blockquote'); + break; + case 'createLink': { + const url = window.prompt('Link URL'); + if (url) document.execCommand('createLink', false, url); + break; + } + case 'removeFormat': + document.execCommand('removeFormat'); + break; + default: + break; + } + if (this.currentStep) { + this.currentStep.descriptionHtml = editor.innerHTML; + this.pendingSave = true; + this.saveStepDebounced(); + } + } + + onDocumentKeyDown(e) { + if (!this.active || !this.guide) return; + if ((e.ctrlKey || e.metaKey) && e.key === '/' && !e.shiftKey) { + e.preventDefault(); + this.openQuickActions(); + return; + } + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') { + e.preventDefault(); + this.saveAll(); + return; + } + if (e.key === 'Escape' && !isEditableTarget(e.target)) { + if (this.selectedAnnotationId && this.canvas.deleteSelected()) { + e.preventDefault(); + this.saveStepDebounced(); + return; + } + } + if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z' && !isEditableTarget(e.target)) { + e.preventDefault(); + if (e.shiftKey) this.redo(); + else this.undo(); + return; + } + if (!isEditableTarget(e.target)) { + if (e.key === 'Delete' && this.selectedAnnotationId) { + e.preventDefault(); + if (this.canvas.deleteSelected()) this.saveStepDebounced(); + return; + } + if (e.key === 'ArrowUp' && e.altKey) { + e.preventDefault(); + this.moveSelectedStep(-1); + return; + } + if (e.key === 'ArrowDown' && e.altKey) { + e.preventDefault(); + this.moveSelectedStep(1); + 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; + if (dx || dy) { + const moved = this.canvas.nudgeSelected(dx, dy); + if (moved) { + const step = this.currentStep; + if (step) { + step.annotations = clone(this.canvas.annotations || []); + this.pendingSave = true; + this.saveStepDebounced(); + } + e.preventDefault(); + } + } + } + } + } +} + +function labeledRow(labelText, control) { + return el('div.form-row', {}, el('label', {}, labelText), control); +} + +function makeSelect(value, options) { + return el('select', {}, options.map((opt) => el('option', { value: opt.value, selected: opt.value === value }, opt.label))); +} + +function loadImage(src) { + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = () => resolve(null); + img.src = src; + }); +} + +window.GuideEditor = GuideEditor; diff --git a/app/renderer/index.html b/app/renderer/index.html new file mode 100644 index 0000000..2dfbeb9 --- /dev/null +++ b/app/renderer/index.html @@ -0,0 +1,29 @@ + + + + + +StepForge + + + +
+
+
StepForge
+
+
+ + +
+
+
+ +
+ + + + + + + diff --git a/app/renderer/region.html b/app/renderer/region.html new file mode 100644 index 0000000..bad33ce --- /dev/null +++ b/app/renderer/region.html @@ -0,0 +1,41 @@ + + + + +Select region + + + +
Drag to select a region — Esc to cancel
+
+ + + diff --git a/app/renderer/style.css b/app/renderer/style.css new file mode 100644 index 0000000..7ffddca --- /dev/null +++ b/app/renderer/style.css @@ -0,0 +1,673 @@ +/* StepForge UI + * Minimal desktop shell with a clean, Logitech-like surface: + * neutral panels, subtle borders, blue accent, and generous spacing. + */ + +:root { + --bg: #f4f6f8; + --panel: rgba(255, 255, 255, 0.92); + --panel-solid: #ffffff; + --panel-2: #eef2f6; + --text: #18212b; + --muted: #657181; + --border: #d9e1e8; + --accent: #0068ff; + --accent-strong: #0054cc; + --accent-fg: #ffffff; + --danger: #c52d2d; + --warn: #ffe7b7; + --shadow: 0 14px 40px rgba(15, 23, 42, 0.08); + --radius: 14px; +} + +@media (prefers-color-scheme: dark) { + :root { + --bg: #0f141b; + --panel: rgba(20, 27, 35, 0.94); + --panel-solid: #141b23; + --panel-2: #1b2430; + --text: #e7eef7; + --muted: #9ba8b7; + --border: #273241; + --accent: #3b8cff; + --accent-strong: #69a1ff; + --accent-fg: #08101a; + --danger: #ff6b6b; + --warn: #4a3410; + --shadow: 0 14px 40px rgba(0, 0, 0, 0.35); + } +} + +* { box-sizing: border-box; } +html, body { margin: 0; height: 100%; } +body { + font: 13px/1.45 "Segoe UI Variable", "Segoe UI", system-ui, -apple-system, sans-serif; + color: var(--text); + background: + radial-gradient(circle at top left, rgba(0, 104, 255, 0.05), transparent 34%), + linear-gradient(180deg, var(--bg), color-mix(in srgb, var(--bg) 88%, #000 12%)); + overflow: hidden; +} + +*::selection { background: rgba(0, 104, 255, 0.2); } + +#app { display: flex; flex-direction: column; height: 100vh; } +#view { flex: 1; min-height: 0; display: flex; } +.hidden { display: none !important; } +.muted { color: var(--muted); font-size: 12px; } + +button { + font: inherit; + color: var(--text); + background: var(--panel-solid); + border: 1px solid var(--border); + border-radius: 10px; + padding: 6px 12px; + cursor: pointer; + white-space: nowrap; + transition: background 140ms ease, border-color 140ms ease, transform 120ms ease, box-shadow 140ms ease; +} +button:hover { background: var(--panel-2); } +button:active { transform: translateY(1px); } +button.primary { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-fg); +} +button.primary:hover { background: var(--accent-strong); border-color: var(--accent-strong); } +button.danger { color: var(--danger); } +button.icon { padding: 5px 9px; min-width: 34px; } +button.tool { + padding: 5px 10px; + border-color: transparent; + background: transparent; + color: var(--muted); +} +button.tool:hover { background: var(--panel-2); color: var(--text); } +button.tool.active { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-fg); +} + +input, select, textarea { + font: inherit; + color: var(--text); + background: var(--panel-solid); + border: 1px solid var(--border); + border-radius: 10px; + padding: 7px 10px; +} +input::placeholder, textarea::placeholder { color: color-mix(in srgb, var(--muted) 78%, transparent); } +input:focus, select:focus, textarea:focus, button:focus-visible { + outline: 2px solid color-mix(in srgb, var(--accent) 60%, white 40%); + outline-offset: 1px; +} +textarea { resize: vertical; } +label { user-select: none; } +kbd { + display: inline-flex; + align-items: center; + gap: 2px; + background: var(--panel-2); + border: 1px solid var(--border); + border-radius: 6px; + padding: 0 5px; + font: inherit; + font-size: 11px; +} + +#topbar { + display: flex; + align-items: center; + gap: 12px; + height: 56px; + padding: 0 16px; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--panel-solid) 78%, transparent); + backdrop-filter: blur(16px); +} +.brand { + font-weight: 650; + letter-spacing: 0.01em; + cursor: pointer; + user-select: none; +} +#topbar-context { display: flex; align-items: center; gap: 8px; min-width: 0; } +#topbar-context .muted { max-width: 44vw; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +#global-search { width: min(360px, 32vw); margin-left: auto; } +#capture-status { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 10px; + border-radius: 999px; + background: #b42318; + color: #fff; + font-size: 12px; +} +#capture-status button { + padding: 3px 8px; + border: 0; + background: rgba(255, 255, 255, 0.14); + color: #fff; +} + +.library, .editor { flex: 1; min-height: 0; display: flex; } + +.lib-side { + width: 248px; + min-width: 248px; + padding: 14px; + display: flex; + flex-direction: column; + gap: 6px; + border-right: 1px solid var(--border); + background: color-mix(in srgb, var(--panel-solid) 84%, transparent); + overflow-y: auto; +} +.lib-side h3, +.pane-props h3 { + margin: 12px 0 6px; + color: var(--muted); + font-size: 11px; + font-weight: 650; + letter-spacing: 0.07em; + text-transform: uppercase; +} +.lib-side .nav-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 10px; + cursor: pointer; + user-select: none; + color: var(--text); +} +.lib-side .nav-item:hover { background: var(--panel-2); } +.lib-side .nav-item.active { + background: color-mix(in srgb, var(--accent) 12%, var(--panel-solid)); + color: var(--accent-strong); +} +.lib-side .nav-item .count { + margin-left: auto; + color: var(--muted); + font-size: 11px; +} +.lib-main { + flex: 1; + min-width: 0; + overflow-y: auto; + padding: 22px 24px; +} +.lib-actions { + display: flex; + gap: 8px; + flex-wrap: wrap; + margin-bottom: 16px; +} +.guide-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); + gap: 12px; +} +.guide-card { + position: relative; + padding: 14px; + min-height: 116px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--panel); + box-shadow: var(--shadow); + cursor: pointer; +} +.guide-card:hover { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); } +.guide-card h4 { + margin: 0 0 8px; + font-size: 14px; + line-height: 1.25; + padding-right: 22px; + word-break: break-word; +} +.guide-card .meta { + display: flex; + gap: 8px; + flex-wrap: wrap; + color: var(--muted); + font-size: 12px; + margin-bottom: 10px; +} +.guide-card .badge { + display: inline-flex; + align-items: center; + padding: 2px 7px; + border-radius: 999px; + background: var(--panel-2); + color: var(--muted); + font-size: 10px; + letter-spacing: 0.03em; + text-transform: uppercase; +} +.guide-card .fav { + position: absolute; + top: 10px; + right: 10px; + opacity: 0.35; + font-size: 16px; + cursor: pointer; +} +.guide-card .fav.on { + opacity: 1; + color: #f5a524; +} +.guide-card .muted { font-size: 12px; } +.guide-card .snippet, +.qa-item .snippet { color: var(--muted); } + +.empty-state { + padding: 60px 20px; + text-align: center; + color: var(--muted); +} +.empty-state .big { font-size: 40px; margin-bottom: 10px; color: var(--text); } + +.pane-steps { + width: 270px; + min-width: 270px; + display: flex; + flex-direction: column; + min-height: 0; + background: color-mix(in srgb, var(--panel-solid) 86%, transparent); + border-right: 1px solid var(--border); +} +.pane-canvas { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + min-height: 0; + background: linear-gradient(180deg, color-mix(in srgb, var(--bg) 76%, white 24%), var(--bg)); +} +.pane-props { + width: 330px; + min-width: 330px; + padding: 14px; + overflow-y: auto; + border-left: 1px solid var(--border); + background: color-mix(in srgb, var(--panel-solid) 88%, transparent); +} +.pane-head, +.pane-foot { + display: flex; + align-items: center; + gap: 8px; + padding: 14px; +} +.pane-head { justify-content: space-between; border-bottom: 1px solid var(--border); } +.pane-foot { border-top: 1px solid var(--border); flex-wrap: wrap; } +.eyebrow { + color: var(--muted); + font-size: 11px; + font-weight: 650; + letter-spacing: 0.06em; + text-transform: uppercase; +} + +.steps-list { + flex: 1; + min-height: 0; + overflow-y: auto; + padding: 10px; +} +.step-item { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + margin-bottom: 4px; + border: 1px solid transparent; + border-radius: 12px; + cursor: pointer; +} +.step-item:hover { background: var(--panel-2); } +.step-item.selected { + border-color: color-mix(in srgb, var(--accent) 42%, var(--border)); + background: color-mix(in srgb, var(--accent) 10%, var(--panel-solid)); +} +.step-item.sub { margin-left: 18px; } +.step-item .num { + min-width: 28px; + color: var(--muted); + font-weight: 650; +} +.step-item .t { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.step-item .flags { + font-size: 10px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.04em; +} +.step-item.skipped .t { text-decoration: line-through; opacity: 0.68; } +.step-item.hiddenstep .t { opacity: 0.5; } +.status-dot { width: 8px; height: 8px; border-radius: 999px; flex: none; } +.status-todo { background: #9aa7b8; } +.status-in-progress { background: #f0a500; } +.status-done { background: #22a06b; } + +.canvas-toolbar { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + padding: 10px 12px; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--panel-solid) 70%, transparent); +} +.canvas-toolbar .sep { + width: 1px; + height: 20px; + background: var(--border); + margin: 0 4px; +} +.canvas-wrap { + flex: 1; + min-height: 0; + display: grid; + place-items: center; + padding: 18px; + overflow: auto; + position: relative; +} +.canvas-wrap canvas { + background: #fff; + border-radius: 12px; + box-shadow: var(--shadow); + max-width: 100%; + max-height: 100%; +} +.canvas-empty { + position: absolute; + inset: 0; + display: grid; + place-items: center; + color: var(--muted); + pointer-events: none; +} + +.pane-props section + section { margin-top: 16px; } +.pane-props input[type="text"], +.pane-props input[type="number"], +.pane-props input[type="color"], +.pane-props select, +.pane-props textarea, +.pane-props .rich-editor { + width: 100%; +} +.pane-props .row, +.form-row { + display: flex; + align-items: center; + gap: 8px; +} +.pane-props .row > label { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} +.form-row { + gap: 10px; + margin-bottom: 8px; +} +.form-row > label:first-child { + width: 160px; + flex: none; + color: var(--muted); +} +.form-row.stacked { + flex-direction: column; + align-items: stretch; +} +.form-row.stacked > label:first-child { + width: auto; + margin-bottom: 4px; +} + +.rich-toolbar { + display: flex; + gap: 4px; + flex-wrap: wrap; + margin-bottom: 8px; +} +.rich-toolbar button { padding: 4px 8px; font-size: 12px; } +.rich-editor { + min-height: 110px; + max-height: 220px; + overflow-y: auto; + padding: 10px 11px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--panel-solid); +} +.rich-editor:focus { outline: 2px solid color-mix(in srgb, var(--accent) 60%, white 40%); } +.rich-editor table, +.rich-editor th, +.rich-editor td { + border: 1px solid var(--border); + border-collapse: collapse; + padding: 2px 8px; +} +.rich-editor pre { + padding: 8px; + border-radius: 10px; + background: var(--panel-2); +} + +.block-card, +.annotation-editor-inner { + display: flex; + flex-direction: column; + gap: 8px; +} +.block-card { + border: 1px solid var(--border); + border-radius: 12px; + padding: 10px; + background: var(--panel-solid); +} +.annotation-list { + display: flex; + flex-direction: column; + gap: 8px; +} +.annotation-editor .form-row > label:first-child { + width: 118px; +} + +fieldset { + margin: 0; + padding: 12px; + border: 1px solid var(--border); + border-radius: 14px; + background: var(--panel); +} +fieldset legend { + padding: 0 6px; + color: var(--muted); + font-size: 12px; + font-weight: 650; +} + +.settings-form { + display: flex; + flex-direction: column; + gap: 12px; +} +.placeholder-rows { + display: flex; + flex-direction: column; + gap: 8px; +} +.placeholder-row { + display: grid; + grid-template-columns: 1fr 1fr auto; + gap: 8px; +} +.placeholder-row input { width: 100%; } + +.quick-actions { + width: min(760px, 92vw); +} +.quick-actions input { + width: 100%; + font-size: 15px; + padding: 11px 12px; +} +.qa-results { + margin-top: 10px; + max-height: 360px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 4px; + padding-top: 8px; + border-top: 1px solid var(--border); +} +.qa-item { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 9px 10px; + border-radius: 10px; + cursor: pointer; +} +.qa-item:hover, +.qa-item.active { + background: var(--panel-2); +} +.qa-item .kind { + flex: none; + margin-top: 2px; + padding: 2px 6px; + border-radius: 999px; + background: var(--panel-2); + color: var(--muted); + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.export-dialog, +.linked-guide, +.card-list { + display: flex; + flex-direction: column; + gap: 10px; +} +.warn-banner { + padding: 10px 12px; + border-radius: 12px; + background: var(--warn); + color: var(--text); +} + +#modal-root:not(:empty) { + position: fixed; + inset: 0; + z-index: 50; + display: grid; + place-items: center; + background: rgba(10, 15, 20, 0.42); +} +.modal { + width: min(720px, 92vw); + max-height: 88vh; + display: flex; + flex-direction: column; + background: var(--panel-solid); + border: 1px solid var(--border); + border-radius: 18px; + box-shadow: var(--shadow); + overflow: hidden; +} +.modal.wide { width: min(1020px, 96vw); } +.modal header { + display: flex; + align-items: center; + gap: 10px; + padding: 14px 18px; + border-bottom: 1px solid var(--border); + font-weight: 650; +} +.modal header .close { + margin-left: auto; + color: var(--muted); + cursor: pointer; +} +.modal .body { + padding: 16px 18px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 10px; +} +.modal footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 18px; + border-top: 1px solid var(--border); +} + +#toast-root { + position: fixed; + right: 18px; + bottom: 18px; + z-index: 80; + display: flex; + flex-direction: column; + gap: 8px; +} +.toast { + max-width: 360px; + padding: 10px 14px; + border-radius: 12px; + background: var(--text); + color: var(--panel-solid); + box-shadow: var(--shadow); +} +.toast.error { + background: var(--danger); + color: #fff; +} + +.ctx-menu { + position: fixed; + z-index: 70; + min-width: 190px; + padding: 5px; + background: var(--panel-solid); + border: 1px solid var(--border); + border-radius: 14px; + box-shadow: var(--shadow); +} +.ctx-menu .mi { + padding: 7px 10px; + border-radius: 9px; + cursor: pointer; +} +.ctx-menu .mi:hover { background: var(--panel-2); } +.ctx-menu .mi.danger { color: var(--danger); } +.ctx-menu hr { + margin: 6px 0; + border: 0; + border-top: 1px solid var(--border); +} + diff --git a/app/renderer/util.js b/app/renderer/util.js new file mode 100644 index 0000000..a4a9e3f --- /dev/null +++ b/app/renderer/util.js @@ -0,0 +1,142 @@ +'use strict'; + +/* Small DOM + app helpers shared by all renderer modules. */ + +/** Element builder: el('div.cls#id', {attrs/on*}, ...children) */ +function el(spec, props = {}, ...children) { + const [tag, ...rest] = spec.split(/(?=[.#])/); + const node = document.createElement(tag || 'div'); + for (const part of rest) { + if (part.startsWith('.')) node.classList.add(part.slice(1)); + if (part.startsWith('#')) node.id = part.slice(1); + } + for (const [key, value] of Object.entries(props || {})) { + if (key.startsWith('on') && typeof value === 'function') { + node.addEventListener(key.slice(2).toLowerCase(), value); + } else if (key === 'dataset') { + Object.assign(node.dataset, value); + } else if (key === 'style' && typeof value === 'object') { + Object.assign(node.style, value); + } else if (key in node && key !== 'list') { + node[key] = value; + } else { + node.setAttribute(key, value); + } + } + for (const child of children.flat()) { + if (child == null || child === false) continue; + node.append(child.nodeType ? child : document.createTextNode(String(child))); + } + return node; +} + +function clearNode(node) { + while (node.firstChild) node.removeChild(node.firstChild); +} + +function debounce(fn, ms) { + let t = null; + const wrapped = (...args) => { + clearTimeout(t); + t = setTimeout(() => fn(...args), ms); + }; + wrapped.flush = (...args) => { clearTimeout(t); fn(...args); }; + wrapped.cancel = () => clearTimeout(t); + return wrapped; +} + +function toast(message, { error = false, ms = 2600 } = {}) { + const root = document.getElementById('toast-root'); + const node = el('div.toast', { className: `toast${error ? ' error' : ''}` }, message); + root.append(node); + setTimeout(() => node.remove(), ms); +} + +/** Modal helper. Returns { close, node }. Esc and ✕ close it. */ +function openModal({ title, body, footer, wide = false, onClose }) { + const root = document.getElementById('modal-root'); + clearNode(root); + const close = () => { + clearNode(root); + document.removeEventListener('keydown', escHandler, true); + if (onClose) onClose(); + }; + const escHandler = (e) => { + if (e.key === 'Escape') { e.stopPropagation(); close(); } + }; + document.addEventListener('keydown', escHandler, true); + const modal = el('div.modal', { className: `modal${wide ? ' wide' : ''}` }, + el('header', {}, title, el('span.close', { onClick: close, title: 'Close (Esc)' }, '✕')), + el('div.body', {}, body), + footer ? el('footer', {}, footer) : null, + ); + modal.addEventListener('click', (e) => e.stopPropagation()); + root.append(modal); + root.onclick = close; + return { close, node: modal }; +} + +/** Simple confirm dialog returning a promise. */ +function confirmDialog(message, { danger = false, okLabel = 'OK' } = {}) { + return new Promise((resolve) => { + const { close } = openModal({ + title: 'Confirm', + body: el('div', {}, message), + footer: [ + el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'), + el('button', { + className: `primary${danger ? ' danger' : ''}`, + onClick: () => { close(); resolve(true); }, + }, okLabel), + ], + onClose: () => resolve(false), + }); + }); +} + +function promptDialog(title, { value = '', label = 'Name' } = {}) { + return new Promise((resolve) => { + const input = el('input', { type: 'text', value }); + const done = (v) => { close(); resolve(v); }; + const { close } = openModal({ + title, + body: el('div.form-row', {}, el('label', {}, label), input), + footer: [ + el('button', { onClick: () => done(null) }, 'Cancel'), + el('button.primary', { onClick: () => done(input.value.trim() || null) }, 'OK'), + ], + onClose: () => resolve(null), + }); + input.addEventListener('keydown', (e) => { if (e.key === 'Enter') done(input.value.trim() || null); }); + setTimeout(() => input.focus(), 0); + }); +} + +/** Context menu at (x, y); items: [{label, danger, action}] or 'sep'. */ +function contextMenu(x, y, items) { + document.querySelectorAll('.ctx-menu').forEach((n) => n.remove()); + const menu = el('div.ctx-menu', { style: { left: `${x}px`, top: `${y}px` } }); + for (const item of items) { + if (item === 'sep') { menu.append(el('hr')); continue; } + menu.append(el('div.mi', { + className: `mi${item.danger ? ' danger' : ''}`, + onClick: () => { menu.remove(); item.action(); }, + }, item.label)); + } + document.body.append(menu); + const rect = menu.getBoundingClientRect(); + if (rect.right > innerWidth) menu.style.left = `${innerWidth - rect.width - 6}px`; + if (rect.bottom > innerHeight) menu.style.top = `${innerHeight - rect.height - 6}px`; + setTimeout(() => { + document.addEventListener('click', () => menu.remove(), { once: true }); + }, 0); +} + +function fmtDate(iso) { + if (!iso) return ''; + const d = new Date(iso); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; +} + +const escapeHtml = (s) => String(s ?? '') + .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); diff --git a/package.json b/package.json index e223e98..15e5f9a 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,12 @@ "license": "MPL-2.0", "private": true, "scripts": { - "start": "electron .", + "start": "node scripts/start-electron.js", "test": "node --test tests/unit/", + "sample": "node scripts/make-sample-guide.js", + "build": "bash scripts/build-release.sh", "verify": "bash scripts/verify.sh", - "sample": "node scripts/make-sample-guide.js" + "bootstrap": "bash scripts/bootstrap-offline.sh" }, "devDependencies": { "electron": "^41.7.1" diff --git a/scripts/bootstrap-offline.sh b/scripts/bootstrap-offline.sh new file mode 100644 index 0000000..8dd2dfd --- /dev/null +++ b/scripts/bootstrap-offline.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +for cmd in node npm tar; do + if ! command -v "$cmd" >/dev/null 2>&1; then + echo "Missing required tool: $cmd" >&2 + exit 1 + fi +done + +if command -v dpkg-deb >/dev/null 2>&1; then + echo "dpkg-deb available" +else + echo "dpkg-deb not available; Linux .deb packaging will be skipped" >&2 +fi + +node - <<'NODE' +const pkg = require('./package.json'); +console.log(`StepForge ${pkg.version} bootstrap OK`); +NODE diff --git a/scripts/build-release.sh b/scripts/build-release.sh new file mode 100644 index 0000000..76767bb --- /dev/null +++ b/scripts/build-release.sh @@ -0,0 +1,101 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" +BUILD_ROOT="${STEPFORGE_BUILD_DIR:-$ROOT_DIR/build}" +EXAMPLES_ROOT="${STEPFORGE_EXAMPLES_DIR:-$ROOT_DIR/examples}" +ARTIFACT_DIR="$BUILD_ROOT/artifacts" +REPORT_FILE="$BUILD_ROOT/build_report.md" +MANIFEST_FILE="$BUILD_ROOT/artifacts_manifest.json" + +mkdir -p "$BUILD_ROOT" + +bash "$ROOT_DIR/scripts/bootstrap-offline.sh" +node "$ROOT_DIR/scripts/make-sample-guide.js" --root "$EXAMPLES_ROOT" +STEPFORGE_PACKAGE_DIR="$ARTIFACT_DIR" bash "$ROOT_DIR/scripts/package-linux.sh" >/dev/null + +BUILD_ROOT="$BUILD_ROOT" \ +ARTIFACT_DIR="$ARTIFACT_DIR" \ +EXAMPLES_ROOT="$EXAMPLES_ROOT" \ +REPORT_FILE="$REPORT_FILE" \ +MANIFEST_FILE="$MANIFEST_FILE" \ +ROOT_DIR="$ROOT_DIR" \ +node - <<'NODE' +const fs = require('node:fs'); +const path = require('node:path'); +const crypto = require('node:crypto'); + +const buildRoot = process.env.BUILD_ROOT; +const artifactDir = process.env.ARTIFACT_DIR; +const examplesRoot = process.env.EXAMPLES_ROOT; +const reportFile = process.env.REPORT_FILE; +const manifestFile = process.env.MANIFEST_FILE; +const rootDir = process.env.ROOT_DIR; + +function walk(dir, base = dir, out = []) { + if (!fs.existsSync(dir)) return out; + for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) walk(abs, base, out); + else out.push(path.relative(base, abs)); + } + return out; +} + +function sha256(file) { + return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex'); +} + +const files = []; +for (const rel of walk(artifactDir, artifactDir)) { + const abs = path.join(artifactDir, rel); + files.push({ + kind: 'artifact', + path: path.relative(buildRoot, abs), + size: fs.statSync(abs).size, + sha256: sha256(abs), + }); +} +for (const rel of walk(examplesRoot, examplesRoot)) { + if (!rel.startsWith('sample-')) continue; + const abs = path.join(examplesRoot, rel); + files.push({ + kind: 'sample', + path: path.relative(buildRoot, abs), + size: fs.statSync(abs).size, + sha256: sha256(abs), + }); +} + +const pkg = require(path.join(rootDir, 'package.json')); +const report = `# StepForge Build Report + +Version: ${pkg.version} +Generated: ${new Date().toISOString()} + +## Outputs + +- Portable tarball: ${files.find((f) => f.path.endsWith('.tar.gz'))?.path || 'not generated'} +- Debian package: ${files.find((f) => f.path.endsWith('.deb'))?.path || 'not generated'} +- Sample guide archive: ${files.find((f) => f.path.endsWith('sample-guide.sfgz'))?.path || 'not generated'} + +## Notes + +- The desktop shell is Electron. +- Core storage, exports, and archive handling are local-only. +- Sample exports and package artifacts are written by the offline build scripts. +`; + +fs.writeFileSync(reportFile, report); +fs.writeFileSync(manifestFile, JSON.stringify({ + format: 'stepforge-artifacts-manifest', + version: 1, + generatedAt: new Date().toISOString(), + packageVersion: pkg.version, + files, +}, null, 2) + '\n'); +NODE + +echo "Build artifacts written to $BUILD_ROOT" diff --git a/scripts/make-sample-guide.js b/scripts/make-sample-guide.js new file mode 100644 index 0000000..0328b19 --- /dev/null +++ b/scripts/make-sample-guide.js @@ -0,0 +1,253 @@ +#!/usr/bin/env node +'use strict'; + +const fs = require('node:fs'); +const path = require('node:path'); + +const { GuideStore } = require('../core/store'); +const raster = require('../core/raster'); +const { encodePng } = require('../core/png'); +const { buildRenderAst } = require('../core/renderast'); +const { exportGuideArchive } = require('../core/archive'); +const { runExport } = require('../exporters'); +const { writeJsonSync, slugify } = require('../core/util'); + +const ROOT_DIR = path.resolve(__dirname, '..'); +const DEFAULT_ROOT = path.join(ROOT_DIR, 'examples'); + +function parseArgs(argv) { + const out = { root: DEFAULT_ROOT }; + for (let i = 0; i < argv.length; i++) { + const arg = argv[i]; + if (arg === '--root' && argv[i + 1]) out.root = path.resolve(argv[++i]); + else if (arg === '--help' || arg === '-h') out.help = true; + } + return out; +} + +function cleanDir(dir) { + fs.rmSync(dir, { recursive: true, force: true }); + fs.mkdirSync(dir, { recursive: true }); +} + +function drawChrome(img, { accent, title, subtitle, sidebarLabel, bodyLabel }) { + const W = img.width; + const H = img.height; + raster.fillRect(img, 0, 0, W, H, [245, 247, 250, 255]); + raster.fillRect(img, 0, 0, W, 68, accent); + raster.fillRect(img, 28, 94, 270, H - 138, [255, 255, 255, 255]); + raster.fillRect(img, 326, 94, W - 354, H - 138, [255, 255, 255, 255]); + raster.fillRect(img, 48, 118, 212, 18, [232, 237, 243, 255]); + raster.fillRect(img, 48, 152, 212, 18, [232, 237, 243, 255]); + raster.fillRect(img, 48, 186, 212, 18, [232, 237, 243, 255]); + raster.fillRect(img, 362, 148, 220, 152, [230, 237, 245, 255]); + raster.fillRect(img, 608, 148, 276, 40, [235, 241, 248, 255]); + raster.fillRect(img, 608, 202, 276, 40, [235, 241, 248, 255]); + raster.fillRect(img, 608, 256, 276, 40, [235, 241, 248, 255]); + raster.drawText(img, 28, 20, title, 26, [255, 255, 255, 255]); + raster.drawText(img, 28, 44, subtitle, 12, [214, 226, 240, 255]); + raster.drawText(img, 48, 102, sidebarLabel, 12, [78, 90, 105, 255]); + raster.drawText(img, 356, 102, bodyLabel, 12, [78, 90, 105, 255]); +} + +function makeShotOne() { + const img = raster.createImage(1280, 760, [245, 247, 250, 255]); + drawChrome(img, { + accent: [0, 104, 255, 255], + title: 'Reset password', + subtitle: 'Users > Security > Reset', + sidebarLabel: 'Users', + bodyLabel: 'Admin Portal', + }); + raster.fillRect(img, 392, 156, 176, 36, [0, 104, 255, 255]); + raster.drawTextCentered(img, 480, 175, 'Open Users', 16, [255, 255, 255, 255]); + raster.fillRect(img, 644, 160, 160, 20, [255, 255, 255, 255]); + raster.fillRect(img, 644, 196, 240, 20, [255, 255, 255, 255]); + raster.fillRect(img, 644, 232, 220, 20, [255, 255, 255, 255]); + raster.drawText(img, 360, 336, '1. Open the Users list and confirm the target account is visible.', 12, [48, 59, 71, 255]); + raster.drawText(img, 360, 360, 'The highlight shows the next action target.', 12, [96, 108, 121, 255]); + return img; +} + +function makeShotTwo() { + const img = raster.createImage(1280, 760, [245, 247, 250, 255]); + drawChrome(img, { + accent: [20, 115, 90, 255], + title: 'Security settings', + subtitle: '2-factor authentication and resets', + sidebarLabel: 'Security', + bodyLabel: 'Account settings', + }); + raster.fillRect(img, 366, 160, 252, 56, [20, 115, 90, 255]); + raster.drawTextCentered(img, 492, 180, 'Enable 2FA', 18, [255, 255, 255, 255]); + raster.fillRect(img, 648, 160, 250, 22, [233, 238, 244, 255]); + raster.fillRect(img, 648, 196, 250, 22, [233, 238, 244, 255]); + raster.fillRect(img, 648, 232, 250, 22, [233, 238, 244, 255]); + raster.drawText(img, 360, 336, '2. Enable the reset policy and save the change.', 12, [48, 59, 71, 255]); + raster.drawText(img, 360, 360, 'The annotation number points at the primary action.', 12, [96, 108, 121, 255]); + return img; +} + +function makeShotThree() { + const img = raster.createImage(1280, 760, [245, 247, 250, 255]); + drawChrome(img, { + accent: [36, 50, 78, 255], + title: 'Confirmation', + subtitle: 'Review before closing the workflow', + sidebarLabel: 'Review', + bodyLabel: 'Change summary', + }); + raster.fillRect(img, 366, 150, 472, 210, [255, 255, 255, 255]); + raster.fillRect(img, 396, 182, 120, 18, [232, 237, 243, 255]); + raster.fillRect(img, 396, 220, 316, 18, [232, 237, 243, 255]); + raster.fillRect(img, 396, 256, 356, 18, [232, 237, 243, 255]); + raster.fillRect(img, 396, 292, 270, 18, [232, 237, 243, 255]); + raster.fillRect(img, 778, 298, 36, 36, [36, 50, 78, 255]); + raster.drawText(img, 396, 406, '3. Confirm the summary, then close the dialog.', 12, [48, 59, 71, 255]); + raster.drawText(img, 396, 430, 'A blur redacts the account number in the sample export.', 12, [96, 108, 121, 255]); + return img; +} + +function createGuide(store) { + const guide = store.createGuide({ + title: 'Reset a password in Admin Portal', + descriptionHtml: '

Offline sample guide showing capture, annotations, rich text, and exports.

', + placeholders: { + Product: 'Admin Portal', + Author: 'StepForge', + Department: 'Support', + }, + flags: { + focusedViewDefault: true, + hideSkippedStepsInExports: true, + }, + }); + + const steps = [ + { + title: 'Open [[Product]] users', + descriptionHtml: '

Open the users list and select the target account.

', + annotations: [ + { type: 'rect', x: 0.275, y: 0.18, w: 0.19, h: 0.18, style: { stroke: '#0068ff', strokeWidth: 6, fill: 'transparent' } }, + { type: 'number', value: 1, x: 0.30, y: 0.08, w: 0.08, h: 0.12, style: { stroke: '#0068ff' } }, + ], + textBlocks: [ + { position: 'after-description', level: 'info', title: 'Tip', descriptionHtml: '

Use the search box to avoid scrolling.

' }, + ], + image: makeShotOne(), + }, + { + title: 'Enable the reset policy', + descriptionHtml: '

Make sure the policy is active before continuing.

', + annotations: [ + { type: 'arrow', x: 0.47, y: 0.24, w: 0.23, h: -0.04, style: { stroke: '#14a375', strokeWidth: 5 } }, + { type: 'tooltip', x: 0.53, y: 0.13, w: 0.17, h: 0.08, text: 'Primary action', style: { fill: '#111827', textColor: '#ffffff', stroke: '#111827', tail: 'bottom' } }, + { type: 'number', value: 2, x: 0.31, y: 0.08, w: 0.08, h: 0.12, style: { stroke: '#14a375' } }, + ], + codeBlocks: [ + { id: 'cmd', language: 'bash', code: 'stepforge --capture --window --delay 300' }, + ], + image: makeShotTwo(), + }, + { + title: 'Review the confirmation', + descriptionHtml: '

Confirm the summary and close the modal.

', + annotations: [ + { type: 'blur', x: 0.49, y: 0.32, w: 0.21, h: 0.08, radius: 12, style: { stroke: '#9ca3af', strokeWidth: 2 } }, + { type: 'highlight', x: 0.47, y: 0.24, w: 0.28, h: 0.20, style: { fill: '#ffeeb0', stroke: '#f0a500', strokeWidth: 2 } }, + { type: 'number', value: 3, x: 0.31, y: 0.08, w: 0.08, h: 0.12, style: { stroke: '#36a' } }, + ], + tableBlocks: [ + { id: 't1', rows: [['Field', 'Value'], ['Title', 'Admin Portal'], ['Owner', 'Support']] }, + ], + image: makeShotThree(), + }, + ]; + + steps.forEach((entry, index) => { + const buf = encodePng(entry.image); + store.addStep(guide.guideId, { + title: entry.title, + descriptionHtml: entry.descriptionHtml, + annotations: entry.annotations, + textBlocks: entry.textBlocks || [], + codeBlocks: entry.codeBlocks || [], + tableBlocks: entry.tableBlocks || [], + focusedView: { enabled: true, zoom: 1.1, panX: 0.5, panY: 0.5 }, + }, buf, { width: entry.image.width, height: entry.image.height }, { position: index }); + }); + + const substep = store.addStep(guide.guideId, { + kind: 'empty', + parentStepId: store.getGuide(guide.guideId).stepsOrder[1], + title: 'Confirm permission prompt', + descriptionHtml: '

Only administrators can complete this step.

', + textBlocks: [{ position: 'after-description', level: 'warn', title: 'Access', descriptionHtml: '

Admin rights required.

' }], + }, null, null, { position: 2 }); + + store.addStep(guide.guideId, { + kind: 'empty', + title: 'Legacy note', + hidden: true, + descriptionHtml: '

This hidden step exercises filtering in exports.

', + }, null, null, { position: 4 }); + + store.addStep(guide.guideId, { + kind: 'empty', + title: 'Deprecated flow', + skipped: true, + descriptionHtml: '

This skipped step remains in the library but is excluded from exports.

', + }, null, null, { position: 5 }); + + return { guideId: guide.guideId, substepId: substep.stepId }; +} + +function exportOutputs(store, guideId, root, manifest) { + const ast = buildRenderAst(store, guideId); + const formats = ['json', 'markdown', 'html-simple', 'html-rich', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx']; + const outputs = {}; + for (const format of formats) { + const outDir = path.join(root, 'sample-exports', format); + fs.mkdirSync(outDir, { recursive: true }); + const result = runExport(format, ast, outDir, {}); + outputs[format] = path.relative(root, result.file || outDir); + } + const archiveFile = path.join(root, 'sample-guide.sfgz'); + exportGuideArchive(store, guideId, archiveFile); + manifest.archive = path.relative(root, archiveFile); + manifest.exports = outputs; +} + +function main() { + const args = parseArgs(process.argv.slice(2)); + if (args.help) { + console.log('Usage: node scripts/make-sample-guide.js [--root ]'); + process.exit(0); + } + + const root = args.root; + const dataDir = path.join(root, 'sample-data'); + const exportsDir = path.join(root, 'sample-exports'); + cleanDir(root); + fs.mkdirSync(dataDir, { recursive: true }); + fs.mkdirSync(exportsDir, { recursive: true }); + + const store = new GuideStore(dataDir); + const { guideId, substepId } = createGuide(store); + const manifest = { + format: 'stepforge-sample-manifest', + version: 1, + generatedAt: new Date().toISOString(), + guideId, + title: store.getGuide(guideId).title, + dataDir: path.relative(root, dataDir), + note: 'The sample guide is generated entirely offline from local assets.', + }; + exportOutputs(store, guideId, root, manifest); + manifest.substepId = substepId; + manifest.slug = slugify(manifest.title); + writeJsonSync(path.join(root, 'sample-manifest.json'), manifest); + console.log(`Sample guide written to ${root}`); +} + +if (require.main === module) main(); diff --git a/scripts/package-linux.sh b/scripts/package-linux.sh new file mode 100644 index 0000000..114d2b6 --- /dev/null +++ b/scripts/package-linux.sh @@ -0,0 +1,82 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +VERSION="$(node -p "require('${ROOT_DIR}/package.json').version" 2>/dev/null || echo 0.0.0)" +OUT_DIR="${STEPFORGE_PACKAGE_DIR:-$ROOT_DIR/build/artifacts}" +mkdir -p "$OUT_DIR" +WORK_DIR="$(mktemp -d "${OUT_DIR%/}/.pkg.XXXXXX")" +APP_DIR="$WORK_DIR/opt/stepforge" + +cleanup() { + rm -rf "$WORK_DIR" +} +trap cleanup EXIT + +mkdir -p "$APP_DIR" "$WORK_DIR/usr/bin" "$WORK_DIR/DEBIAN" + +copy_item() { + local src="$1" + local dest="$2" + if [[ -e "$ROOT_DIR/$src" ]]; then + mkdir -p "$(dirname "$dest")" + cp -a "$ROOT_DIR/$src" "$dest" + fi +} + +# Application payload: only the files needed to run the app. +copy_item app "$APP_DIR/app" +copy_item core "$APP_DIR/core" +copy_item exporters "$APP_DIR/exporters" +copy_item scripts "$APP_DIR/scripts" +copy_item README.md "$APP_DIR/README.md" +copy_item ARCHITECTURE.md "$APP_DIR/ARCHITECTURE.md" +copy_item CHANGELOG.md "$APP_DIR/CHANGELOG.md" +copy_item CODE_OF_CONDUCT.md "$APP_DIR/CODE_OF_CONDUCT.md" +copy_item CONTRIBUTING.md "$APP_DIR/CONTRIBUTING.md" +copy_item LICENSE "$APP_DIR/LICENSE" +copy_item SECURITY.md "$APP_DIR/SECURITY.md" +copy_item package.json "$APP_DIR/package.json" +copy_item package-lock.json "$APP_DIR/package-lock.json" +copy_item prompt.md "$APP_DIR/prompt.md" +copy_item examples "$APP_DIR/examples" +copy_item build/agent_audit.md "$APP_DIR/build/agent_audit.md" + +if [[ -d "$ROOT_DIR/node_modules" ]]; then + cp -a "$ROOT_DIR/node_modules" "$APP_DIR/node_modules" +fi + +cat > "$WORK_DIR/usr/bin/stepforge" <<'EOF' +#!/usr/bin/env sh +APP_DIR=/opt/stepforge +cd "$APP_DIR" || exit 1 +exec "$APP_DIR/node_modules/.bin/electron" "$APP_DIR" "$@" +EOF +chmod 0755 "$WORK_DIR/usr/bin/stepforge" + +cat > "$WORK_DIR/DEBIAN/control" < +Description: Offline desktop guide capture and export tool + A fully offline desktop app for step-by-step documentation, built for local + capture, annotation, and export workflows. +EOF + +DEB_FILE="$OUT_DIR/stepforge_${VERSION}_amd64.deb" +TAR_FILE="$OUT_DIR/stepforge_${VERSION}_linux-x64.tar.gz" + +if command -v dpkg-deb >/dev/null 2>&1; then + dpkg-deb --build "$WORK_DIR" "$DEB_FILE" >/dev/null +else + echo "dpkg-deb is not installed; skipping .deb build" >&2 +fi + +tar -C "$WORK_DIR/opt" -czf "$TAR_FILE" stepforge + +printf '%s\n' "$DEB_FILE" +printf '%s\n' "$TAR_FILE" diff --git a/scripts/start-electron.js b/scripts/start-electron.js new file mode 100644 index 0000000..6dfd261 --- /dev/null +++ b/scripts/start-electron.js @@ -0,0 +1,30 @@ +#!/usr/bin/env node +'use strict'; + +const { spawn } = require('node:child_process'); + +const electronPath = require('electron'); +const env = { ...process.env }; +delete env.ELECTRON_RUN_AS_NODE; + +const child = spawn(electronPath, ['.'], { + stdio: 'inherit', + env, + windowsHide: false, +}); + +let closed = false; +child.on('close', (code, signal) => { + closed = true; + if (code === null) { + process.exit(signal ? 1 : 0); + return; + } + process.exit(code); +}); + +for (const signal of ['SIGINT', 'SIGTERM', 'SIGUSR2']) { + process.on(signal, () => { + if (!closed) child.kill(signal); + }); +} diff --git a/scripts/verify.sh b/scripts/verify.sh new file mode 100644 index 0000000..9ca58d2 --- /dev/null +++ b/scripts/verify.sh @@ -0,0 +1,9 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$ROOT_DIR" + +bash tests/run_test.sh +bash scripts/build-release.sh diff --git a/tests/checks/test_startup_smoke.sh b/tests/checks/test_startup_smoke.sh new file mode 100644 index 0000000..a243bb8 --- /dev/null +++ b/tests/checks/test_startup_smoke.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Workflow check: ensure the Electron launcher boots without the +# ELECTRON_RUN_AS_NODE shim leaking into the app process. + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +LOG_FILE="$TMP_ROOT/start.log" +set +e +STEPFORGE_DATA_DIR="$TMP_ROOT/data" timeout 8s npm start >"$LOG_FILE" 2>&1 +status=$? +set -e + +if [[ $status -ne 124 ]]; then + cat "$LOG_FILE" >&2 + echo "electron launcher did not stay alive under timeout (status $status)" >&2 + exit 1 +fi + +if grep -Eq 'TypeError: Cannot read properties of undefined \(reading '\''requestSingleInstanceLock'\''\)|bad option: --ozone-platform=headless' "$LOG_FILE"; then + cat "$LOG_FILE" >&2 + echo "launcher still exposed a Node-mode startup failure" >&2 + exit 1 +fi + +echo "startup smoke OK" diff --git a/tests/checks/test_workflow_build_release.sh b/tests/checks/test_workflow_build_release.sh new file mode 100644 index 0000000..54814f6 --- /dev/null +++ b/tests/checks/test_workflow_build_release.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Workflow check: run the offline build with temp output roots and verify the +# report, manifest, and sample assets are produced. + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +BUILD_ROOT="$TMP_ROOT/build" +EXAMPLES_ROOT="$TMP_ROOT/examples" + +STEPFORGE_BUILD_DIR="$BUILD_ROOT" \ +STEPFORGE_EXAMPLES_DIR="$EXAMPLES_ROOT" \ +bash scripts/build-release.sh >/dev/null + +for f in build_report.md artifacts_manifest.json; do + if [[ ! -s "$BUILD_ROOT/$f" ]]; then + echo "Missing build output: $f" >&2 + exit 1 + fi +done + +if ! find "$BUILD_ROOT/artifacts" -maxdepth 1 -type f -name '*.tar.gz' -print -quit | grep -q .; then + echo "Missing portable tarball" >&2 + exit 1 +fi + +if [[ ! -s "$EXAMPLES_ROOT/sample-manifest.json" ]]; then + echo "Missing sample manifest from build" >&2 + exit 1 +fi + +if [[ ! -s "$EXAMPLES_ROOT/sample-guide.sfgz" ]]; then + echo "Missing sample archive from build" >&2 + exit 1 +fi + +MANIFEST_FILE="$BUILD_ROOT/artifacts_manifest.json" node - <<'NODE' +const fs = require('node:fs'); +const manifest = JSON.parse(fs.readFileSync(process.env.MANIFEST_FILE, 'utf8')); +if (manifest.format !== 'stepforge-artifacts-manifest') throw new Error('unexpected build manifest format'); +if (!Array.isArray(manifest.files) || manifest.files.length < 3) throw new Error('missing build files'); +if (!manifest.files.some((file) => file.path.endsWith('.tar.gz'))) throw new Error('missing tarball entry'); +if (!manifest.files.some((file) => file.path.endsWith('sample-guide.sfgz'))) throw new Error('missing sample archive entry'); +NODE + +echo "build release OK" diff --git a/tests/checks/test_workflow_sample_artifacts.sh b/tests/checks/test_workflow_sample_artifacts.sh new file mode 100644 index 0000000..c34ad02 --- /dev/null +++ b/tests/checks/test_workflow_sample_artifacts.sh @@ -0,0 +1,40 @@ +#!/usr/bin/env bash +# Workflow check: generate the offline sample guide and verify the expected +# outputs exist. This exercises the sample pipeline end to end in a temp root. + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$ROOT_DIR" + +TMP_ROOT="$(mktemp -d)" +trap 'rm -rf "$TMP_ROOT"' EXIT + +SAMPLE_ROOT="$TMP_ROOT/sample" +node scripts/make-sample-guide.js --root "$SAMPLE_ROOT" >/dev/null + +for f in sample-manifest.json sample-guide.sfgz; do + if [[ ! -s "$SAMPLE_ROOT/$f" ]]; then + echo "Missing sample output: $f" >&2 + exit 1 + fi +done + +for dir in sample-data sample-exports/json sample-exports/markdown sample-exports/html-simple \ + sample-exports/html-rich sample-exports/pdf sample-exports/gif \ + sample-exports/image-bundle sample-exports/docx sample-exports/pptx; do + if ! find "$SAMPLE_ROOT/$dir" -type f -print -quit | grep -q .; then + echo "Sample export directory is empty: $dir" >&2 + exit 1 + fi +done + +MANIFEST_FILE="$SAMPLE_ROOT/sample-manifest.json" node - <<'NODE' +const fs = require('node:fs'); +const manifest = JSON.parse(fs.readFileSync(process.env.MANIFEST_FILE, 'utf8')); +if (manifest.format !== 'stepforge-sample-manifest') throw new Error('unexpected sample manifest format'); +if (!manifest.guideId) throw new Error('missing guideId'); +if (!manifest.exports || Object.keys(manifest.exports).length < 9) throw new Error('missing sample exports'); +NODE + +echo "sample artifacts OK"