Complete the app: capture UI, dialogs, template manager, shortcuts help
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:
Iisyourdad
2026-06-10 22:15:15 -05:00
parent 382dbc9717
commit 6e790832f5
10 changed files with 520 additions and 60 deletions
+285 -20
View File
@@ -296,6 +296,10 @@ function showExportDialog({
onChooseDir,
onExport,
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) => {
const formatOptions = (formats || []).map((f) => {
@@ -305,21 +309,91 @@ function showExportDialog({
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.');
const optionsHost = el('div', { className: 'export-options' });
function refreshTemplates() {
const list = templatesByFormat[formatSelect.value] || [];
// The effective option set shown to (and edited by) the user.
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);
templateSelect.append(el('option', { value: '' }, 'Default template'));
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();
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', {},
labeledRow('Format', formatSelect),
labeledRow('Template', templateSelect),
labeledRow('Template', el('div.row', {}, templateSelect, saveTplBtn, manageBtn)),
labeledRow('Output folder', el('div.row', {}, outDirInput, el('button', {
type: 'button',
disabled: typeof onChooseDir !== 'function',
@@ -329,9 +403,16 @@ function showExportDialog({
if (chosen) outDirInput.value = chosen;
},
}, '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({
title: 'Export',
body,
@@ -340,25 +421,13 @@ function showExportDialog({
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);
}
await onPreview(payload()); // keep dialog open so settings can be tweaked
},
}, '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,
});
const ok = await onExport(payload());
if (ok !== false) {
close();
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 } = {}) {
return new Promise((resolve) => {
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) {
return new Promise((resolve) => {
const { close } = openModal({
@@ -439,5 +700,9 @@ window.StepForgeDialogs = {
showExportDialog,
showLinkedGuideDialog,
showInfoDialog,
showBackupsDialog,
showPlaceholdersDialog,
showShortcutsDialog,
showTemplateManager,
};
})();