Finish Electron shell and workflow wiring
Template tests / tests (push) Failing after 4s

This commit is contained in:
Iisyourdad
2026-06-10 18:32:30 -05:00
parent a5bbdde480
commit f47aca67c2
22 changed files with 5002 additions and 2 deletions
+211
View File
@@ -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;
+430
View File
@@ -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();
});
}
+98
View File
@@ -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);
+7
View File
@@ -0,0 +1,7 @@
'use strict';
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('regionPicker', {
done: (rect) => ipcRenderer.send('region:picked', rect),
});
+596
View File
@@ -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 = `
<div id="library-host"></div>
<div id="editor-host" class="hidden"></div>
`;
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();
+502
View File
@@ -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;
+440
View File
@@ -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,
};
File diff suppressed because it is too large Load Diff
+29
View File
@@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src file: data:; font-src 'self';">
<title>StepForge</title>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div id="app">
<header id="topbar">
<div class="brand" id="btn-home" title="Guide library">StepForge</div>
<div id="topbar-context"></div>
<div class="spacer"></div>
<div id="capture-status" class="hidden"></div>
<input id="global-search" type="search" placeholder="Search guides… (Ctrl+/ for quick actions)">
</header>
<main id="view"></main>
</div>
<div id="modal-root"></div>
<div id="toast-root"></div>
<script src="util.js"></script>
<script src="canvas.js"></script>
<script src="dialogs.js"></script>
<script src="editor.js"></script>
<script src="app.js"></script>
</body>
</html>
+41
View File
@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Select region</title>
<style>
html, body { margin: 0; height: 100%; cursor: crosshair; background: rgba(0,0,0,0.25); user-select: none; }
#hint { position: fixed; top: 16px; left: 50%; transform: translateX(-50%);
background: rgba(17,24,39,.9); color: #fff; padding: 6px 14px; border-radius: 6px;
font: 13px system-ui, sans-serif; pointer-events: none; }
#sel { position: fixed; border: 2px solid #2563eb; background: rgba(37,99,235,.15); display: none; }
</style>
</head>
<body>
<div id="hint">Drag to select a region — Esc to cancel</div>
<div id="sel"></div>
<script>
const sel = document.getElementById('sel');
let start = null;
function rectFrom(a, b) {
return { x: Math.min(a.x, b.x), y: Math.min(a.y, b.y), w: Math.abs(a.x - b.x), h: Math.abs(a.y - b.y) };
}
window.addEventListener('mousedown', (e) => { start = { x: e.clientX, y: e.clientY }; });
window.addEventListener('mousemove', (e) => {
if (!start) return;
const r = rectFrom(start, { x: e.clientX, y: e.clientY });
sel.style.display = 'block';
sel.style.left = r.x + 'px'; sel.style.top = r.y + 'px';
sel.style.width = r.w + 'px'; sel.style.height = r.h + 'px';
});
window.addEventListener('mouseup', (e) => {
if (!start) return;
const r = rectFrom(start, { x: e.clientX, y: e.clientY });
window.regionPicker.done(r.w > 3 && r.h > 3 ? r : null);
});
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') window.regionPicker.done(null);
});
</script>
</body>
</html>
+673
View File
@@ -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);
}
+142
View File
@@ -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<boolean>. */
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
+4 -2
View File
@@ -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"
+24
View File
@@ -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
+101
View File
@@ -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"
+253
View File
@@ -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: '<p>Offline sample guide showing capture, annotations, rich text, and exports.</p>',
placeholders: {
Product: 'Admin Portal',
Author: 'StepForge',
Department: 'Support',
},
flags: {
focusedViewDefault: true,
hideSkippedStepsInExports: true,
},
});
const steps = [
{
title: 'Open [[Product]] users',
descriptionHtml: '<p>Open the users list and select the target account.</p>',
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: '<p>Use the search box to avoid scrolling.</p>' },
],
image: makeShotOne(),
},
{
title: 'Enable the reset policy',
descriptionHtml: '<p>Make sure the policy is active before continuing.</p>',
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: '<p>Confirm the summary and close the modal.</p>',
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: '<p>Only administrators can complete this step.</p>',
textBlocks: [{ position: 'after-description', level: 'warn', title: 'Access', descriptionHtml: '<p>Admin rights required.</p>' }],
}, null, null, { position: 2 });
store.addStep(guide.guideId, {
kind: 'empty',
title: 'Legacy note',
hidden: true,
descriptionHtml: '<p>This hidden step exercises filtering in exports.</p>',
}, null, null, { position: 4 });
store.addStep(guide.guideId, {
kind: 'empty',
title: 'Deprecated flow',
skipped: true,
descriptionHtml: '<p>This skipped step remains in the library but is excluded from exports.</p>',
}, 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 <dir>]');
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();
+82
View File
@@ -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" <<EOF
Package: stepforge
Version: $VERSION
Section: utils
Priority: optional
Architecture: amd64
Maintainer: StepForge <noreply@example.com>
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"
+30
View File
@@ -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);
});
}
+9
View File
@@ -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
+31
View File
@@ -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"
@@ -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"
@@ -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"