'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(); // An explicitly requested step (new capture, added step, restored // neighbour) wins; otherwise keep the current selection if it survived. if (stepId && this.stepMap.has(stepId)) { this.selectedStepId = stepId; } else if (!this.selectedStepId || !this.stepMap.has(this.selectedStepId)) { this.selectedStepId = (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'), ), this.dom.focusedControls = el('div.focused-controls.hidden', {}, el('div.form-row', {}, el('label', {}, 'Zoom'), this.dom.fvZoom = el('input', { type: 'range', min: 1, max: 3, step: 0.05, value: 1.5 })), el('div.form-row', {}, el('label', {}, 'Pan X'), this.dom.fvPanX = el('input', { type: 'range', min: 0, max: 1, step: 0.01, value: 0.5 })), el('div.form-row', {}, el('label', {}, 'Pan Y'), this.dom.fvPanY = el('input', { type: 'range', min: 0, max: 1, step: 0.01, value: 0.5 })), el('div.muted', {}, 'Exports crop to this view; the original image is never modified.'), ), ), 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', {}, 'Blocks'), this.dom.blocksList = el('div', { className: 'blocks-list' }), el('div.row', {}, this.dom.addTextBlockBtn = el('button', { type: 'button' }, '+ Text block'), this.dom.addCodeBlockBtn = el('button', { type: 'button' }, '+ Code'), this.dom.addTableBlockBtn = el('button', { type: 'button' }, '+ Table'), ), ), 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'); // Focused view lives under step.focusedView.enabled, not a flat field. const focusedInput = this.dom.focusedViewToggle.querySelector('input'); focusedInput.addEventListener('change', () => { if (!this.currentStep) return; this.currentStep.focusedView = { zoom: 1.5, panX: 0.5, panY: 0.5, ...(this.currentStep.focusedView || {}), enabled: focusedInput.checked, }; this.pendingSave = true; this.saveStepDebounced(); this.syncFocusedControls(); this.emitMeta(); }); const bindFocusedSlider = (node, field) => node.addEventListener('input', () => { const step = this.currentStep; if (!step || !step.focusedView) return; step.focusedView[field] = Number(node.value); this.pendingSave = true; this.saveStepDebounced(); }); bindFocusedSlider(this.dom.fvZoom, 'zoom'); bindFocusedSlider(this.dom.fvPanX, 'panX'); bindFocusedSlider(this.dom.fvPanY, 'panY'); this.dom.addTextBlockBtn.addEventListener('click', () => this.addBlock('text')); this.dom.addCodeBlockBtn.addEventListener('click', () => this.addBlock('code')); this.dom.addTableBlockBtn.addEventListener('click', () => this.addBlock('table')); 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.syncFocusedControls(); this.renderCanvas(); this.renderAnnotationPanel(); this.renderBlocksPanel(); this.renderGuidePanel(); this.emitMeta(); } syncFocusedControls() { const fv = this.currentStep?.focusedView; const enabled = Boolean(fv && fv.enabled); this.dom.focusedControls.classList.toggle('hidden', !enabled); if (enabled) { this.dom.fvZoom.value = fv.zoom || 1.5; this.dom.fvPanX.value = fv.panX ?? 0.5; this.dom.fvPanY.value = fv.panY ?? 0.5; } } // ---- text / code / table blocks ---------------------------------------- addBlock(kind) { const step = this.currentStep; if (!step) { this.onToast('Select a step first.', { error: true }); return; } const id = `blk-${Date.now().toString(36)}`; if (kind === 'text') { step.textBlocks = step.textBlocks || []; step.textBlocks.push({ id, position: 'after-description', level: 'info', title: '', descriptionHtml: '' }); } else if (kind === 'code') { step.codeBlocks = step.codeBlocks || []; step.codeBlocks.push({ id, language: '', code: '' }); } else if (kind === 'table') { step.tableBlocks = step.tableBlocks || []; step.tableBlocks.push({ id, rows: [['Column A', 'Column B'], ['', '']] }); } this.pendingSave = true; this.saveStepDebounced(); this.renderBlocksPanel(); } renderBlocksPanel() { clearNode(this.dom.blocksList); const step = this.currentStep; if (!step) { this.dom.blocksList.append(el('div.muted', {}, 'Select a step to add blocks.')); return; } const save = () => { this.pendingSave = true; this.saveStepDebounced(); }; const removeBtn = (onRemove) => el('button.icon.danger', { type: 'button', title: 'Remove block', onClick: () => { onRemove(); save(); this.renderBlocksPanel(); }, }, '✕'); for (const tb of step.textBlocks || []) { const position = makeSelect(tb.position, [ { value: 'before-title', label: 'Before title' }, { value: 'after-title', label: 'After title' }, { value: 'before-image', label: 'Before image' }, { value: 'after-image', label: 'After image' }, { value: 'before-description', label: 'Before description' }, { value: 'after-description', label: 'After description' }, ]); const level = makeSelect(tb.level, [ { value: 'info', label: 'Note' }, { value: 'warn', label: 'Warning' }, { value: 'error', label: 'Important' }, { value: 'success', label: 'Tip' }, ]); const title = el('input', { type: 'text', value: tb.title || '', placeholder: 'Block title' }); const body = el('textarea', { rows: 2, placeholder: 'Block text' }); body.value = (tb.descriptionHtml || '').replace(/<[^>]+>/g, ''); position.addEventListener('change', () => { tb.position = position.value; save(); }); level.addEventListener('change', () => { tb.level = level.value; save(); }); title.addEventListener('input', () => { tb.title = title.value; save(); }); body.addEventListener('input', () => { tb.descriptionHtml = `

${escapeHtml(body.value)}

`; save(); }); this.dom.blocksList.append(el('div.block-card', {}, el('div.row', {}, el('strong', {}, 'Text block'), el('span.spacer'), removeBtn(() => { step.textBlocks = step.textBlocks.filter((b) => b !== tb); })), el('div.row', {}, level, position), title, body, )); } for (const cb of step.codeBlocks || []) { const lang = el('input', { type: 'text', value: cb.language || '', placeholder: 'Language (e.g. bash)' }); const code = el('textarea', { rows: 3, placeholder: 'Code', spellcheck: false }); code.value = cb.code || ''; code.style.fontFamily = 'monospace'; lang.addEventListener('input', () => { cb.language = lang.value; save(); }); code.addEventListener('input', () => { cb.code = code.value; save(); }); this.dom.blocksList.append(el('div.block-card', {}, el('div.row', {}, el('strong', {}, 'Code block'), el('span.spacer'), removeBtn(() => { step.codeBlocks = step.codeBlocks.filter((b) => b !== cb); })), lang, code, )); } for (const tbl of step.tableBlocks || []) { const grid = el('textarea', { rows: 3, placeholder: 'One row per line, cells separated by |', spellcheck: false }); grid.value = (tbl.rows || []).map((r) => r.join(' | ')).join('\n'); grid.addEventListener('input', () => { tbl.rows = grid.value.split('\n').filter((l) => l.trim() !== '') .map((line) => line.split('|').map((c) => c.trim())); save(); }); this.dom.blocksList.append(el('div.block-card', {}, el('div.row', {}, el('strong', {}, 'Table'), el('span.spacer'), removeBtn(() => { step.tableBlocks = step.tableBlocks.filter((b) => b !== tbl); })), el('div.muted', {}, 'First line is the header row.'), grid, )); } if (!(step.textBlocks || []).length && !(step.codeBlocks || []).length && !(step.tableBlocks || []).length) { this.dom.blocksList.append(el('div.muted', {}, 'Informational text, code, and table blocks render in every export.')); } } 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' }, ]); // Light-weight apply: mutate the selected annotation, redraw, and let the // debounced save flush. Re-rendering the panel here would rebuild the // inputs and steal focus mid-keystroke, so only structural changes // (type/tail) pass rerender: true. const apply = (patch, { rerender = false } = {}) => { const ann = this.canvas.selected(); if (!ann) return; Object.assign(ann, patch); this.beforeCanvasSnapshot = null; step.annotations = clone(this.canvas.annotations || []); this.pendingSave = true; this.canvas.render(); this.saveStepDebounced(); if (rerender) this.renderAnnotationPanel(); 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'), ), el('div.row', {}, el('button', { type: 'button', title: 'Copy this style to every annotation of the same type in this step', onClick: () => this.applyStyleAcross('step') }, 'Style → step'), el('button', { type: 'button', title: 'Copy this style to every annotation of the same type in the whole guide', onClick: () => this.applyStyleAcross('guide') }, 'Style → guide'), ), ); this.dom.annotationEditor.append(annSection); typeSelect.addEventListener('change', () => { const ann = this.canvas.selected(); if (!ann) return; apply({ type: typeSelect.value }, { rerender: true }); 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); const prevIdx = this.steps.findIndex((s) => s.stepId === previous.stepId); if (prevIdx >= 0) this.steps[prevIdx] = 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); const nextIdx = this.steps.findIndex((s) => s.stepId === next.stepId); if (nextIdx >= 0) this.steps[nextIdx] = 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); // Keep the steps array in sync — it holds the objects the list renders. const idx = this.steps.findIndex((s) => s.stepId === saved.stepId); if (idx >= 0) this.steps[idx] = saved; if (this.selectedStepId === saved.stepId) { this.renderStepList(); this.syncStepFields(); this.canvas.setAnnotations(saved.annotations || []); // Rebuilding the annotation editor while the user is typing in one of // its inputs would steal focus, so skip it in that case. if (!this.dom.annotationEditor.contains(document.activeElement)) { 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, delayMs = null) { const result = mode === 'region' ? await api.capture.region({ guideId: this.guideId }) : await api.capture.shoot({ guideId: this.guideId, mode, delayMs }); if (result && result.ok) { await this.reload(result.step.stepId); this.onToast('Captured.'); } else if (result && result.reason) { this.onToast(result.reason, { error: true }); } } /** Capture menu anchored at a toolbar button. */ async openCaptureMenu(event) { const rect = event.target.getBoundingClientRect(); const session = (await api.capture.state())?.active; contextMenu(rect.left, rect.bottom + 4, [ { label: 'Capture full screen', action: () => this.captureStep('fullscreen') }, { label: 'Capture window', action: () => this.captureStep('window') }, { label: 'Capture region…', action: () => this.captureStep('region') }, { label: 'Capture after 3 s delay', action: () => this.captureStep('fullscreen', 3000) }, 'sep', { label: 'Paste image as step', action: () => this.pasteClipboardStep() }, { label: 'Import images…', action: () => this.importImageSteps() }, 'sep', session ? { label: 'Finish capture session', action: () => this.finishCaptureSession() } : { label: 'Start capture session (hotkey)', action: () => this.startCaptureSession() }, ]); } async pasteClipboardStep() { const result = await api.step.fromClipboard({ guideId: this.guideId }); if (result && result.ok) { await this.reload(result.step.stepId); this.onToast('Image pasted as a new step.'); } else { this.onToast(result?.reason || 'Clipboard has no image.', { error: true }); } } async shareAsFile() { const result = await api.archive.export({ guideId: this.guideId }); if (result && result.ok) this.onToast(`Shared to ${result.path}`); } async openBackupsDialog() { if (!this.guideId) return; const snapshots = await api.snapshots.list({ guideId: this.guideId }); await dialogs.showBackupsDialog({ snapshots, onCreate: async () => { await api.snapshots.create({ guideId: this.guideId, label: 'manual' }); this.onToast('Snapshot created.'); return api.snapshots.list({ guideId: this.guideId }); }, onRestore: async (name) => { const ok = await confirmDialog( `Restore "${name}"? Current state is snapshotted first, so this is undoable.`, { okLabel: 'Restore' }, ); if (!ok) return false; await api.snapshots.restore({ guideId: this.guideId, name }); await this.reload(); this.onToast('Snapshot restored.'); return true; }, }); } async openGuidePlaceholders() { if (!this.guide) return; await dialogs.showPlaceholdersDialog({ title: 'Guide placeholders', hint: 'Use [[Name]] in titles, descriptions, and blocks. Guide values override global ones.', values: this.guide.placeholders || {}, onSave: async (values) => { this.guide.placeholders = values; await api.guide.save({ guide: this.guide }); this.onToast('Placeholders saved.'); }, }); } openShortcutsHelp() { dialogs.showShortcutsDialog(); } /** Copy the selected annotation's style to every annotation of the same type. */ async applyStyleAcross(scope) { const source = this.canvas.selected(); if (!source) return; const patch = clone(source.style || {}); if (scope === 'step') { const step = this.currentStep; for (const ann of step.annotations || []) { if (ann.type === source.type && ann.id !== source.id) ann.style = { ...ann.style, ...patch }; } step.annotations = clone(step.annotations); await this.flushStep(step); this.onToast(`Style applied to all ${source.type} annotations in this step.`); } else { for (const step of this.steps) { let touched = false; for (const ann of step.annotations || []) { if (ann.type === source.type && ann.id !== source.id) { ann.style = { ...ann.style, ...patch }; touched = true; } } if (touched || step.stepId === this.currentStep?.stepId) { await api.step.save({ guideId: this.guideId, step }); } } await this.reload(this.selectedStepId); this.onToast(`Style applied to all ${source.type} annotations in the guide.`); } } 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 }), onLoadDefaults: async (format) => api.export.defaults({ format }), onLoadTemplate: async (format, name) => api.templates.load({ format, name }), onSaveTemplate: async (format, name, options) => { await api.templates.save({ format, name, options }); this.onToast(`Template “${name}” saved.`); }, onManageTemplates: async (format, mode) => { if (mode === 'manage') { await dialogs.showTemplateManager({ format, names: await api.templates.list({ format }), onRename: async (name, newName) => { await api.templates.rename({ format, name, newName }); return api.templates.list({ format }); }, onDuplicate: async (name) => { await api.templates.duplicate({ format, name }); return api.templates.list({ format }); }, onDelete: async (name) => { await api.templates.delete({ format, name }); return api.templates.list({ format }); }, onImport: async () => { const res = await api.templates.import(); if (res?.ok) this.onToast(`Imported template for ${res.format}.`); return api.templates.list({ format }); }, onExport: async (name) => { const res = await api.templates.export({ format, name }); if (res?.ok) this.onToast('Template shared as .sfglt.'); }, }); } return api.templates.list({ format }); }, onPreview: async ({ format, options }) => { const preview = await api.export.preview({ guideId: this.guideId, format, options }); if (preview && preview.file) { await api.shell.openPath({ target: preview.file }); // open in default viewer this.onToast('Preview opened (first steps only).'); } return true; }, onExport: async ({ format, options, outDir }) => { const result = await api.export.run({ guideId: this.guideId, format, options, outDir }); if (result && result.ok === false) return false; 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)) { // Escape deselects; Delete is the destructive key. if (this.selectedAnnotationId) { e.preventDefault(); this.canvas.select(null); 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)) { // Tool palette hotkeys (Folge-style single keys). const TOOL_KEYS = { s: 'select', r: 'rect', o: 'oval', l: 'line', a: 'arrow', t: 'text', g: 'tooltip', n: 'number', b: 'blur', h: 'highlight', m: 'magnify', u: 'cursor', c: 'crop', }; if (!e.ctrlKey && !e.metaKey && !e.altKey && TOOL_KEYS[e.key.toLowerCase()]) { e.preventDefault(); this.setTool(TOOL_KEYS[e.key.toLowerCase()]); return; } if (e.key === 'PageUp' || e.key === 'PageDown') { e.preventDefault(); const idx = this.steps.findIndex((s) => s.stepId === this.selectedStepId); const next = this.steps[idx + (e.key === 'PageDown' ? 1 : -1)]; if (next) this.selectStep(next.stepId); return; } if ((e.ctrlKey || e.metaKey) && (e.key === '=' || e.key === '+')) { e.preventDefault(); this.setZoom(Math.min(3, (Number(this.currentZoom) || 1) + 0.25)); return; } if ((e.ctrlKey || e.metaKey) && e.key === '-') { e.preventDefault(); this.setZoom(Math.max(0.25, (Number(this.currentZoom) || 1) - 0.25)); return; } if ((e.ctrlKey || e.metaKey) && e.key === '0') { e.preventDefault(); this.setZoom('fit'); return; } // Copy / paste the selected annotation. if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'c' && this.selectedAnnotationId) { e.preventDefault(); this.annotationClipboard = clone(this.canvas.selected()); this.onToast('Annotation copied.'); return; } if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'v') { e.preventDefault(); if (this.annotationClipboard && this.currentStep?.image) { const copy = clone(this.annotationClipboard); copy.id = `ann-${Date.now().toString(36)}`; copy.x = Math.min(0.92, copy.x + 0.03); copy.y = Math.min(0.92, copy.y + 0.03); this.currentStep.annotations.push(copy); this.canvas.setAnnotations(this.currentStep.annotations); this.canvas.select(copy.id); this.pendingSave = true; this.saveStepDebounced(); } else { this.pasteClipboardStep(); // OS clipboard image -> new step } return; } if (e.key === 'Delete' && this.selectedAnnotationId) { e.preventDefault(); if (this.canvas.deleteSelected()) this.saveStepDebounced(); return; } if ((e.ctrlKey || e.metaKey) && e.key === 'Delete') { e.preventDefault(); this.deleteSelectedStep(); 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 speed = e.shiftKey ? 10 : 1; // shift nudges faster const dx = (e.key === 'ArrowLeft' ? -1 : e.key === 'ArrowRight' ? 1 : 0) * speed; const dy = (e.key === 'ArrowUp' ? -1 : e.key === 'ArrowDown' ? 1 : 0) * speed; 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; })();