6e790832f5
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>
709 lines
25 KiB
JavaScript
709 lines
25 KiB
JavaScript
'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,
|
||
};
|
||
})();
|