Files
autodoc/app/renderer/editor.js
T
Iisyourdad f47aca67c2
Template tests / tests (push) Failing after 4s
Finish Electron shell and workflow wiring
2026-06-10 18:32:30 -05:00

1209 lines
44 KiB
JavaScript
Raw 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';
const api = window.stepforge;
const dialogs = window.StepForgeDialogs || {};
const clone = (value) => JSON.parse(JSON.stringify(value));
function stepNumberMap(steps) {
const numbers = new Map();
const childCounts = new Map();
let top = 0;
for (const step of steps) {
let number;
if (step.parentStepId && numbers.has(step.parentStepId)) {
const parent = numbers.get(step.parentStepId);
const next = (childCounts.get(step.parentStepId) || 0) + 1;
childCounts.set(step.parentStepId, next);
number = `${parent}.${next}`;
} else {
top += 1;
number = String(top);
}
numbers.set(step.stepId, number);
}
return numbers;
}
function isEditableTarget(target) {
return target && (
target.tagName === 'INPUT' ||
target.tagName === 'TEXTAREA' ||
target.isContentEditable
);
}
class GuideEditor {
constructor({ root, onMetaChange = () => {}, onToast = toast, onBack = () => {} } = {}) {
this.root = root;
this.onMetaChange = onMetaChange;
this.onToast = onToast;
this.onBack = onBack;
this.guideId = null;
this.guide = null;
this.steps = [];
this.stepMap = new Map();
this.selectedStepId = null;
this.selectedAnnotationId = null;
this.currentTool = 'select';
this.currentZoom = 'fit';
this.pendingSave = false;
this.pendingGuideSave = false;
this.canvasHistory = [];
this.canvasFuture = [];
this.beforeCanvasSnapshot = null;
this.stepLoadToken = 0;
this.imageLoadToken = 0;
this.shellMounted = false;
this.linkedConflict = false;
this.descriptionDirty = false;
this.titleDirty = false;
this.active = true;
this.saveStepDebounced = debounce(() => this.flushStep(), 180);
this.saveGuideDebounced = debounce(() => this.flushGuide(), 180);
this.onDocumentKeyDown = this.onDocumentKeyDown.bind(this);
document.addEventListener('keydown', this.onDocumentKeyDown, true);
}
destroy() {
document.removeEventListener('keydown', this.onDocumentKeyDown, true);
if (this.resizeObserver) this.resizeObserver.disconnect();
}
setActive(active) {
this.active = Boolean(active);
}
get currentStep() {
return this.stepMap.get(this.selectedStepId) || null;
}
get currentStepNumber() {
if (!this.currentStep) return '';
return stepNumberMap(this.steps).get(this.currentStep.stepId) || '';
}
getMeta() {
return {
guide: this.guide ? clone(this.guide) : null,
step: this.currentStep ? clone(this.currentStep) : null,
stepCount: this.steps.length,
selectedStepId: this.selectedStepId,
selectedAnnotationId: this.selectedAnnotationId,
linked: Boolean(this.guide && this.guide.linkedSource),
dirty: this.pendingSave || this.pendingGuideSave || this.descriptionDirty || this.titleDirty,
view: 'editor',
};
}
emitMeta() {
this.onMetaChange(this.getMeta());
}
async open(guideId, stepId = null) {
this.guideId = guideId;
this.selectedStepId = stepId;
this.selectedAnnotationId = null;
this.canvasHistory = [];
this.canvasFuture = [];
this.pendingSave = false;
this.pendingGuideSave = false;
this.setActive(true);
await this.reload(stepId);
}
async reload(stepId = this.selectedStepId) {
const token = ++this.stepLoadToken;
const { guide, steps } = await api.guide.get({ guideId: this.guideId });
if (token !== this.stepLoadToken) return;
this.guide = guide;
this.steps = steps;
this.stepMap = new Map(steps.map((step) => [step.stepId, step]));
if (!this.shellMounted) this.mountShell();
if (!this.selectedStepId || !this.stepMap.has(this.selectedStepId)) {
this.selectedStepId = stepId && this.stepMap.has(stepId) ? stepId : (steps[0] && steps[0].stepId) || null;
}
this.selectedAnnotationId = null;
this.renderAll();
}
mountShell() {
this.shellMounted = true;
this.root.innerHTML = '';
const toolButtons = [
['select', 'Select'],
['rect', 'Rect'],
['oval', 'Oval'],
['line', 'Line'],
['arrow', 'Arrow'],
['text', 'Text'],
['tooltip', 'Tip'],
['number', '#'],
['blur', 'Blur'],
['highlight', 'Hi'],
['magnify', 'Mag'],
['cursor', 'Cursor'],
['crop', 'Crop'],
];
this.dom = {};
this.dom.root = el('div.editor', {},
el('aside.pane-steps', {},
el('div.pane-head', {},
el('div', {},
el('div.eyebrow', {}, 'Steps'),
this.dom.stepCount = el('div.muted', {}, '0 steps'),
),
el('div.row', {},
this.dom.addStepBtn = el('button.primary', { type: 'button' }, 'Add'),
this.dom.importBtn = el('button', { type: 'button' }, 'Import'),
),
),
this.dom.stepsList = el('div.steps-list'),
el('div.pane-foot', {},
this.dom.moveUpBtn = el('button.icon', { type: 'button', title: 'Move step up' }, '↑'),
this.dom.moveDownBtn = el('button.icon', { type: 'button', title: 'Move step down' }, '↓'),
this.dom.duplicateBtn = el('button', { type: 'button' }, 'Duplicate'),
this.dom.deleteBtn = el('button.danger', { type: 'button' }, 'Delete'),
),
),
el('section.pane-canvas', {},
el('div.canvas-toolbar', {},
...toolButtons.map(([tool, label]) => this.dom[`tool-${tool}`] = el('button.tool', { type: 'button', dataset: { tool } }, label)),
el('span.sep'),
this.dom.zoomFitBtn = el('button.tool', { type: 'button' }, 'Fit'),
this.dom.zoom100Btn = el('button.tool', { type: 'button' }, '100%'),
this.dom.zoom125Btn = el('button.tool', { type: 'button' }, '125%'),
this.dom.zoom150Btn = el('button.tool', { type: 'button' }, '150%'),
el('span.sep'),
this.dom.undoBtn = el('button.tool', { type: 'button' }, 'Undo'),
this.dom.redoBtn = el('button.tool', { type: 'button' }, 'Redo'),
),
this.dom.canvasWrap = el('div.canvas-wrap', {},
this.dom.canvas = el('canvas', { width: 1, height: 1 }),
this.dom.canvasEmpty = el('div.canvas-empty', {}, 'Select an image step to edit annotations.'),
),
),
el('aside.pane-props', {},
el('section', {},
el('h3', {}, 'Step'),
this.dom.titleInput = el('input', { type: 'text', placeholder: 'Step title' }),
this.dom.statusSelect = makeSelect('todo', [
{ value: 'todo', label: 'Todo' },
{ value: 'in-progress', label: 'In progress' },
{ value: 'done', label: 'Done' },
]),
el('div.row', {},
this.dom.hiddenToggle = el('label', {}, el('input', { type: 'checkbox' }), ' Hidden'),
this.dom.skippedToggle = el('label', {}, el('input', { type: 'checkbox' }), ' Skipped'),
),
el('div.row', {},
this.dom.forceNewPageToggle = el('label', {}, el('input', { type: 'checkbox' }), ' New page'),
this.dom.focusedViewToggle = el('label', {}, el('input', { type: 'checkbox' }), ' Focused'),
),
),
el('section', {},
el('h3', {}, 'Description'),
this.dom.richToolbar = el('div.rich-toolbar', {},
this.toolbarBtn('bold', 'Bold'),
this.toolbarBtn('italic', 'Italic'),
this.toolbarBtn('insertUnorderedList', 'Bullet'),
this.toolbarBtn('insertOrderedList', 'Number'),
this.toolbarBtn('formatBlock', 'Quote', 'blockquote'),
this.toolbarBtn('createLink', 'Link'),
this.toolbarBtn('removeFormat', 'Clear'),
),
this.dom.descEditor = el('div.rich-editor', { contentEditable: 'true', spellcheck: true }),
),
el('section', {},
el('h3', {}, 'Annotations'),
this.dom.annotationList = el('div', { className: 'annotation-list' }),
this.dom.annotationEditor = el('div', { className: 'annotation-editor' }),
),
el('section', {},
el('h3', {}, 'Guide'),
this.dom.guideSummary = el('div.muted', {}),
this.dom.linkedBtn = el('button', { type: 'button' }, 'Linked guide'),
this.dom.saveNowBtn = el('button.primary', { type: 'button' }, 'Save now'),
this.dom.snapshotBtn = el('button', { type: 'button' }, 'Snapshot'),
),
),
);
this.root.append(this.dom.root);
// canvas interactions need to snapshot the current step before the drag
// mutates it, so undo can restore the pre-edit annotations.
this.dom.canvas.addEventListener('pointerdown', () => {
if (this.currentStep) this.beforeCanvasSnapshot = clone(this.currentStep);
}, true);
this.canvas = new AnnotationCanvas(this.dom.canvas, {
onChange: (annotations) => this.onCanvasChange(annotations),
onSelect: (ann) => this.onCanvasSelect(ann),
onCrop: (rect) => this.onCanvasCrop(rect),
onRequestText: (ann) => this.editAnnotationText(ann),
defaultStyle: (tool) => this.defaultStyleForTool(tool),
nextNumber: () => this.nextAnnotationNumber(),
});
this.resizeObserver = new ResizeObserver(() => this.canvas.applyZoom());
this.resizeObserver.observe(this.dom.canvasWrap);
this.bindShellEvents();
}
toolbarBtn(action, label, block = null) {
return el('button', {
type: 'button',
onClick: () => this.formatDescription(action, block),
}, label);
}
bindShellEvents() {
this.dom.addStepBtn.addEventListener('click', () => this.addEmptyStep());
this.dom.importBtn.addEventListener('click', () => this.importImageSteps());
this.dom.moveUpBtn.addEventListener('click', () => this.moveSelectedStep(-1));
this.dom.moveDownBtn.addEventListener('click', () => this.moveSelectedStep(1));
this.dom.duplicateBtn.addEventListener('click', () => this.duplicateSelectedStep());
this.dom.deleteBtn.addEventListener('click', () => this.deleteSelectedStep());
this.dom.saveNowBtn.addEventListener('click', () => this.saveAll());
this.dom.snapshotBtn.addEventListener('click', () => this.createSnapshot());
this.dom.linkedBtn.addEventListener('click', () => this.openLinkedGuide());
this.dom.zoomFitBtn.addEventListener('click', () => this.setZoom('fit'));
this.dom.zoom100Btn.addEventListener('click', () => this.setZoom(1));
this.dom.zoom125Btn.addEventListener('click', () => this.setZoom(1.25));
this.dom.zoom150Btn.addEventListener('click', () => this.setZoom(1.5));
this.dom.undoBtn.addEventListener('click', () => this.undo());
this.dom.redoBtn.addEventListener('click', () => this.redo());
Object.entries(this.dom).forEach(([key, value]) => {
if (key.startsWith('tool-')) {
value.addEventListener('click', () => this.setTool(value.dataset.tool));
}
});
this.dom.titleInput.addEventListener('focus', () => {
if (this.currentStep) this.pushCanvasHistory('title');
});
this.dom.titleInput.addEventListener('input', () => {
if (!this.currentStep) return;
this.currentStep.title = this.dom.titleInput.value;
this.pendingSave = true;
this.saveStepDebounced();
this.renderStepList();
this.emitMeta();
});
this.dom.statusSelect.addEventListener('change', () => {
if (!this.currentStep) return;
this.currentStep.status = this.dom.statusSelect.value;
this.pendingSave = true;
this.saveStepDebounced();
this.renderStepList();
this.emitMeta();
});
const bindCheckbox = (node, field) => node.addEventListener('change', () => {
if (!this.currentStep) return;
this.currentStep[field] = node.checked;
this.pendingSave = true;
this.saveStepDebounced();
this.renderStepList();
this.emitMeta();
});
bindCheckbox(this.dom.hiddenToggle.querySelector('input'), 'hidden');
bindCheckbox(this.dom.skippedToggle.querySelector('input'), 'skipped');
bindCheckbox(this.dom.forceNewPageToggle.querySelector('input'), 'forceNewPage');
bindCheckbox(this.dom.focusedViewToggle.querySelector('input'), 'focusedViewDefault');
this.dom.descEditor.addEventListener('focus', () => {
if (this.currentStep) this.pushCanvasHistory('description');
});
this.dom.descEditor.addEventListener('input', () => {
if (!this.currentStep) return;
this.currentStep.descriptionHtml = this.dom.descEditor.innerHTML;
this.pendingSave = true;
this.saveStepDebounced();
this.emitMeta();
});
this.dom.descEditor.addEventListener('paste', (e) => {
// Keep pasted text simple; backend sanitization will handle the rest.
e.preventDefault();
const text = e.clipboardData.getData('text/plain');
document.execCommand('insertText', false, text);
});
this.dom.annotationList.addEventListener('click', (e) => {
const item = e.target.closest('[data-ann-id]');
if (!item) return;
this.canvas.select(item.dataset.annId);
});
}
renderAll() {
this.renderStepList();
this.syncStepFields();
this.renderCanvas();
this.renderAnnotationPanel();
this.renderGuidePanel();
this.emitMeta();
}
renderStepList() {
const current = this.currentStep;
const numbers = stepNumberMap(this.steps);
clearNode(this.dom.stepsList);
this.dom.stepCount.textContent = `${this.steps.length} step${this.steps.length === 1 ? '' : 's'}`;
for (const step of this.steps) {
const number = numbers.get(step.stepId) || '';
let depth = 0;
let parent = step.parentStepId;
while (parent && this.stepMap.has(parent)) {
depth += 1;
parent = this.stepMap.get(parent).parentStepId;
}
const selected = current && current.stepId === step.stepId;
const item = el('div.step-item', {
className: `step-item${selected ? ' selected' : ''}${depth ? ' sub' : ''}${step.skipped ? ' skipped' : ''}${step.hidden ? ' hiddenstep' : ''}`,
dataset: { stepId: step.stepId },
onClick: () => this.selectStep(step.stepId),
onContextMenu: (e) => {
e.preventDefault();
this.selectStep(step.stepId);
contextMenu(e.clientX, e.clientY, [
{ label: 'Add substep', action: () => this.addSubstep(step.stepId) },
{ label: 'Duplicate step', action: () => this.duplicateSelectedStep() },
'sep',
{ label: 'Move up', action: () => this.moveSelectedStep(-1) },
{ label: 'Move down', action: () => this.moveSelectedStep(1) },
'sep',
{ label: 'Delete step', danger: true, action: () => this.deleteSelectedStep() },
]);
},
},
el('span.status-dot', { className: `status-dot status-${step.status}` }),
el('span.num', {}, number || '•'),
el('span.t', {}, step.title || 'Untitled step'),
el('span.flags', {}, [
step.parentStepId ? 'sub' : '',
step.hidden ? 'hidden' : '',
step.skipped ? 'skipped' : '',
].filter(Boolean).join(' · ')));
this.dom.stepsList.append(item);
}
if (!this.steps.length) {
this.dom.stepsList.append(el('div.empty-state', { style: { marginTop: '40px' } }, 'No steps yet.'));
}
}
syncStepFields() {
const step = this.currentStep;
const guide = this.guide;
if (!step) {
this.dom.titleInput.value = '';
this.dom.descEditor.innerHTML = '';
this.dom.statusSelect.value = 'todo';
this.dom.hiddenToggle.querySelector('input').checked = false;
this.dom.skippedToggle.querySelector('input').checked = false;
this.dom.forceNewPageToggle.querySelector('input').checked = false;
this.dom.focusedViewToggle.querySelector('input').checked = false;
this.dom.guideSummary.textContent = guide ? guide.title : '';
return;
}
if (document.activeElement !== this.dom.titleInput) this.dom.titleInput.value = step.title || '';
if (document.activeElement !== this.dom.descEditor) this.dom.descEditor.innerHTML = step.descriptionHtml || '';
this.dom.statusSelect.value = step.status || 'todo';
this.dom.hiddenToggle.querySelector('input').checked = Boolean(step.hidden);
this.dom.skippedToggle.querySelector('input').checked = Boolean(step.skipped);
this.dom.forceNewPageToggle.querySelector('input').checked = Boolean(step.forceNewPage);
this.dom.focusedViewToggle.querySelector('input').checked = Boolean(step.focusedView?.enabled);
this.dom.guideSummary.textContent = guide
? `${guide.title} · ${guide.linkedSource ? 'linked' : 'local'} · ${this.steps.length} steps`
: '';
}
async renderCanvas() {
const step = this.currentStep;
const token = ++this.imageLoadToken;
this.canvas.setTool(this.currentTool);
this.canvas.setZoom(this.currentZoom);
if (!step || !step.image) {
this.canvas.setImage(null, 0, 0);
this.dom.canvasEmpty.classList.remove('hidden');
return;
}
this.dom.canvasEmpty.classList.add('hidden');
const src = await api.step.imagePath({
guideId: this.guideId,
stepId: step.stepId,
which: 'working',
});
if (token !== this.imageLoadToken || !src) return;
const img = new Image();
img.onload = () => {
if (token !== this.imageLoadToken) return;
this.canvas.setImage(img, img.naturalWidth || img.width, img.naturalHeight || img.height);
this.canvas.setAnnotations(step.annotations || []);
this.canvas.setTool(this.currentTool);
this.canvas.setZoom(this.currentZoom);
};
img.onerror = () => {
if (token !== this.imageLoadToken) return;
this.canvas.setImage(null, 0, 0);
this.dom.canvasEmpty.classList.remove('hidden');
};
img.src = src;
}
renderAnnotationPanel() {
clearNode(this.dom.annotationList);
const step = this.currentStep;
if (!step) {
this.dom.annotationList.append(el('div.muted', {}, 'No step selected.'));
clearNode(this.dom.annotationEditor);
this.dom.annotationEditor.append(el('div.muted', {}, 'Select a step to edit annotations.'));
return;
}
const anns = step.annotations || [];
if (!anns.length) {
this.dom.annotationList.append(el('div.muted', {}, 'No annotations yet. Pick a tool and drag on the canvas.'));
} else {
for (const ann of anns) {
const selected = this.canvas.selected() && this.canvas.selected().id === ann.id;
this.dom.annotationList.append(el('div.block-card', {
dataset: { annId: ann.id },
style: { cursor: 'pointer', borderColor: selected ? 'var(--accent)' : '' },
},
el('div.row', {}, el('strong', {}, ann.type), el('span.muted', {}, ann.text || ann.value || '')),
el('div.muted', {}, `${ann.x.toFixed(3)}, ${ann.y.toFixed(3)} · ${ann.w.toFixed(3)} × ${ann.h.toFixed(3)}`)));
}
}
const selected = this.canvas.selected();
clearNode(this.dom.annotationEditor);
if (!selected) {
this.dom.annotationEditor.append(el('div.muted', {}, 'Select an annotation to edit its style.'));
return;
}
const style = selected.style || {};
const typeSelect = makeSelect(selected.type, [
'rect', 'oval', 'line', 'arrow', 'text', 'tooltip', 'number', 'blur', 'highlight', 'magnify', 'cursor',
].map((type) => ({ value: type, label: type })));
const textInput = el('input', { type: 'text', value: selected.text || '', placeholder: 'Annotation text' });
const valueInput = el('input', { type: 'number', value: Number.isFinite(selected.value) ? selected.value : '', placeholder: 'Value' });
const strokeInput = el('input', { type: 'color', value: style.stroke || '#E5484D' });
const fillInput = el('input', { type: 'color', value: style.fill && style.fill !== 'transparent' ? style.fill : '#ffffff' });
const strokeWidthInput = el('input', { type: 'number', min: 1, step: 1, value: style.strokeWidth || 3 });
const fontSizeInput = el('input', { type: 'number', min: 0.01, step: 0.001, value: style.fontSize || 0.022 });
const textColorInput = el('input', { type: 'color', value: style.textColor || '#ffffff' });
const zoomInput = el('input', { type: 'number', min: 1, step: 0.1, value: selected.zoom || 2 });
const radiusInput = el('input', { type: 'number', min: 1, step: 1, value: selected.radius || 8 });
const tailInput = makeSelect(style.tail || 'bottom', [
{ value: 'bottom', label: 'Bottom' },
{ value: 'top', label: 'Top' },
{ value: 'left', label: 'Left' },
{ value: 'right', label: 'Right' },
]);
const apply = async (patch) => {
const ann = this.canvas.selected();
if (!ann) return;
Object.assign(ann, patch);
this.beforeCanvasSnapshot = null;
this.pendingSave = true;
this.canvas.setAnnotations(step.annotations || []);
this.canvas.select(ann.id);
await this.flushStep();
this.renderAnnotationPanel();
this.renderStepList();
this.emitMeta();
};
const annSection = el('div', { className: 'annotation-editor-inner' },
labeledRow('Type', typeSelect),
labeledRow('Text', textInput),
labeledRow('Value', valueInput),
labeledRow('Stroke', strokeInput),
labeledRow('Fill', fillInput),
labeledRow('Stroke width', strokeWidthInput),
labeledRow('Font size', fontSizeInput),
labeledRow('Text color', textColorInput),
labeledRow('Zoom', zoomInput),
labeledRow('Radius', radiusInput),
labeledRow('Tail', tailInput),
el('div.row', {},
el('button', {
type: 'button',
onClick: () => {
this.canvas.deleteSelected();
},
}, 'Delete annotation'),
),
);
this.dom.annotationEditor.append(annSection);
typeSelect.addEventListener('change', async () => {
const ann = this.canvas.selected();
if (!ann) return;
await apply({ type: typeSelect.value });
if (ann.type === 'tooltip') this.editAnnotationText(ann);
});
textInput.addEventListener('focus', () => this.pushCanvasHistory('annotation-text'));
textInput.addEventListener('input', async () => {
const ann = this.canvas.selected();
if (!ann) return;
await apply({ text: textInput.value });
});
valueInput.addEventListener('input', async () => {
const ann = this.canvas.selected();
if (!ann) return;
const next = valueInput.value === '' ? null : Number(valueInput.value);
await apply({ value: Number.isFinite(next) ? next : null });
});
strokeInput.addEventListener('input', async () => {
const ann = this.canvas.selected();
if (!ann) return;
await apply({ style: { ...ann.style, stroke: strokeInput.value } });
});
fillInput.addEventListener('input', async () => {
const ann = this.canvas.selected();
if (!ann) return;
await apply({ style: { ...ann.style, fill: fillInput.value } });
});
strokeWidthInput.addEventListener('input', async () => {
const ann = this.canvas.selected();
if (!ann) return;
await apply({ style: { ...ann.style, strokeWidth: Number(strokeWidthInput.value || 1) } });
});
fontSizeInput.addEventListener('input', async () => {
const ann = this.canvas.selected();
if (!ann) return;
await apply({ style: { ...ann.style, fontSize: Number(fontSizeInput.value || 0.022) } });
});
textColorInput.addEventListener('input', async () => {
const ann = this.canvas.selected();
if (!ann) return;
await apply({ style: { ...ann.style, textColor: textColorInput.value } });
});
zoomInput.addEventListener('input', async () => {
const ann = this.canvas.selected();
if (!ann) return;
await apply({ zoom: Number(zoomInput.value || 2) });
});
radiusInput.addEventListener('input', async () => {
const ann = this.canvas.selected();
if (!ann) return;
await apply({ radius: Number(radiusInput.value || 8) });
});
tailInput.addEventListener('change', async () => {
const ann = this.canvas.selected();
if (!ann) return;
await apply({ style: { ...ann.style, tail: tailInput.value } });
});
}
renderGuidePanel() {
if (!this.guide) return;
this.dom.linkedBtn.textContent = this.guide.linkedSource ? 'Linked guide' : 'Local guide';
this.dom.linkedBtn.disabled = !this.guide.linkedSource;
this.dom.snapshotBtn.textContent = 'Snapshot';
}
defaultStyleForTool(tool) {
switch (tool) {
case 'highlight': return { fill: '#ffe066', stroke: '#ffbf00', strokeWidth: 1 };
case 'tooltip': return { fill: '#111827', textColor: '#ffffff', stroke: '#111827', tail: 'bottom' };
case 'number': return { fill: '#1f6feb', stroke: '#1f6feb', textColor: '#ffffff' };
case 'blur': return { fill: 'transparent', stroke: '#9ca3af', strokeWidth: 2 };
case 'cursor': return { fill: '#ffffff', stroke: '#111827', strokeWidth: 2 };
default: return { fill: 'transparent', stroke: '#E5484D', strokeWidth: 3, textColor: '#ffffff' };
}
}
nextAnnotationNumber() {
const step = this.currentStep;
if (!step) return 1;
const nums = (step.annotations || []).filter((ann) => ann.type === 'number').map((ann) => Number(ann.value) || 0);
return (nums.length ? Math.max(...nums) : 0) + 1;
}
setTool(tool) {
this.currentTool = tool;
this.canvas.setTool(tool);
for (const [key, node] of Object.entries(this.dom)) {
if (!key.startsWith('tool-')) continue;
node.classList.toggle('active', node.dataset.tool === tool);
}
}
setZoom(mode) {
this.currentZoom = mode;
this.canvas.setZoom(mode);
this.canvas.applyZoom();
const buttons = [this.dom.zoomFitBtn, this.dom.zoom100Btn, this.dom.zoom125Btn, this.dom.zoom150Btn];
buttons.forEach((btn) => btn.classList.remove('active'));
if (mode === 'fit') this.dom.zoomFitBtn.classList.add('active');
if (mode === 1) this.dom.zoom100Btn.classList.add('active');
if (mode === 1.25) this.dom.zoom125Btn.classList.add('active');
if (mode === 1.5) this.dom.zoom150Btn.classList.add('active');
}
pushCanvasHistory(label = 'change') {
if (!this.currentStep) return;
this.canvasHistory.push(clone(this.currentStep));
if (this.canvasHistory.length > 40) this.canvasHistory.shift();
this.canvasFuture.length = 0;
this.beforeCanvasSnapshot = null;
}
async undo() {
if (!this.currentStep) return;
if (!this.canvasHistory.length) {
this.onToast('Nothing to undo.');
return;
}
this.canvasFuture.push(clone(this.currentStep));
const previous = this.canvasHistory.pop();
this.stepMap.set(previous.stepId, previous);
this.selectedStepId = previous.stepId;
await this.flushStep(previous);
this.renderAll();
}
async redo() {
if (!this.currentStep) return;
if (!this.canvasFuture.length) {
this.onToast('Nothing to redo.');
return;
}
this.canvasHistory.push(clone(this.currentStep));
const next = this.canvasFuture.pop();
this.stepMap.set(next.stepId, next);
this.selectedStepId = next.stepId;
await this.flushStep(next);
this.renderAll();
}
async flushStep(step = this.currentStep) {
if (!step) return;
this.pendingSave = false;
const saved = await api.step.save({ guideId: this.guideId, step });
this.stepMap.set(saved.stepId, saved);
if (this.selectedStepId === saved.stepId) {
this.stepMap.set(saved.stepId, saved);
this.renderStepList();
this.syncStepFields();
this.canvas.setAnnotations(saved.annotations || []);
this.renderAnnotationPanel();
this.emitMeta();
}
return saved;
}
async flushGuide() {
if (!this.guide) return;
this.pendingGuideSave = false;
await api.guide.save({ guide: this.guide });
this.emitMeta();
}
async saveAll() {
if (this.currentStep) await this.flushStep();
if (this.guide) await this.flushGuide();
this.onToast('Saved.');
}
async createSnapshot() {
if (!this.guideId) return;
await api.snapshots.create({ guideId: this.guideId, label: 'manual' });
this.onToast('Snapshot created.');
}
async selectStep(stepId) {
if (!this.stepMap.has(stepId)) return;
this.selectedStepId = stepId;
this.selectedAnnotationId = null;
this.canvas.select(null);
this.syncStepFields();
this.renderStepList();
this.renderCanvas();
this.renderAnnotationPanel();
this.emitMeta();
}
async addEmptyStep() {
const title = await dialogs.promptText({
title: 'Add Step',
label: 'Step title',
value: '',
placeholder: 'Untitled step',
});
if (title == null) return;
const step = await api.step.add({
guideId: this.guideId,
fields: {
kind: 'empty',
title: title.trim() || 'Untitled step',
status: 'todo',
},
position: this.steps.length,
});
await this.reload(step.stepId);
this.onToast('Step added.');
}
async addSubstep(parentStepId = this.selectedStepId) {
if (!parentStepId) return;
const title = await dialogs.promptText({
title: 'Add Substep',
label: 'Substep title',
value: '',
placeholder: 'Untitled substep',
});
if (title == null) return;
const parent = this.stepMap.get(parentStepId);
const parentIndex = this.steps.findIndex((s) => s.stepId === parentStepId);
const step = await api.step.add({
guideId: this.guideId,
fields: {
kind: 'empty',
title: title.trim() || 'Untitled substep',
parentStepId,
status: 'todo',
},
position: parentIndex + 1,
});
await this.reload(step.stepId);
this.onToast(parent ? 'Substep added.' : 'Step added.');
}
async duplicateSelectedStep() {
const step = this.currentStep;
if (!step) return;
const copy = clone(step);
copy.stepId = undefined;
copy.title = copy.title ? `${copy.title} copy` : 'Untitled step copy';
const image = await this.currentStepImageToBase64();
const newStep = await api.step.add({
guideId: this.guideId,
fields: {
...copy,
image: undefined,
},
imageBase64: image ? image.base64 : null,
size: image ? image.size : null,
position: this.steps.findIndex((s) => s.stepId === step.stepId) + 1,
});
await this.reload(newStep.stepId);
this.onToast('Step duplicated.');
}
async deleteSelectedStep() {
const step = this.currentStep;
if (!step) return;
const ok = await confirmDialog(`Delete “${step.title || 'Untitled step'}”?`, { danger: true, okLabel: 'Delete' });
if (!ok) return;
await api.step.delete({ guideId: this.guideId, stepId: step.stepId });
const next = this.steps[this.steps.findIndex((s) => s.stepId === step.stepId) + 1]
|| this.steps[this.steps.findIndex((s) => s.stepId === step.stepId) - 1]
|| null;
await this.reload(next && next.stepId);
this.onToast('Step deleted.');
}
async moveSelectedStep(delta) {
const step = this.currentStep;
if (!step) return;
const idx = this.steps.findIndex((s) => s.stepId === step.stepId);
const nextIdx = idx + delta;
if (nextIdx < 0 || nextIdx >= this.steps.length) return;
const order = this.steps.map((s) => s.stepId);
const [item] = order.splice(idx, 1);
order.splice(nextIdx, 0, item);
await api.step.reorder({ guideId: this.guideId, order });
await this.reload(step.stepId);
}
async importImageSteps() {
const result = await api.step.importImage({ guideId: this.guideId });
if (!result || !result.ok) return;
const last = result.steps && result.steps[result.steps.length - 1];
await this.reload(last ? last.stepId : this.selectedStepId);
this.onToast('Images imported.');
}
async captureStep(mode) {
const result = await api.capture.shoot({ guideId: this.guideId, mode, delayMs: 0 });
if (result && result.ok) {
await this.reload(result.step.stepId);
this.onToast('Captured.');
} else if (result && result.reason) {
this.onToast(result.reason, { error: true });
}
}
async startCaptureSession() {
await api.capture.session({ action: 'start', guideId: this.guideId });
this.onToast('Capture session started.');
this.emitMeta();
}
async pauseCaptureSession() {
await api.capture.session({ action: 'pause', guideId: this.guideId });
this.onToast('Capture paused.');
this.emitMeta();
}
async resumeCaptureSession() {
await api.capture.session({ action: 'resume', guideId: this.guideId });
this.onToast('Capture resumed.');
this.emitMeta();
}
async finishCaptureSession() {
await api.capture.session({ action: 'finish', guideId: this.guideId });
this.onToast('Capture session finished.');
this.emitMeta();
}
async openSettings() {
const settings = await api.settings.all();
const placeholders = await api.settings.globalPlaceholders();
await dialogs.showSettingsDialog({
settings,
placeholders,
onSave: async (next) => {
await api.settings.set({ keyPath: 'appearance', value: next.appearance });
await api.settings.set({ keyPath: 'spellcheck', value: next.spellcheck });
await api.settings.set({ keyPath: 'capture', value: next.capture });
await api.settings.set({ keyPath: 'editor', value: next.editor });
await api.settings.set({ keyPath: 'exports', value: next.exports });
await api.settings.set({ keyPath: 'backups', value: next.backups });
await api.settings.setGlobalPlaceholders(next.placeholders || {});
},
});
}
async openExportDialog() {
const formats = (await api.export.formats()).map((id) => ({ id, label: id.replace(/-/g, ' ') }));
const templatesByFormat = {};
for (const format of formats) {
templatesByFormat[format.id] = await api.templates.list({ format: format.id });
}
const settings = await api.settings.all();
await dialogs.showExportDialog({
formats,
templatesByFormat,
defaultFormat: 'pdf',
defaultOutDir: settings.exports?.lastOutputDirs?.pdf || '',
onChooseDir: async (format) => api.export.chooseDir({ format }),
onPreview: async ({ format, templateName, outDir }) => {
const options = templateName ? await api.templates.load({ format, name: templateName }) : {};
const preview = await api.export.preview({ guideId: this.guideId, format, options });
if (preview && preview.file) await api.shell.showItemInFolder({ target: preview.file });
this.onToast(`Preview written to ${preview.file}`);
return true;
},
onExport: async ({ format, templateName, outDir }) => {
const options = templateName ? await api.templates.load({ format, name: templateName }) : {};
const result = await api.export.run({ guideId: this.guideId, format, options, outDir });
if (result && result.file) {
this.onToast(`Exported ${format}`);
}
return true;
},
});
}
async openLinkedGuide() {
if (!this.guide || !this.guide.linkedSource) {
await dialogs.showInfoDialog('Linked Guide', 'This guide is stored locally and is not linked to a shared archive.');
return;
}
const library = await api.library.list();
const guideMeta = library.guides.find((g) => g.guideId === this.guideId) || this.guide;
const locked = Boolean(guideMeta.locked);
await dialogs.showLinkedGuideDialog({
guide: guideMeta,
lock: locked ? { acquired: false } : { acquired: true },
onSave: async () => {
const result = await api.archive.saveLinked({ guideId: this.guideId, force: false });
if (result.saved) this.onToast('Linked archive saved.');
else this.onToast('Could not save linked archive.', { error: true });
},
onForceSave: async () => {
const result = await api.archive.saveLinked({ guideId: this.guideId, force: true });
if (result.saved) this.onToast('Linked archive force-saved.');
else this.onToast('Could not save linked archive.', { error: true });
},
onOpenArchive: async () => {
await api.shell.showItemInFolder({ target: this.guide.linkedSource.path });
},
});
}
async openQuickActions() {
const commands = [
{ kind: 'cmd', label: 'New guide', description: 'Create a blank guide', action: () => this.onBack('new') },
{ kind: 'cmd', label: 'Export', description: 'Export the current guide', action: () => this.openExportDialog() },
{ kind: 'cmd', label: 'Settings', description: 'Open application settings', action: () => this.openSettings() },
{ kind: 'cmd', label: 'Linked guide', description: 'Show linked archive details', action: () => this.openLinkedGuide() },
{ kind: 'cmd', label: 'Start capture session', description: 'Enable hotkey capture for this guide', action: () => this.startCaptureSession() },
];
await dialogs.showQuickActions({
commands,
searchFn: async (query) => {
const results = await api.search.query({ q: query });
return results.map((r) => ({
kind: r.stepId ? 'step' : 'guide',
label: r.title || '(untitled)',
description: r.snippet || '',
action: () => this.openSearchResult(r),
}));
},
});
}
async openSearchResult(result) {
if (!result) return;
if (result.stepId) {
await this.onBack();
await this.open(result.guideId, result.stepId);
} else {
await this.onBack();
await this.open(result.guideId, null);
}
}
async currentStepImageToBase64() {
const step = this.currentStep;
if (!step || !step.image) return null;
const file = await api.step.imagePath({ guideId: this.guideId, stepId: step.stepId, which: 'working' });
if (!file) return null;
return new Promise((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth || img.width;
canvas.height = img.naturalHeight || img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
const data = canvas.toDataURL('image/png').split(',')[1];
resolve({ base64: data, size: { width: canvas.width, height: canvas.height } });
};
img.onerror = () => resolve(null);
img.src = file;
});
}
async onCanvasChange(annotations) {
const step = this.currentStep;
if (!step) return;
if (this.beforeCanvasSnapshot) {
this.canvasHistory.push(this.beforeCanvasSnapshot);
if (this.canvasHistory.length > 40) this.canvasHistory.shift();
this.canvasFuture.length = 0;
this.beforeCanvasSnapshot = null;
}
step.annotations = clone(annotations || []);
this.pendingSave = true;
this.saveStepDebounced();
this.renderAnnotationPanel();
this.renderStepList();
this.emitMeta();
}
onCanvasSelect(ann) {
this.selectedAnnotationId = ann ? ann.id : null;
this.renderAnnotationPanel();
this.emitMeta();
}
async onCanvasCrop(rect) {
const step = this.currentStep;
if (!step || !step.image) return;
const ok = await confirmDialog('Crop the working image to the selected area?');
if (!ok) return;
const src = await api.step.imagePath({ guideId: this.guideId, stepId: step.stepId, which: 'working' });
if (!src) return;
const img = await loadImage(src);
if (!img) return;
const crop = rect;
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(crop.w * img.naturalWidth));
canvas.height = Math.max(1, Math.round(crop.h * img.naturalHeight));
const ctx = canvas.getContext('2d');
ctx.drawImage(
img,
Math.round(crop.x * img.naturalWidth),
Math.round(crop.y * img.naturalHeight),
Math.round(crop.w * img.naturalWidth),
Math.round(crop.h * img.naturalHeight),
0,
0,
canvas.width,
canvas.height,
);
const nextAnnotations = (step.annotations || []).map((ann) => {
const next = clone(ann);
next.x = (ann.x - crop.x) / crop.w;
next.y = (ann.y - crop.y) / crop.h;
next.w = ann.w / crop.w;
next.h = ann.h / crop.h;
return next;
});
await api.step.setWorkingImage({
guideId: this.guideId,
stepId: step.stepId,
pngBase64: canvas.toDataURL('image/png').split(',')[1],
size: { width: canvas.width, height: canvas.height },
});
step.image.size = { width: canvas.width, height: canvas.height };
step.annotations = nextAnnotations;
await this.flushStep(step);
await this.reload(step.stepId);
this.onToast('Image cropped.');
}
async editAnnotationText(ann) {
const step = this.currentStep;
if (!step || !ann) return;
const value = await dialogs.promptText({
title: ann.type === 'tooltip' ? 'Edit tooltip' : 'Edit text',
label: 'Text',
value: ann.text || '',
multiline: true,
});
if (value == null) return;
ann.text = value;
step.annotations = clone(step.annotations || []);
this.pendingSave = true;
await this.flushStep(step);
this.renderAnnotationPanel();
this.emitMeta();
}
formatDescription(command, block = null) {
const editor = this.dom.descEditor;
editor.focus();
switch (command) {
case 'bold':
document.execCommand('bold');
break;
case 'italic':
document.execCommand('italic');
break;
case 'insertUnorderedList':
document.execCommand('insertUnorderedList');
break;
case 'insertOrderedList':
document.execCommand('insertOrderedList');
break;
case 'formatBlock':
document.execCommand('formatBlock', false, block || 'blockquote');
break;
case 'createLink': {
const url = window.prompt('Link URL');
if (url) document.execCommand('createLink', false, url);
break;
}
case 'removeFormat':
document.execCommand('removeFormat');
break;
default:
break;
}
if (this.currentStep) {
this.currentStep.descriptionHtml = editor.innerHTML;
this.pendingSave = true;
this.saveStepDebounced();
}
}
onDocumentKeyDown(e) {
if (!this.active || !this.guide) return;
if ((e.ctrlKey || e.metaKey) && e.key === '/' && !e.shiftKey) {
e.preventDefault();
this.openQuickActions();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 's') {
e.preventDefault();
this.saveAll();
return;
}
if (e.key === 'Escape' && !isEditableTarget(e.target)) {
if (this.selectedAnnotationId && this.canvas.deleteSelected()) {
e.preventDefault();
this.saveStepDebounced();
return;
}
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z' && !isEditableTarget(e.target)) {
e.preventDefault();
if (e.shiftKey) this.redo();
else this.undo();
return;
}
if (!isEditableTarget(e.target)) {
if (e.key === 'Delete' && this.selectedAnnotationId) {
e.preventDefault();
if (this.canvas.deleteSelected()) this.saveStepDebounced();
return;
}
if (e.key === 'ArrowUp' && e.altKey) {
e.preventDefault();
this.moveSelectedStep(-1);
return;
}
if (e.key === 'ArrowDown' && e.altKey) {
e.preventDefault();
this.moveSelectedStep(1);
return;
}
if (e.key.startsWith('Arrow')) {
const dx = e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0;
const dy = e.key === 'ArrowUp' ? -1 : e.key === 'ArrowDown' ? 1 : 0;
if (dx || dy) {
const moved = this.canvas.nudgeSelected(dx, dy);
if (moved) {
const step = this.currentStep;
if (step) {
step.annotations = clone(this.canvas.annotations || []);
this.pendingSave = true;
this.saveStepDebounced();
}
e.preventDefault();
}
}
}
}
}
}
function labeledRow(labelText, control) {
return el('div.form-row', {}, el('label', {}, labelText), control);
}
function makeSelect(value, options) {
return el('select', {}, options.map((opt) => el('option', { value: opt.value, selected: opt.value === value }, opt.label)));
}
function loadImage(src) {
return new Promise((resolve) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = () => resolve(null);
img.src = src;
});
}
window.GuideEditor = GuideEditor;