Files
Iisyourdad 6e790832f5
Template tests / tests (push) Failing after 21s
Complete the app: capture UI, dialogs, template manager, shortcuts help
- 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>
2026-06-10 22:15:15 -05:00

709 lines
25 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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,
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) => {
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 optionsHost = el('div', { className: 'export-options' });
// 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(); 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', el('div.row', {}, templateSelect, saveTplBtn, manageBtn)),
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…'))),
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,
footer: [
el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'),
el('button', {
onClick: async () => {
if (typeof onPreview !== 'function') return;
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(payload());
if (ok !== false) {
close();
resolve(true);
}
},
}, 'Export'),
],
wide: true,
onClose: () => resolve(false),
});
});
}
/** 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 || {};
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 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({
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,
showBackupsDialog,
showPlaceholdersDialog,
showShortcutsDialog,
showTemplateManager,
};
})();