03bd9b0e2b
Bug fixes from code review: - Wrap renderer modules (canvas/dialogs/editor/app) in IIFEs: duplicate top-level 'const api' across plain scripts threw a SyntaxError that prevented app.js from ever running (blank window), and dialogs.js/ editor.js silently overrode each other's labeledRow/makeSelect - Focused-view toggle now writes step.focusedView.enabled instead of a nonexistent flat field that the schema dropped on save - Annotation property edits no longer rebuild the panel on every keystroke (focus was stolen mid-typing); debounced save instead - flushStep/undo/redo keep this.steps in sync with stepMap so the step list stops going stale after the first save - Escape now deselects the annotation; Delete remains the delete key Welcome screen (per spec): app opens to a title at top and three buttons at the bottom — New Capture (creates a guide, opens the editor, starts a capture session), Existing Workspace (library), Settings. Brand click returns to the welcome screen. Adds an env-gated dev screenshot hook (STEPFORGE_SCREENSHOT[_JS]) used to visually verify welcome/library/editor views under WSLg. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1239 lines
45 KiB
JavaScript
1239 lines
45 KiB
JavaScript
'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');
|
||
|
||
// 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.emitMeta();
|
||
});
|
||
|
||
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' },
|
||
]);
|
||
|
||
// 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'),
|
||
),
|
||
);
|
||
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) {
|
||
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)) {
|
||
// 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)) {
|
||
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;
|
||
})();
|