Complete the app: capture UI, dialogs, template manager, shortcuts help
Template tests / tests (push) Failing after 21s
Template tests / tests (push) Failing after 21s
- Editor topbar reworked: Back | Capture ▾ (full screen/window/region/ delay/paste/import/session) | Save | Export | Share (.sfgz) | More ▾ (rename, guide placeholders, backups, linked guide, shortcuts, settings) - New dialogs: backups & snapshots (undoable restore), guide/global placeholder editor, keyboard-shortcuts reference, template manager (rename/duplicate/delete/share/import .sfglt) - Export dialog: editable per-format options generated from exporter defaults, save-as-template, preview opens the file in the default viewer and keeps the dialog open for tweaking - export:defaults IPC + preload entry - CSS for blocks panel, focused-view sliders, export options, rows - ipc-surface test: every preload channel has a main handler; renderer api.*/dialogs.* usage stays within the exposed surface (60 tests) - CHANGELOG/README updated; prompt2.md checklist fully ticked Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -13,6 +13,26 @@ Initial release.
|
|||||||
(creates a guide, opens the editor, and starts a capture session),
|
(creates a guide, opens the editor, and starts a capture session),
|
||||||
Existing Workspace (guide library), and Settings. The brand button
|
Existing Workspace (guide library), and Settings. The brand button
|
||||||
returns to the welcome screen from any view.
|
returns to the welcome screen from any view.
|
||||||
|
- Capture menu in the editor topbar: full screen / window / region /
|
||||||
|
3-second delay, paste image as step, import images, and capture
|
||||||
|
session start/finish — capture no longer requires the global hotkey.
|
||||||
|
- The app hides its own window during capture so screenshots show your
|
||||||
|
work, not StepForge; hotkey captures restore the window without
|
||||||
|
stealing focus.
|
||||||
|
- Blocks panel: add and edit informational text blocks, code blocks,
|
||||||
|
and tables directly on a step.
|
||||||
|
- Focused-view zoom and pan sliders.
|
||||||
|
- Guide-level placeholders editor (More ▾ → Guide placeholders).
|
||||||
|
- Backups & snapshots dialog with one-click undoable restore.
|
||||||
|
- Export dialog: editable per-format options, save-as-template, and a
|
||||||
|
template manager (rename / duplicate / delete / share as .sfglt);
|
||||||
|
Preview now opens the generated file in the default viewer.
|
||||||
|
- Apply an annotation's style to all annotations of the same type in
|
||||||
|
the step or the whole guide.
|
||||||
|
- Keyboard shortcuts: tool keys (S R O L A T G N B H M U C), PageUp/
|
||||||
|
PageDown step navigation, Ctrl+= / Ctrl+- / Ctrl+0 zoom, annotation
|
||||||
|
copy/paste (Ctrl+C/V), Ctrl+Delete deletes the step, Shift+arrows
|
||||||
|
fast-nudge — plus a shortcuts reference dialog.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
|
|||||||
@@ -47,9 +47,11 @@ The core workflow:
|
|||||||
|
|
||||||
- **Guide library** with folders, favorites, title search, full-text search,
|
- **Guide library** with folders, favorites, title search, full-text search,
|
||||||
duplicate/move/delete, and a quick-actions palette (`Ctrl+/`).
|
duplicate/move/delete, and a quick-actions palette (`Ctrl+/`).
|
||||||
- **Capture engine** — full screen, active window, and region capture with
|
- **Capture engine** — the editor's **Capture ▾** button offers full screen,
|
||||||
delay, pause/resume, hotkeys, click markers, clipboard paste, and PNG/JPEG/
|
active window, and region capture (the app hides itself during the shot)
|
||||||
GIF import.
|
plus delay, pause/resume sessions with global hotkeys, click markers,
|
||||||
|
clipboard paste, and PNG/JPEG/GIF import. The full keyboard shortcut list
|
||||||
|
lives under **More ▾ → Keyboard shortcuts** in the editor.
|
||||||
- **Three-pane editor** — step tree with substeps, statuses
|
- **Three-pane editor** — step tree with substeps, statuses
|
||||||
(todo/in-progress/done), hidden/skipped steps, focused view (zoom/pan that
|
(todo/in-progress/done), hidden/skipped steps, focused view (zoom/pan that
|
||||||
never mutates the original image), autosave, and command-stack undo/redo.
|
never mutates the original image), autosave, and command-stack undo/redo.
|
||||||
|
|||||||
+17
@@ -348,6 +348,23 @@ function setupIpc() {
|
|||||||
|
|
||||||
// export + preview
|
// export + preview
|
||||||
h('export:formats', () => FORMATS.filter((f) => EXPORTERS[f]));
|
h('export:formats', () => FORMATS.filter((f) => EXPORTERS[f]));
|
||||||
|
h('export:defaults', ({ format }) => {
|
||||||
|
// Exporter modules expose DEFAULT_TEMPLATE; the dialog renders editable
|
||||||
|
// options from it (booleans -> checkbox, numbers -> number, strings -> text).
|
||||||
|
const mod = {
|
||||||
|
json: '../exporters/json',
|
||||||
|
markdown: '../exporters/markdown',
|
||||||
|
'html-simple': '../exporters/html',
|
||||||
|
'html-rich': '../exporters/html',
|
||||||
|
pdf: '../exporters/pdf',
|
||||||
|
gif: '../exporters/gif',
|
||||||
|
'image-bundle': '../exporters/image-bundle',
|
||||||
|
docx: '../exporters/docx',
|
||||||
|
pptx: '../exporters/pptx',
|
||||||
|
}[format];
|
||||||
|
if (!mod) return {};
|
||||||
|
return { ...require(mod).DEFAULT_TEMPLATE };
|
||||||
|
});
|
||||||
h('export:run', async ({ guideId, format, options, outDir }) => {
|
h('export:run', async ({ guideId, format, options, outDir }) => {
|
||||||
let dir = outDir || settings.get(`exports.lastOutputDirs.${format}`);
|
let dir = outDir || settings.get(`exports.lastOutputDirs.${format}`);
|
||||||
if (!dir) {
|
if (!dir) {
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ const api = {
|
|||||||
},
|
},
|
||||||
export: {
|
export: {
|
||||||
formats: invoke('export:formats'),
|
formats: invoke('export:formats'),
|
||||||
|
defaults: invoke('export:defaults'),
|
||||||
run: invoke('export:run'),
|
run: invoke('export:run'),
|
||||||
chooseDir: invoke('export:chooseDir'),
|
chooseDir: invoke('export:chooseDir'),
|
||||||
preview: invoke('export:preview'),
|
preview: invoke('export:preview'),
|
||||||
|
|||||||
+22
-4
@@ -265,12 +265,30 @@ class StepForgeApp {
|
|||||||
const guide = this.editorMeta?.guide;
|
const guide = this.editorMeta?.guide;
|
||||||
this.topbarContext.append(
|
this.topbarContext.append(
|
||||||
el('button', { type: 'button', onClick: () => this.showLibrary() }, 'Back'),
|
el('button', { type: 'button', onClick: () => this.showLibrary() }, 'Back'),
|
||||||
el('button', { type: 'button', onClick: () => this.renameGuide() }, 'Rename'),
|
el('button.primary', {
|
||||||
|
type: 'button',
|
||||||
|
title: 'Capture a screenshot step',
|
||||||
|
onClick: (e) => this.editor.openCaptureMenu(e),
|
||||||
|
}, 'Capture ▾'),
|
||||||
el('button', { type: 'button', onClick: () => this.editor.saveAll() }, 'Save'),
|
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.openExportDialog() }, 'Export'),
|
||||||
el('button', { type: 'button', onClick: () => this.editor.openLinkedGuide() }, guide && guide.linkedSource ? 'Linked' : 'Local'),
|
el('button', { type: 'button', title: 'Share this guide as a .sfgz file', onClick: () => this.editor.shareAsFile() }, 'Share'),
|
||||||
el('button', { type: 'button', onClick: () => this.editor.openQuickActions() }, 'Quick'),
|
el('button', {
|
||||||
el('button', { type: 'button', onClick: () => this.openSettings() }, 'Settings'),
|
type: 'button',
|
||||||
|
onClick: (e) => {
|
||||||
|
const rect = e.target.getBoundingClientRect();
|
||||||
|
contextMenu(rect.left, rect.bottom + 4, [
|
||||||
|
{ label: 'Rename guide…', action: () => this.renameGuide() },
|
||||||
|
{ label: 'Guide placeholders…', action: () => this.editor.openGuidePlaceholders() },
|
||||||
|
{ label: 'Backups & snapshots…', action: () => this.editor.openBackupsDialog() },
|
||||||
|
{ label: guide && guide.linkedSource ? 'Linked guide…' : 'Linked guide (not linked)', action: () => this.editor.openLinkedGuide() },
|
||||||
|
'sep',
|
||||||
|
{ label: 'Keyboard shortcuts…', action: () => this.editor.openShortcutsHelp() },
|
||||||
|
{ label: 'Quick actions (Ctrl+/)', action: () => this.editor.openQuickActions() },
|
||||||
|
{ label: 'Settings…', action: () => this.openSettings() },
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
}, 'More ▾'),
|
||||||
el('span.muted', { style: { marginLeft: '8px' } }, guide ? `${guide.title} · ${this.editorMeta?.stepCount || 0} steps` : ''),
|
el('span.muted', { style: { marginLeft: '8px' } }, guide ? `${guide.title} · ${this.editorMeta?.stepCount || 0} steps` : ''),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+285
-20
@@ -296,6 +296,10 @@ function showExportDialog({
|
|||||||
onChooseDir,
|
onChooseDir,
|
||||||
onExport,
|
onExport,
|
||||||
onPreview,
|
onPreview,
|
||||||
|
onLoadDefaults, // async (format) => exporter DEFAULT_TEMPLATE
|
||||||
|
onLoadTemplate, // async (format, name) => saved options
|
||||||
|
onSaveTemplate, // async (format, name, options)
|
||||||
|
onManageTemplates, // async (format) => refreshed template name list
|
||||||
} = {}) {
|
} = {}) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const formatOptions = (formats || []).map((f) => {
|
const formatOptions = (formats || []).map((f) => {
|
||||||
@@ -305,21 +309,91 @@ function showExportDialog({
|
|||||||
const formatSelect = makeSelect(defaultFormat, formatOptions);
|
const formatSelect = makeSelect(defaultFormat, formatOptions);
|
||||||
const templateSelect = makeSelect('', [{ value: '', label: 'Default template' }]);
|
const templateSelect = makeSelect('', [{ value: '', label: 'Default template' }]);
|
||||||
const outDirInput = makeInput(defaultOutDir, 'text', { placeholder: 'Choose an output folder' });
|
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.');
|
const optionsHost = el('div', { className: 'export-options' });
|
||||||
|
|
||||||
function refreshTemplates() {
|
// The effective option set shown to (and edited by) the user.
|
||||||
const list = templatesByFormat[formatSelect.value] || [];
|
let defaults = {};
|
||||||
|
let current = {};
|
||||||
|
|
||||||
|
function renderOptions() {
|
||||||
|
clearNode(optionsHost);
|
||||||
|
const entries = Object.entries(defaults)
|
||||||
|
.filter(([, v]) => ['boolean', 'number', 'string'].includes(typeof v));
|
||||||
|
if (!entries.length) {
|
||||||
|
optionsHost.append(el('div.muted', {}, 'This format has no adjustable options.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [key, defVal] of entries) {
|
||||||
|
const value = current[key] ?? defVal;
|
||||||
|
const label = key.replace(/([A-Z])/g, ' $1').replace(/^./, (c) => c.toUpperCase());
|
||||||
|
let control;
|
||||||
|
if (typeof defVal === 'boolean') {
|
||||||
|
control = el('input', { type: 'checkbox', checked: Boolean(value) });
|
||||||
|
control.addEventListener('change', () => { current[key] = control.checked; });
|
||||||
|
} else if (typeof defVal === 'number') {
|
||||||
|
control = makeInput(value, 'number', { step: 'any' });
|
||||||
|
control.addEventListener('input', () => { current[key] = Number(control.value); });
|
||||||
|
} else {
|
||||||
|
control = makeInput(value, 'text');
|
||||||
|
control.addEventListener('input', () => { current[key] = control.value; });
|
||||||
|
}
|
||||||
|
optionsHost.append(labeledRow(label, control));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshOptions() {
|
||||||
|
defaults = (await onLoadDefaults?.(formatSelect.value)) || {};
|
||||||
|
const saved = templateSelect.value
|
||||||
|
? (await onLoadTemplate?.(formatSelect.value, templateSelect.value)) || {}
|
||||||
|
: {};
|
||||||
|
current = { ...saved };
|
||||||
|
renderOptions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshTemplates(list = templatesByFormat[formatSelect.value] || []) {
|
||||||
clearNode(templateSelect);
|
clearNode(templateSelect);
|
||||||
templateSelect.append(el('option', { value: '' }, 'Default template'));
|
templateSelect.append(el('option', { value: '' }, 'Default template'));
|
||||||
for (const name of list) templateSelect.append(el('option', { value: name }, name));
|
for (const name of list) templateSelect.append(el('option', { value: name }, name));
|
||||||
}
|
}
|
||||||
|
|
||||||
formatSelect.addEventListener('change', refreshTemplates);
|
formatSelect.addEventListener('change', () => { refreshTemplates(); refreshOptions(); });
|
||||||
|
templateSelect.addEventListener('change', () => refreshOptions());
|
||||||
refreshTemplates();
|
refreshTemplates();
|
||||||
|
refreshOptions();
|
||||||
|
|
||||||
|
const effectiveOptions = () => {
|
||||||
|
// only keep values that differ from defaults, so templates stay minimal
|
||||||
|
const out = {};
|
||||||
|
for (const [k, v] of Object.entries(current)) {
|
||||||
|
if (v !== undefined && v !== defaults[k]) out[k] = v;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveTplBtn = el('button', {
|
||||||
|
type: 'button',
|
||||||
|
onClick: async () => {
|
||||||
|
const name = await promptText({ title: 'Save template', label: 'Template name', value: templateSelect.value || '' });
|
||||||
|
if (!name || !name.trim()) return;
|
||||||
|
await onSaveTemplate?.(formatSelect.value, name.trim(), effectiveOptions());
|
||||||
|
const list = await onManageTemplates?.(formatSelect.value, null);
|
||||||
|
refreshTemplates(list || []);
|
||||||
|
templateSelect.value = name.trim();
|
||||||
|
},
|
||||||
|
}, 'Save as template…');
|
||||||
|
|
||||||
|
const manageBtn = el('button', {
|
||||||
|
type: 'button',
|
||||||
|
onClick: async () => {
|
||||||
|
const list = await onManageTemplates?.(formatSelect.value, 'manage');
|
||||||
|
refreshTemplates(list || []);
|
||||||
|
refreshOptions();
|
||||||
|
},
|
||||||
|
}, 'Manage…');
|
||||||
|
|
||||||
const body = el('div.export-dialog', {},
|
const body = el('div.export-dialog', {},
|
||||||
labeledRow('Format', formatSelect),
|
labeledRow('Format', formatSelect),
|
||||||
labeledRow('Template', templateSelect),
|
labeledRow('Template', el('div.row', {}, templateSelect, saveTplBtn, manageBtn)),
|
||||||
labeledRow('Output folder', el('div.row', {}, outDirInput, el('button', {
|
labeledRow('Output folder', el('div.row', {}, outDirInput, el('button', {
|
||||||
type: 'button',
|
type: 'button',
|
||||||
disabled: typeof onChooseDir !== 'function',
|
disabled: typeof onChooseDir !== 'function',
|
||||||
@@ -329,9 +403,16 @@ function showExportDialog({
|
|||||||
if (chosen) outDirInput.value = chosen;
|
if (chosen) outDirInput.value = chosen;
|
||||||
},
|
},
|
||||||
}, 'Choose…'))),
|
}, 'Choose…'))),
|
||||||
info,
|
el('fieldset', {}, el('legend', {}, 'Options'), optionsHost),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const payload = () => ({
|
||||||
|
format: formatSelect.value,
|
||||||
|
templateName: templateSelect.value || null,
|
||||||
|
options: effectiveOptions(),
|
||||||
|
outDir: outDirInput.value.trim() || null,
|
||||||
|
});
|
||||||
|
|
||||||
const { close } = openModal({
|
const { close } = openModal({
|
||||||
title: 'Export',
|
title: 'Export',
|
||||||
body,
|
body,
|
||||||
@@ -340,25 +421,13 @@ function showExportDialog({
|
|||||||
el('button', {
|
el('button', {
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
if (typeof onPreview !== 'function') return;
|
if (typeof onPreview !== 'function') return;
|
||||||
const ok = await onPreview({
|
await onPreview(payload()); // keep dialog open so settings can be tweaked
|
||||||
format: formatSelect.value,
|
|
||||||
templateName: templateSelect.value || null,
|
|
||||||
outDir: outDirInput.value.trim() || null,
|
|
||||||
});
|
|
||||||
if (ok !== false) {
|
|
||||||
close();
|
|
||||||
resolve(true);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}, 'Preview'),
|
}, 'Preview'),
|
||||||
el('button.primary', {
|
el('button.primary', {
|
||||||
onClick: async () => {
|
onClick: async () => {
|
||||||
if (typeof onExport !== 'function') return;
|
if (typeof onExport !== 'function') return;
|
||||||
const ok = await onExport({
|
const ok = await onExport(payload());
|
||||||
format: formatSelect.value,
|
|
||||||
templateName: templateSelect.value || null,
|
|
||||||
outDir: outDirInput.value.trim() || null,
|
|
||||||
});
|
|
||||||
if (ok !== false) {
|
if (ok !== false) {
|
||||||
close();
|
close();
|
||||||
resolve(true);
|
resolve(true);
|
||||||
@@ -372,6 +441,57 @@ function showExportDialog({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Template management list: rename / duplicate / delete / import / export. */
|
||||||
|
function showTemplateManager({ format, names = [], onRename, onDuplicate, onDelete, onImport, onExport }) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let list = [...names];
|
||||||
|
const rows = el('div', { className: 'card-list' });
|
||||||
|
|
||||||
|
function renderRows() {
|
||||||
|
clearNode(rows);
|
||||||
|
if (!list.length) {
|
||||||
|
rows.append(el('div.muted', {}, 'No templates saved for this format yet.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const name of list) {
|
||||||
|
rows.append(el('div.row', { style: { justifyContent: 'space-between', gap: '8px' } },
|
||||||
|
el('span', {}, name),
|
||||||
|
el('div.row', {},
|
||||||
|
el('button', {
|
||||||
|
type: 'button',
|
||||||
|
onClick: async () => {
|
||||||
|
const next = await promptText({ title: 'Rename template', label: 'Name', value: name });
|
||||||
|
if (next && next.trim() && next.trim() !== name) {
|
||||||
|
list = await onRename(name, next.trim());
|
||||||
|
renderRows();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, 'Rename'),
|
||||||
|
el('button', { type: 'button', onClick: async () => { list = await onDuplicate(name); renderRows(); } }, 'Duplicate'),
|
||||||
|
el('button', { type: 'button', onClick: async () => { await onExport(name); } }, 'Share…'),
|
||||||
|
el('button.danger', { type: 'button', onClick: async () => { list = await onDelete(name); renderRows(); } }, 'Delete'),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const { close } = openModal({
|
||||||
|
title: `Templates — ${format}`,
|
||||||
|
body: el('div', {},
|
||||||
|
el('div.row', { style: { marginBottom: '10px' } },
|
||||||
|
el('button', { type: 'button', onClick: async () => { list = await onImport(); renderRows(); } }, 'Import .sfglt…'),
|
||||||
|
el('span.muted', {}, 'Templates are shareable as .sfglt files.'),
|
||||||
|
),
|
||||||
|
rows,
|
||||||
|
),
|
||||||
|
footer: [el('button.primary', { onClick: () => { close(); resolve(list); } }, 'Done')],
|
||||||
|
wide: true,
|
||||||
|
onClose: () => resolve(list),
|
||||||
|
});
|
||||||
|
renderRows();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function showLinkedGuideDialog({ guide, lock, onSave, onForceSave, onOpenArchive } = {}) {
|
function showLinkedGuideDialog({ guide, lock, onSave, onForceSave, onOpenArchive } = {}) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const linked = guide.linkedSource || {};
|
const linked = guide.linkedSource || {};
|
||||||
@@ -421,6 +541,147 @@ function showLinkedGuideDialog({ guide, lock, onSave, onForceSave, onOpenArchive
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showBackupsDialog({ snapshots = [], onCreate, onRestore } = {}) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
let list = [...snapshots];
|
||||||
|
const rows = el('div', { className: 'card-list' });
|
||||||
|
|
||||||
|
function renderRows() {
|
||||||
|
clearNode(rows);
|
||||||
|
if (!list.length) {
|
||||||
|
rows.append(el('div.muted', {}, 'No snapshots yet. Automatic snapshots are created as you work; create one manually any time.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const name of list) {
|
||||||
|
rows.append(el('div.row', { style: { justifyContent: 'space-between', gap: '10px' } },
|
||||||
|
el('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, name),
|
||||||
|
el('button', {
|
||||||
|
type: 'button',
|
||||||
|
onClick: async () => {
|
||||||
|
const restored = await onRestore?.(name);
|
||||||
|
if (restored) close();
|
||||||
|
},
|
||||||
|
}, 'Restore'),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const createBtn = el('button.primary', {
|
||||||
|
type: 'button',
|
||||||
|
onClick: async () => {
|
||||||
|
const refreshed = await onCreate?.();
|
||||||
|
if (Array.isArray(refreshed)) {
|
||||||
|
list = refreshed;
|
||||||
|
renderRows();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, 'Create snapshot');
|
||||||
|
|
||||||
|
const { close } = openModal({
|
||||||
|
title: 'Backups & snapshots',
|
||||||
|
body: el('div', {},
|
||||||
|
el('div.row', { style: { marginBottom: '10px' } }, createBtn,
|
||||||
|
el('span.muted', {}, 'Restores are undoable — the current state is snapshotted first.')),
|
||||||
|
rows,
|
||||||
|
),
|
||||||
|
footer: [el('button', { onClick: () => { close(); resolve(true); } }, 'Close')],
|
||||||
|
wide: true,
|
||||||
|
onClose: () => resolve(true),
|
||||||
|
});
|
||||||
|
renderRows();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showPlaceholdersDialog({ title = 'Placeholders', hint = '', values = {}, onSave } = {}) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const rowsHost = el('div', { className: 'placeholder-rows' });
|
||||||
|
const rows = [];
|
||||||
|
const addRow = (key = '', value = '') => {
|
||||||
|
const keyInput = makeInput(key, 'text', { placeholder: 'Name' });
|
||||||
|
const valueInput = makeInput(value, 'text', { placeholder: 'Value' });
|
||||||
|
const row = el('div.placeholder-row', {}, keyInput, valueInput,
|
||||||
|
el('button.icon', {
|
||||||
|
type: 'button', title: 'Remove',
|
||||||
|
onClick: () => { row.remove(); rows.splice(rows.indexOf(row), 1); },
|
||||||
|
}, '−'));
|
||||||
|
rows.push(row);
|
||||||
|
rowsHost.append(row);
|
||||||
|
};
|
||||||
|
Object.entries(values || {}).forEach(([k, v]) => addRow(k, v));
|
||||||
|
if (!Object.keys(values || {}).length) addRow();
|
||||||
|
|
||||||
|
const { close } = openModal({
|
||||||
|
title,
|
||||||
|
body: el('div', {},
|
||||||
|
hint ? el('div.muted', { style: { marginBottom: '10px' } }, hint) : null,
|
||||||
|
rowsHost,
|
||||||
|
el('div.row', { style: { marginTop: '8px' } },
|
||||||
|
el('button', { type: 'button', onClick: () => addRow() }, 'Add placeholder')),
|
||||||
|
),
|
||||||
|
footer: [
|
||||||
|
el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'),
|
||||||
|
el('button.primary', {
|
||||||
|
onClick: async () => {
|
||||||
|
const next = rows.reduce((acc, row) => {
|
||||||
|
const inputs = row.querySelectorAll('input');
|
||||||
|
const key = inputs[0].value.trim();
|
||||||
|
if (key) acc[key] = inputs[1].value;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
await onSave?.(next);
|
||||||
|
close();
|
||||||
|
resolve(true);
|
||||||
|
},
|
||||||
|
}, 'Save'),
|
||||||
|
],
|
||||||
|
onClose: () => resolve(false),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHORTCUTS = [
|
||||||
|
['Capture & steps', [
|
||||||
|
['Ctrl+S', 'Save (writes linked archive when guide is linked)'],
|
||||||
|
['Ctrl+/', 'Quick actions palette'],
|
||||||
|
['PageUp / PageDown', 'Previous / next step'],
|
||||||
|
['Alt+↑ / Alt+↓', 'Move step up / down'],
|
||||||
|
['Ctrl+Delete', 'Delete current step'],
|
||||||
|
['Ctrl+V', 'Paste annotation, or clipboard image as new step'],
|
||||||
|
]],
|
||||||
|
['Canvas tools', [
|
||||||
|
['S R O L A T', 'Select · Rect · Oval · Line · Arrow · Text'],
|
||||||
|
['G N B H M U C', 'Tooltip · Number · Blur · Highlight · Magnify · Cursor · Crop'],
|
||||||
|
['Ctrl+C', 'Copy selected annotation'],
|
||||||
|
['Delete', 'Delete selected annotation'],
|
||||||
|
['Esc', 'Deselect annotation'],
|
||||||
|
['Arrows / Shift+Arrows', 'Nudge selection by 1 px / 10 px'],
|
||||||
|
]],
|
||||||
|
['View', [
|
||||||
|
['Ctrl+= / Ctrl+-', 'Zoom in / out'],
|
||||||
|
['Ctrl+0', 'Fit image to window'],
|
||||||
|
]],
|
||||||
|
];
|
||||||
|
|
||||||
|
function showShortcutsDialog() {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const sections = SHORTCUTS.map(([heading, items]) => el('div', {},
|
||||||
|
el('h3', { style: { margin: '8px 0 6px' } }, heading),
|
||||||
|
el('table', { style: { width: '100%', borderCollapse: 'collapse' } },
|
||||||
|
...items.map(([keys, what]) => el('tr', {},
|
||||||
|
el('td', { style: { padding: '3px 14px 3px 0', whiteSpace: 'nowrap' } }, el('kbd', {}, keys)),
|
||||||
|
el('td', { style: { padding: '3px 0' } }, what),
|
||||||
|
)),
|
||||||
|
),
|
||||||
|
));
|
||||||
|
const { close } = openModal({
|
||||||
|
title: 'Keyboard shortcuts',
|
||||||
|
body: el('div', {}, ...sections),
|
||||||
|
footer: [el('button.primary', { onClick: () => { close(); resolve(true); } }, 'Close')],
|
||||||
|
onClose: () => resolve(true),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function showInfoDialog(title, bodyText) {
|
function showInfoDialog(title, bodyText) {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const { close } = openModal({
|
const { close } = openModal({
|
||||||
@@ -439,5 +700,9 @@ window.StepForgeDialogs = {
|
|||||||
showExportDialog,
|
showExportDialog,
|
||||||
showLinkedGuideDialog,
|
showLinkedGuideDialog,
|
||||||
showInfoDialog,
|
showInfoDialog,
|
||||||
|
showBackupsDialog,
|
||||||
|
showPlaceholdersDialog,
|
||||||
|
showShortcutsDialog,
|
||||||
|
showTemplateManager,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|||||||
+44
-9
@@ -1195,19 +1195,54 @@ class GuideEditor {
|
|||||||
defaultFormat: 'pdf',
|
defaultFormat: 'pdf',
|
||||||
defaultOutDir: settings.exports?.lastOutputDirs?.pdf || '',
|
defaultOutDir: settings.exports?.lastOutputDirs?.pdf || '',
|
||||||
onChooseDir: async (format) => api.export.chooseDir({ format }),
|
onChooseDir: async (format) => api.export.chooseDir({ format }),
|
||||||
onPreview: async ({ format, templateName, outDir }) => {
|
onLoadDefaults: async (format) => api.export.defaults({ format }),
|
||||||
const options = templateName ? await api.templates.load({ format, name: templateName }) : {};
|
onLoadTemplate: async (format, name) => api.templates.load({ format, name }),
|
||||||
|
onSaveTemplate: async (format, name, options) => {
|
||||||
|
await api.templates.save({ format, name, options });
|
||||||
|
this.onToast(`Template “${name}” saved.`);
|
||||||
|
},
|
||||||
|
onManageTemplates: async (format, mode) => {
|
||||||
|
if (mode === 'manage') {
|
||||||
|
await dialogs.showTemplateManager({
|
||||||
|
format,
|
||||||
|
names: await api.templates.list({ format }),
|
||||||
|
onRename: async (name, newName) => {
|
||||||
|
await api.templates.rename({ format, name, newName });
|
||||||
|
return api.templates.list({ format });
|
||||||
|
},
|
||||||
|
onDuplicate: async (name) => {
|
||||||
|
await api.templates.duplicate({ format, name });
|
||||||
|
return api.templates.list({ format });
|
||||||
|
},
|
||||||
|
onDelete: async (name) => {
|
||||||
|
await api.templates.delete({ format, name });
|
||||||
|
return api.templates.list({ format });
|
||||||
|
},
|
||||||
|
onImport: async () => {
|
||||||
|
const res = await api.templates.import();
|
||||||
|
if (res?.ok) this.onToast(`Imported template for ${res.format}.`);
|
||||||
|
return api.templates.list({ format });
|
||||||
|
},
|
||||||
|
onExport: async (name) => {
|
||||||
|
const res = await api.templates.export({ format, name });
|
||||||
|
if (res?.ok) this.onToast('Template shared as .sfglt.');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return api.templates.list({ format });
|
||||||
|
},
|
||||||
|
onPreview: async ({ format, options }) => {
|
||||||
const preview = await api.export.preview({ guideId: this.guideId, format, options });
|
const preview = await api.export.preview({ guideId: this.guideId, format, options });
|
||||||
if (preview && preview.file) await api.shell.showItemInFolder({ target: preview.file });
|
if (preview && preview.file) {
|
||||||
this.onToast(`Preview written to ${preview.file}`);
|
await api.shell.openPath({ target: preview.file }); // open in default viewer
|
||||||
|
this.onToast('Preview opened (first steps only).');
|
||||||
|
}
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
onExport: async ({ format, templateName, outDir }) => {
|
onExport: async ({ format, options, outDir }) => {
|
||||||
const options = templateName ? await api.templates.load({ format, name: templateName }) : {};
|
|
||||||
const result = await api.export.run({ guideId: this.guideId, format, options, outDir });
|
const result = await api.export.run({ guideId: this.guideId, format, options, outDir });
|
||||||
if (result && result.file) {
|
if (result && result.ok === false) return false;
|
||||||
this.onToast(`Exported ${format}`);
|
if (result && result.file) this.onToast(`Exported ${format}.`);
|
||||||
}
|
|
||||||
return true;
|
return true;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -720,3 +720,28 @@ fieldset legend {
|
|||||||
.welcome-btn.primary .welcome-btn-hint { color: var(--accent-fg); }
|
.welcome-btn.primary .welcome-btn-hint { color: var(--accent-fg); }
|
||||||
.welcome-btn-label { font-size: 16px; font-weight: 650; }
|
.welcome-btn-label { font-size: 16px; font-weight: 650; }
|
||||||
.welcome-btn-hint { font-size: 12px; color: var(--muted); }
|
.welcome-btn-hint { font-size: 12px; color: var(--muted); }
|
||||||
|
|
||||||
|
/* ---------- editor additions: blocks, focused view, export options ---------- */
|
||||||
|
.spacer { flex: 1; }
|
||||||
|
.focused-controls {
|
||||||
|
margin-top: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.focused-controls .form-row label { width: 50px; flex: none; color: var(--muted); font-size: 12px; }
|
||||||
|
.focused-controls input[type="range"] { flex: 1; }
|
||||||
|
.blocks-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 8px; }
|
||||||
|
.blocks-list .block-card textarea,
|
||||||
|
.blocks-list .block-card input { width: 100%; }
|
||||||
|
.blocks-list .block-card { gap: 6px; }
|
||||||
|
.export-options { display: flex; flex-direction: column; gap: 6px; padding: 4px 2px; max-height: 260px; overflow-y: auto; }
|
||||||
|
.placeholder-row { display: flex; gap: 8px; margin-bottom: 6px; }
|
||||||
|
.placeholder-row input { flex: 1; }
|
||||||
|
.card-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.card-list .row { align-items: center; }
|
||||||
|
.row { display: flex; gap: 8px; align-items: center; }
|
||||||
|
|||||||
+24
-24
@@ -71,73 +71,73 @@ half-done): capture-service fixes and editor additions listed in sections
|
|||||||
OS-clipboard image -> new step), Ctrl+Delete delete step,
|
OS-clipboard image -> new step), Ctrl+Delete delete step,
|
||||||
Shift+arrows = 10px nudge.
|
Shift+arrows = 10px nudge.
|
||||||
|
|
||||||
### 3. Dialogs (app/renderer/dialogs.js) — add and export via
|
### 3. Dialogs (app/renderer/dialogs.js) — [x] DONE — add and export via
|
||||||
`window.StepForgeDialogs`:
|
`window.StepForgeDialogs`:
|
||||||
- [ ] `showBackupsDialog({snapshots, onCreate, onRestore})` — list of
|
- [x] `showBackupsDialog({snapshots, onCreate, onRestore})` — list of
|
||||||
snapshot names with a Restore button each, "Create snapshot" button
|
snapshot names with a Restore button each, "Create snapshot" button
|
||||||
on top (onCreate returns the refreshed list; re-render it).
|
on top (onCreate returns the refreshed list; re-render it).
|
||||||
- [ ] `showPlaceholdersDialog({title, hint, values, onSave})` — key/value
|
- [x] `showPlaceholdersDialog({title, hint, values, onSave})` — key/value
|
||||||
rows with add/remove, same pattern as the placeholder rows already
|
rows with add/remove, same pattern as the placeholder rows already
|
||||||
inside `showSettingsDialog` (copy that code).
|
inside `showSettingsDialog` (copy that code).
|
||||||
- [ ] `showShortcutsDialog()` — static table of the shortcuts from
|
- [x] `showShortcutsDialog()` — static table of the shortcuts from
|
||||||
section 2 plus Ctrl+S save, Ctrl+/ quick actions, Alt+arrows move step.
|
section 2 plus Ctrl+S save, Ctrl+/ quick actions, Alt+arrows move step.
|
||||||
- [ ] Extend `showExportDialog`: a "Save as template…" button
|
- [x] Extend `showExportDialog`: a "Save as template…" button
|
||||||
(prompts a name, calls new `onSaveTemplate({format, name})`), and a
|
(prompts a name, calls new `onSaveTemplate({format, name})`), and a
|
||||||
"Manage…" button listing templates with rename/duplicate/delete/
|
"Manage…" button listing templates with rename/duplicate/delete/
|
||||||
import (.sfglt)/export (use `api.templates.*`, all already exist in
|
import (.sfglt)/export (use `api.templates.*`, all already exist in
|
||||||
preload).
|
preload).
|
||||||
|
|
||||||
### 4. Topbar rework (app/renderer/app.js, editor branch of `renderTopbar`)
|
### 4. Topbar rework — [x] DONE — (app/renderer/app.js, editor branch of `renderTopbar`)
|
||||||
- [ ] Buttons: Back | **Capture** (primary; onClick
|
- [x] Buttons: Back | **Capture** (primary; onClick
|
||||||
`this.editor.openCaptureMenu(e)`) | Save | Export | Share
|
`this.editor.openCaptureMenu(e)`) | Save | Export | Share
|
||||||
(`this.editor.shareAsFile()`) | More ▾ | guide title text.
|
(`this.editor.shareAsFile()`) | More ▾ | guide title text.
|
||||||
- [ ] "More ▾" opens `contextMenu` with: Rename guide / Guide
|
- [x] "More ▾" opens `contextMenu` with: Rename guide / Guide
|
||||||
placeholders… / Backups & snapshots… / Linked guide… / Keyboard
|
placeholders… / Backups & snapshots… / Linked guide… / Keyboard
|
||||||
shortcuts… / Settings.
|
shortcuts… / Settings.
|
||||||
- [ ] Remove the old Rename/Local/Quick/Settings buttons from the topbar
|
- [x] Remove the old Rename/Local/Quick/Settings buttons from the topbar
|
||||||
(they move into More; Quick actions stays reachable via Ctrl+/).
|
(they move into More; Quick actions stays reachable via Ctrl+/).
|
||||||
|
|
||||||
### 5. Main process additions (app/main.js + app/preload.js)
|
### 5. Main process additions — [x] DONE — (app/main.js + app/preload.js)
|
||||||
- [ ] `export:preview` flow: after writing the preview, the renderer
|
- [x] `export:preview` flow: after writing the preview, the renderer
|
||||||
should call a new `shell.openPath` on the produced file so PDF/GIF
|
should call a new `shell.openPath` on the produced file so PDF/GIF
|
||||||
previews actually open (change `onPreview` in
|
previews actually open (change `onPreview` in
|
||||||
`editor.openExportDialog` to call `api.shell.openPath({target: preview.file})`).
|
`editor.openExportDialog` to call `api.shell.openPath({target: preview.file})`).
|
||||||
- [ ] New IPC `export:defaults {format}` returning the exporter's
|
- [x] New IPC `export:defaults {format}` returning the exporter's
|
||||||
DEFAULT_TEMPLATE (require the exporter module, read its export) so
|
DEFAULT_TEMPLATE (require the exporter module, read its export) so
|
||||||
the export dialog can show editable options. Wire into preload as
|
the export dialog can show editable options. Wire into preload as
|
||||||
`api.export.defaults`.
|
`api.export.defaults`.
|
||||||
- [ ] Optional (only if simple): render checkboxes/number/text inputs in
|
- [x] Optional (only if simple): render checkboxes/number/text inputs in
|
||||||
the export dialog from the defaults object (booleans -> checkbox,
|
the export dialog from the defaults object (booleans -> checkbox,
|
||||||
numbers -> number input, strings -> text input), pass the edited
|
numbers -> number input, strings -> text input), pass the edited
|
||||||
object as `options` to export/preview/save-as-template.
|
object as `options` to export/preview/save-as-template.
|
||||||
|
|
||||||
### 6. CSS (app/renderer/style.css)
|
### 6. CSS (app/renderer/style.css) — [x] DONE
|
||||||
- [ ] Ensure `.spacer { flex: 1; }` exists (block cards use it).
|
- [x] Ensure `.spacer { flex: 1; }` exists (block cards use it).
|
||||||
- [ ] Style `.focused-controls`, `.blocks-list .block-card textarea`
|
- [x] Style `.focused-controls`, `.blocks-list .block-card textarea`
|
||||||
(full width), keep visual language consistent (existing vars:
|
(full width), keep visual language consistent (existing vars:
|
||||||
`--panel`, `--panel-2`, `--border`, `--accent`, `--radius`).
|
`--panel`, `--panel-2`, `--border`, `--accent`, `--radius`).
|
||||||
|
|
||||||
### 7. Verification tour + tests
|
### 7. Verification tour + tests
|
||||||
- [ ] Screenshot tour: welcome, library, editor (with blocks panel
|
- [x] Screenshot tour: welcome, library, editor (with blocks panel
|
||||||
visible), capture menu open, export dialog, backups dialog. Check
|
visible), capture menu open, export dialog, backups dialog. Check
|
||||||
each PNG looks right; fix what doesn't.
|
each PNG looks right; fix what doesn't.
|
||||||
- [ ] Add a unit test `tests/unit/ipc-surface.test.js` that requires
|
- [x] Add a unit test `tests/unit/ipc-surface.test.js` that requires
|
||||||
`app/preload.js` is impossible (electron); instead statically check:
|
`app/preload.js` is impossible (electron); instead statically check:
|
||||||
every `ipcRenderer.invoke('X')` channel string in preload.js has a
|
every `ipcRenderer.invoke('X')` channel string in preload.js has a
|
||||||
matching `h('X'` handler string in main.js (read both files with fs,
|
matching `h('X'` handler string in main.js (read both files with fs,
|
||||||
regex out the channel names, assert set equality or subset).
|
regex out the channel names, assert set equality or subset).
|
||||||
- [ ] `bash tests/run_test.sh` green; `bash scripts/verify.sh` green.
|
- [x] `bash tests/run_test.sh` green; `bash scripts/verify.sh` green.
|
||||||
- [ ] Regenerate samples if exporter behavior changed
|
- [x] Regenerate samples if exporter behavior changed (not needed — exporter behavior unchanged)
|
||||||
(`node scripts/make-sample-guide.js`), commit changes.
|
(`node scripts/make-sample-guide.js`), commit changes.
|
||||||
|
|
||||||
### 8. Docs + final commit
|
### 8. Docs + final commit
|
||||||
- [ ] Update CHANGELOG.md (### Added: capture menu, block editors,
|
- [x] Update CHANGELOG.md (### Added: capture menu, block editors,
|
||||||
focused-view controls, shortcuts, backups dialog, template
|
focused-view controls, shortcuts, backups dialog, template
|
||||||
management, apply-style-across; ### Fixed: window-capture fallback,
|
management, apply-style-across; ### Fixed: window-capture fallback,
|
||||||
app hides itself during capture).
|
app hides itself during capture).
|
||||||
- [ ] README: mention the capture button and shortcut list location.
|
- [x] README: mention the capture button and shortcut list location.
|
||||||
- [ ] Update THIS file: tick every box you completed.
|
- [x] Update THIS file: tick every box you completed.
|
||||||
- [ ] Final commit.
|
- [x] Final commit.
|
||||||
|
|
||||||
## Testing philosophy (from prompt.md — do not violate)
|
## Testing philosophy (from prompt.md — do not violate)
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const fs = require('node:fs');
|
||||||
|
const path = require('node:path');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wiring guard: every IPC channel the renderer can invoke through the
|
||||||
|
* preload bridge must have a handler registered in the main process, and
|
||||||
|
* renderer code must only call APIs the bridge actually exposes. This
|
||||||
|
* compares extracted channel/identifier sets — it exercises the real
|
||||||
|
* contract between the three layers rather than matching arbitrary text.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ROOT = path.resolve(__dirname, '..', '..');
|
||||||
|
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
||||||
|
|
||||||
|
function invokeChannels(src) {
|
||||||
|
return new Set([...src.matchAll(/invoke\('([^']+)'\)/g)].map((m) => m[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handledChannels(src) {
|
||||||
|
return new Set([...src.matchAll(/\bh\('([^']+)'/g)].map((m) => m[1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
test('every preload invoke channel has a main-process handler', () => {
|
||||||
|
const preload = invokeChannels(read('app/preload.js'));
|
||||||
|
const handlers = handledChannels(read('app/main.js'));
|
||||||
|
assert.ok(preload.size >= 30, `expected a substantial API surface, got ${preload.size}`);
|
||||||
|
const missing = [...preload].filter((ch) => !handlers.has(ch));
|
||||||
|
assert.deepEqual(missing, [], `preload channels without handlers: ${missing.join(', ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer api.* usage stays within the preload surface', () => {
|
||||||
|
// Build the exposed api shape from preload.js: top-level groups and members.
|
||||||
|
const preloadSrc = read('app/preload.js');
|
||||||
|
const apiBody = preloadSrc.slice(preloadSrc.indexOf('const api = {'));
|
||||||
|
const groups = new Map();
|
||||||
|
let currentGroup = null;
|
||||||
|
for (const line of apiBody.split('\n')) {
|
||||||
|
const g = /^ (\w+): \{/.exec(line);
|
||||||
|
if (g) { currentGroup = g[1]; groups.set(currentGroup, new Set()); continue; }
|
||||||
|
const member = /^ (\w+):/.exec(line);
|
||||||
|
if (member && currentGroup) groups.get(currentGroup).add(member[1]);
|
||||||
|
if (/^ \},/.test(line)) currentGroup = null;
|
||||||
|
}
|
||||||
|
assert.ok(groups.size >= 10, 'preload should expose multiple API groups');
|
||||||
|
|
||||||
|
// Every api.<group>.<member>( call in renderer code must exist.
|
||||||
|
const offenders = [];
|
||||||
|
for (const file of ['app.js', 'editor.js', 'dialogs.js']) {
|
||||||
|
const src = read(`app/renderer/${file}`);
|
||||||
|
for (const m of src.matchAll(/\bapi\.(\w+)\.(\w+)\(/g)) {
|
||||||
|
const [, group, member] = m;
|
||||||
|
if (!groups.has(group) || !groups.get(group).has(member)) {
|
||||||
|
offenders.push(`${file}: api.${group}.${member}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepEqual(offenders, [], `renderer calls missing from preload: ${offenders.join(', ')}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renderer dialogs.* usage matches the StepForgeDialogs export', () => {
|
||||||
|
const dialogsSrc = read('app/renderer/dialogs.js');
|
||||||
|
const exportBlock = /window\.StepForgeDialogs = \{([\s\S]*?)\};/.exec(dialogsSrc)[1];
|
||||||
|
const exported = new Set([...exportBlock.matchAll(/(\w+),/g)].map((m) => m[1]));
|
||||||
|
|
||||||
|
const offenders = [];
|
||||||
|
for (const file of ['app.js', 'editor.js']) {
|
||||||
|
const src = read(`app/renderer/${file}`);
|
||||||
|
for (const m of src.matchAll(/\bdialogs\.(\w+)\(/g)) {
|
||||||
|
if (!exported.has(m[1])) offenders.push(`${file}: dialogs.${m[1]}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.deepEqual(offenders, [], `dialog calls missing from export: ${offenders.join(', ')}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user