Merge pull request 'Mass guide editor changes' (#18) from guide_editor_changes into main
Template tests / tests (push) Successful in 1m39s

This commit was merged in pull request #18.
This commit is contained in:
2026-06-15 15:37:48 +00:00
24 changed files with 970 additions and 186 deletions
+9
View File
@@ -419,6 +419,15 @@ function setupIpc() {
reindex(guideId);
return true;
});
h('step:restore', ({ guideId, step, originalBase64, workingBase64, position }) => {
const images = {
original: originalBase64 ? Buffer.from(originalBase64, 'base64') : null,
working: workingBase64 ? Buffer.from(workingBase64, 'base64') : null,
};
const restored = store.restoreStep(guideId, step, images, position);
reindex(guideId);
return restored;
});
h('steps:reorder', ({ guideId, order }) => store.reorderSteps(guideId, order));
h('step:imagePath', ({ guideId, stepId, which }) => {
const p = store.stepImagePath(guideId, stepId, which || 'working');
+1
View File
@@ -34,6 +34,7 @@ const api = {
add: invoke('step:add'),
save: invoke('step:save'),
delete: invoke('step:delete'),
restore: invoke('step:restore'),
reorder: invoke('steps:reorder'),
imagePath: invoke('step:imagePath'),
setWorkingImage: invoke('step:setWorkingImage'),
+1 -1
View File
@@ -279,7 +279,7 @@ class StepForgeApp {
}
send({ action: s.paused ? 'resume' : 'pause' });
},
}, notStarted ? 'Start recording' : s.paused ? 'Resume' : 'Pause');
}, s.paused ? 'Start recording' : 'Stop recording');
this.captureStatus.append(
el('span', { title: `Capture session — ${trigger}` }, `Recording - ${trigger}`),
+70 -17
View File
@@ -28,6 +28,7 @@ class AnnotationCanvas {
this.selectedId = null;
this.drag = null;
this.cropRect = null;
this.focusedView = null;
canvasEl.addEventListener('pointerdown', (e) => this.onDown(e));
canvasEl.addEventListener('pointermove', (e) => this.onMove(e));
@@ -55,6 +56,14 @@ class AnnotationCanvas {
this.render();
}
// The focused view crops and zooms the canvas itself to match what
// exports produce. Annotation data stays in full-image-normalized
// coordinates; only the on-screen viewport changes.
setFocusedView(focusedView) {
this.focusedView = focusedView || null;
this.render();
}
setTool(tool) {
this.tool = tool;
this.cropRect = null;
@@ -98,30 +107,64 @@ class AnnotationCanvas {
}
// ---- coordinate helpers ----
// The focused-view crop region, in coordinates normalized to the full
// image (0..1). Identity rect when focused view is off, matching
// core/raster.js applyFocusedView. panX/panY are 0..1 fractions of the
// available pan range (0 = left/bottom edge, 1 = right/top edge), so the
// slider's full range always pans the crop across the whole image
// regardless of zoom. Y is inverted so panY increases upward.
viewRect() {
const fv = this.focusedView;
if (!fv || !fv.enabled || !(fv.zoom > 1)) return { x: 0, y: 0, w: 1, h: 1 };
const w = 1 / fv.zoom, h = 1 / fv.zoom;
const panX = fv.panX ?? 0.5, panY = fv.panY ?? 0.5;
return { x: panX * (1 - w), y: (1 - panY) * (1 - h), w, h };
}
toNorm(e) {
const rect = this.canvas.getBoundingClientRect();
const r = this.viewRect();
return {
x: (e.clientX - rect.left) / rect.width,
y: (e.clientY - rect.top) / rect.height,
x: r.x + ((e.clientX - rect.left) / rect.width) * r.w,
y: r.y + ((e.clientY - rect.top) / rect.height) * r.h,
};
}
px(ann) {
const r = this.viewRect();
return {
x: ann.x * this.canvas.width,
y: ann.y * this.canvas.height,
w: ann.w * this.canvas.width,
h: ann.h * this.canvas.height,
x: (ann.x - r.x) / r.w * this.canvas.width,
y: (ann.y - r.y) / r.h * this.canvas.height,
w: ann.w / r.w * this.canvas.width,
h: ann.h / r.h * this.canvas.height,
};
}
// Converts a canvas-pixel point to image-pixel coordinates in the
// full (uncropped) source image, for sampling `this.image` directly.
toImagePx(x, y) {
const r = this.viewRect();
return {
x: (x / this.canvas.width * r.w + r.x) * this.imgW,
y: (y / this.canvas.height * r.h + r.y) * this.imgH,
};
}
toImageLen(len, axis) {
const r = this.viewRect();
return axis === 'y' ? len / this.canvas.height * r.h * this.imgH : len / this.canvas.width * r.w * this.imgW;
}
// ---- rendering ----
render() {
const { ctx, canvas } = this;
ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!this.image) return;
ctx.imageSmoothingEnabled = true;
ctx.drawImage(this.image, 0, 0, canvas.width, canvas.height);
const r = this.viewRect();
ctx.drawImage(this.image,
r.x * this.imgW, r.y * this.imgH, r.w * this.imgW, r.h * this.imgH,
0, 0, canvas.width, canvas.height);
const ordered = [...this.annotations].sort((a, b) => (DRAW_ORDER[a.type] ?? 3) - (DRAW_ORDER[b.type] ?? 3));
for (const ann of ordered) this.drawAnnotation(ann);
@@ -131,12 +174,17 @@ class AnnotationCanvas {
if (this.cropRect) this.drawCropOverlay();
}
// Annotation strokes/fonts are sized relative to the full image, then
// magnified by the focused-view crop on export (core/raster.js) — divide
// by the view rect so the canvas preview matches that magnification.
strokePx(ann) {
return Math.max(1, ((ann.style && ann.style.strokeWidth) || 3) * this.canvas.width / 1000);
const r = this.viewRect();
return Math.max(1, ((ann.style && ann.style.strokeWidth) || 3) * this.canvas.width / 1000 / r.w);
}
fontPx(ann) {
return Math.max(9, ((ann.style && ann.style.fontSize) || 0.022) * this.canvas.height);
const r = this.viewRect();
return Math.max(9, ((ann.style && ann.style.fontSize) || 0.022) * this.canvas.height / r.h);
}
drawAnnotation(ann) {
@@ -202,10 +250,13 @@ class AnnotationCanvas {
ctx.ellipse(x + w / 2, y + h / 2, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
ctx.clip();
const sw = w / zoom, sh = h / zoom;
const center = this.toImagePx(x + w / 2, y + h / 2);
const srcW = this.toImageLen(sw, 'x');
const srcH = this.toImageLen(sh, 'y');
ctx.drawImage(
this.image,
(x + w / 2 - sw / 2) / this.scale, (y + h / 2 - sh / 2) / this.scale,
sw / this.scale, sh / this.scale,
center.x - srcW / 2, center.y - srcH / 2,
srcW, srcH,
x, y, w, h
);
ctx.restore();
@@ -310,10 +361,11 @@ class AnnotationCanvas {
drawCropOverlay() {
const { ctx, canvas } = this;
const r = this.cropRect;
const x = Math.min(r.x0, r.x1) * canvas.width;
const y = Math.min(r.y0, r.y1) * canvas.height;
const w = Math.abs(r.x1 - r.x0) * canvas.width;
const h = Math.abs(r.y1 - r.y0) * canvas.height;
const view = this.viewRect();
const x = (Math.min(r.x0, r.x1) - view.x) / view.w * canvas.width;
const y = (Math.min(r.y0, r.y1) - view.y) / view.h * canvas.height;
const w = Math.abs(r.x1 - r.x0) / view.w * canvas.width;
const h = Math.abs(r.y1 - r.y0) / view.h * canvas.height;
ctx.save();
ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.beginPath();
@@ -486,8 +538,9 @@ class AnnotationCanvas {
nudgeSelected(dx, dy) {
const sel = this.selected();
if (!sel) return false;
sel.x += dx / this.canvas.width;
sel.y += dy / this.canvas.height;
const r = this.viewRect();
sel.x += dx / this.canvas.width * r.w;
sel.y += dy / this.canvas.height * r.h;
this.changed();
return true;
}
+2 -1
View File
@@ -646,10 +646,11 @@ const SHORTCUTS = [
['PageUp / PageDown', 'Previous / next step'],
['Alt+↑ / Alt+↓', 'Move step up / down'],
['Ctrl+Delete', 'Delete current step'],
['Ctrl+Z / Ctrl+Shift+Z', 'Undo / redo (including step deletion)'],
['Ctrl+V', 'Paste annotation, or clipboard image as new step'],
]],
['Canvas tools', [
['S R O L A T', 'Select · Rect · Oval · Line · Arrow · Text'],
['S R O L A T', 'Select · Rectangle · Oval · Line · Arrow · Text'],
['G N B H M U C', 'Tooltip · Number · Blur · Highlight · Magnify · Cursor · Crop'],
['Ctrl+C', 'Copy selected annotation'],
['Delete', 'Delete selected annotation'],
+299 -63
View File
@@ -8,6 +8,37 @@ const dialogs = window.StepForgeDialogs || {};
const clone = (value) => JSON.parse(JSON.stringify(value));
const BLOCK_KIND_ORDER = { text: 0, code: 1, table: 2 };
// Which style fields are meaningful for each annotation type, so the
// annotation editor only shows controls that actually affect that type.
const ANNOTATION_FIELDS = {
rect: ['stroke', 'fill', 'strokeWidth'],
oval: ['stroke', 'fill', 'strokeWidth'],
line: ['stroke', 'strokeWidth'],
arrow: ['stroke', 'strokeWidth'],
text: ['text', 'stroke', 'fontSize'],
tooltip: ['text', 'fill', 'stroke', 'strokeWidth', 'fontSize', 'textColor', 'tail'],
number: ['value', 'stroke', 'textColor'],
blur: ['radius'],
highlight: [],
magnify: ['zoom', 'stroke', 'strokeWidth'],
cursor: [],
};
// Display names for annotation types in the "Type" dropdown.
const ANNOTATION_TYPE_LABELS = {
rect: 'Rectangle',
oval: 'Oval',
line: 'Line',
arrow: 'Arrow',
text: 'Text',
tooltip: 'Tooltip',
number: 'Number',
blur: 'Blur',
highlight: 'Highlight',
magnify: 'Magnify',
cursor: 'Cursor',
};
function blockText(block) {
for (const key of ['code', 'text', 'body', 'value', 'content']) {
const value = block && block[key];
@@ -173,7 +204,7 @@ class GuideEditor {
this.root.innerHTML = '';
const toolButtons = [
['select', 'Select'],
['rect', 'Rect'],
['rect', 'Rectangle'],
['oval', 'Oval'],
['line', 'Line'],
['arrow', 'Arrow'],
@@ -181,7 +212,7 @@ class GuideEditor {
['tooltip', 'Tip'],
['number', '#'],
['blur', 'Blur'],
['highlight', 'Hi'],
['highlight', 'Highlight'],
['magnify', 'Mag'],
['cursor', 'Cursor'],
['crop', 'Crop'],
@@ -284,8 +315,7 @@ class GuideEditor {
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.saveNowBtn = el('button.primary', { type: 'button', title: 'Save changes. For guides linked to a shared archive, also writes the archive file.' }, 'Save now'),
this.dom.snapshotBtn = el('button', { type: 'button' }, 'Snapshot'),
),
),
@@ -314,10 +344,32 @@ class GuideEditor {
}
toolbarBtn(action, label, block = null) {
return el('button', {
const btn = el('button', {
type: 'button',
onClick: () => this.formatDescription(action, block),
}, label);
btn.dataset.action = action;
if (block) btn.dataset.block = block;
return btn;
}
/** Reflect the current selection's formatting state on the toolbar buttons. */
updateToolbarState() {
if (!this.dom.richToolbar) return;
for (const btn of this.dom.richToolbar.children) {
const { action, block } = btn.dataset;
let active = false;
try {
if (action === 'formatBlock') {
active = document.queryCommandValue('formatBlock').toLowerCase() === (block || 'blockquote');
} else if (action === 'bold' || action === 'italic' || action === 'insertUnorderedList' || action === 'insertOrderedList') {
active = document.queryCommandState(action);
}
} catch {
active = false;
}
btn.classList.toggle('active', active);
}
}
bindShellEvents() {
@@ -330,7 +382,6 @@ class GuideEditor {
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));
@@ -397,6 +448,7 @@ class GuideEditor {
step.focusedView[field] = Number(node.value);
this.pendingSave = true;
this.saveStepDebounced();
this.canvas.setFocusedView(step.focusedView);
});
bindFocusedSlider(this.dom.fvZoom, 'zoom');
bindFocusedSlider(this.dom.fvPanX, 'panX');
@@ -407,7 +459,14 @@ class GuideEditor {
this.dom.addTableBlockBtn.addEventListener('click', () => this.addBlock('table'));
this.dom.descEditor.addEventListener('focus', () => {
// Make Enter start a new paragraph (<p>) rather than a plain <div>,
// so line breaks survive sanitization and show up in exports.
document.execCommand('defaultParagraphSeparator', false, 'p');
if (this.currentStep) this.pushCanvasHistory('description');
this.updateToolbarState();
});
this.dom.descEditor.addEventListener('blur', () => {
for (const btn of this.dom.richToolbar.children) btn.classList.remove('active');
});
this.dom.descEditor.addEventListener('input', () => {
if (!this.currentStep) return;
@@ -415,6 +474,12 @@ class GuideEditor {
this.pendingSave = true;
this.saveStepDebounced();
this.emitMeta();
this.updateToolbarState();
});
this.dom.descEditor.addEventListener('keyup', () => this.updateToolbarState());
this.dom.descEditor.addEventListener('mouseup', () => this.updateToolbarState());
document.addEventListener('selectionchange', () => {
if (document.activeElement === this.dom.descEditor) this.updateToolbarState();
});
this.dom.descEditor.addEventListener('paste', (e) => {
@@ -438,7 +503,6 @@ class GuideEditor {
this.renderCanvas();
this.renderAnnotationPanel();
this.renderBlocksPanel();
this.renderGuidePanel();
this.emitMeta();
}
@@ -451,6 +515,7 @@ class GuideEditor {
this.dom.fvPanX.value = fv.panX ?? 0.5;
this.dom.fvPanY.value = fv.panY ?? 0.5;
}
this.canvas.setFocusedView(fv);
}
// ---- text / code / table blocks ----------------------------------------
@@ -624,6 +689,7 @@ class GuideEditor {
}, header);
if (kind === 'text') {
card.dataset.level = block.level || 'info';
const position = makeSelect(block.position, [
{ value: 'before-title', label: 'Before title' },
{ value: 'after-title', label: 'After title' },
@@ -646,7 +712,7 @@ class GuideEditor {
body.dataset.blockField = 'body';
body.value = (block.descriptionHtml || '').replace(/<[^>]+>/g, '');
position.addEventListener('change', () => { block.position = position.value; save(); });
level.addEventListener('change', () => { block.level = level.value; save(); });
level.addEventListener('change', () => { block.level = level.value; card.dataset.level = level.value; save(); });
title.addEventListener('input', () => { block.title = title.value; save(); });
body.addEventListener('input', () => { block.descriptionHtml = `<p>${escapeHtml(body.value)}</p>`; save(); });
card.append(
@@ -716,6 +782,18 @@ class GuideEditor {
this.selectStep(step.stepId);
contextMenu(e.clientX, e.clientY, [
{ label: 'Add substep', action: () => this.addSubstep(step.stepId) },
{
label: 'Make substep of…',
submenu: () => {
const subtreeIds = new Set([step.stepId, ...this.getStepDescendantIds(step.stepId)]);
return this.steps
.filter((s) => !subtreeIds.has(s.stepId))
.map((s) => ({
label: `${numbers.get(s.stepId)} ${s.title || 'Untitled step'}`,
action: () => this.makeSubstepOf(step.stepId, s.stepId),
}));
},
},
{ label: 'Duplicate step', action: () => this.duplicateSelectedStep() },
'sep',
{ label: 'Move up', action: () => this.moveSelectedStep(-1) },
@@ -795,13 +873,21 @@ class GuideEditor {
if (!ids.length) return;
const ok = await confirmDialog(`Delete ${ids.length} step${ids.length === 1 ? '' : 's'}?`, { danger: true, okLabel: 'Delete' });
if (!ok) return;
if (this.pendingSave) await this.flushStep();
const order = this.steps.map((s) => s.stepId);
const entries = [];
for (const stepId of ids) {
const step = this.stepMap.get(stepId);
if (step) entries.push(await this.snapshotStepForDeletion(step));
}
for (const stepId of ids) {
await api.step.delete({ guideId: this.guideId, stepId });
}
if (entries.length) this.pushCanvasHistory({ type: 'delete-step', steps: entries, order });
this.stepSelectMode = false;
this.selectedSteps = new Set();
await this.reload(null);
this.onToast(`${ids.length} step${ids.length === 1 ? '' : 's'} deleted.`);
this.onToast(`${ids.length} step${ids.length === 1 ? '' : 's'} deleted. Press Ctrl+Z to undo.`);
}
syncStepFields() {
@@ -852,6 +938,7 @@ class GuideEditor {
if (token !== this.imageLoadToken) return;
this.canvas.setImage(img, img.naturalWidth || img.width, img.naturalHeight || img.height);
this.canvas.setAnnotations(step.annotations || []);
this.canvas.setFocusedView(step.focusedView);
this.canvas.setTool(this.currentTool);
this.canvas.setZoom(this.currentZoom);
};
@@ -883,7 +970,7 @@ class GuideEditor {
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.row', {}, el('strong', {}, ANNOTATION_TYPE_LABELS[ann.type] || 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)}`)));
}
}
@@ -898,7 +985,7 @@ class GuideEditor {
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 })));
].map((type) => ({ value: type, label: ANNOTATION_TYPE_LABELS[type] || 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' });
@@ -932,31 +1019,38 @@ class GuideEditor {
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),
const fields = new Set(ANNOTATION_FIELDS[selected.type] || []);
const strokeLabel = (selected.type === 'text' || selected.type === 'number') ? 'Color' : 'Stroke';
const typeLabel = ANNOTATION_TYPE_LABELS[selected.type] || selected.type;
const rows = [labeledRow('Type', typeSelect)];
if (fields.has('text')) rows.push(labeledRow('Text', textInput));
if (fields.has('value')) rows.push(labeledRow('Value', valueInput));
if (fields.has('stroke')) rows.push(labeledRow(strokeLabel, strokeInput));
if (fields.has('fill')) rows.push(labeledRow('Fill', fillInput));
if (fields.has('strokeWidth')) rows.push(labeledRow('Stroke width', strokeWidthInput));
if (fields.has('fontSize')) rows.push(labeledRow('Font size', fontSizeInput));
if (fields.has('textColor')) rows.push(labeledRow('Text color', textColorInput));
if (fields.has('zoom')) rows.push(labeledRow('Zoom', zoomInput));
if (fields.has('radius')) rows.push(labeledRow('Radius', radiusInput));
if (fields.has('tail')) rows.push(labeledRow('Tail', tailInput));
rows.push(
el('div.muted', {}, `Copy this style to every other "${typeLabel}" annotation:`),
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'),
title: `Overwrite the style of every "${typeLabel}" annotation on this step with the style shown above.`,
onClick: () => this.applyStyleAcross('step'),
}, 'This step'),
el('button', {
type: 'button',
title: `Overwrite the style of every "${typeLabel}" annotation across all steps in this guide with the style shown above.`,
onClick: () => this.applyStyleAcross('guide'),
}, 'Entire guide'),
),
);
const annSection = el('div', { className: 'annotation-editor-inner' }, ...rows);
this.dom.annotationEditor.append(annSection);
typeSelect.addEventListener('change', () => {
@@ -1019,13 +1113,6 @@ class GuideEditor {
});
}
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 };
@@ -1067,7 +1154,8 @@ class GuideEditor {
pushCanvasHistory(recordOrLabel = 'change') {
if (!this.currentStep) return;
const record = recordOrLabel && typeof recordOrLabel === 'object' && recordOrLabel.step
const record = recordOrLabel && typeof recordOrLabel === 'object'
&& (recordOrLabel.step || recordOrLabel.type === 'delete-step')
? recordOrLabel
: { step: clone(this.currentStep) };
this.canvasHistory.push(record);
@@ -1108,27 +1196,39 @@ class GuideEditor {
}
async undo() {
if (!this.currentStep) return;
if (!this.canvasHistory.length) {
this.onToast('Nothing to undo.');
return;
}
const previous = this.canvasHistory.pop();
if (previous.type === 'delete-step') {
await this.restoreDeletedSteps(previous);
this.canvasFuture.push(previous);
this.renderAll();
return;
}
if (!this.currentStep) return;
const current = await this.snapshotCurrentStep(true);
if (current) this.canvasFuture.push(current);
const previous = this.canvasHistory.pop();
await this.restoreHistoryRecord(previous);
this.renderAll();
}
async redo() {
if (!this.currentStep) return;
if (!this.canvasFuture.length) {
this.onToast('Nothing to redo.');
return;
}
const next = this.canvasFuture.pop();
if (next.type === 'delete-step') {
await this.deleteStepsAgain(next);
this.canvasHistory.push(next);
this.renderAll();
return;
}
if (!this.currentStep) return;
const current = await this.snapshotCurrentStep(true);
if (current) this.canvasHistory.push(current);
const next = this.canvasFuture.pop();
await this.restoreHistoryRecord(next);
this.renderAll();
}
@@ -1189,24 +1289,48 @@ class GuideEditor {
async saveAll() {
if (this.currentStep) await this.flushStep();
if (this.guide) await this.flushGuide();
if (this.guide && this.guide.linkedSource) {
const result = await api.archive.saveLinked({ guideId: this.guideId, force: false });
if (result.saved) {
this.guide.linkedSource.lastSavedAt = new Date().toISOString();
this.onToast('Saved and synced to linked archive.');
} else {
this.onToast('Saved locally, but the linked archive is locked by another session.', { error: true });
}
return;
}
this.onToast('Saved.');
}
async createSnapshot() {
if (!this.guideId) return;
await api.snapshots.create({ guideId: this.guideId, label: 'manual' });
const label = await dialogs.promptText({
title: 'Create snapshot',
label: 'Snapshot name',
placeholder: 'manual',
});
if (label == null) return;
if (this.currentStep) await this.flushStep();
if (this.guide) await this.flushGuide();
await api.snapshots.create({ guideId: this.guideId, label: label.trim() || 'manual' });
this.onToast('Snapshot created.');
}
async selectStep(stepId) {
if (!this.stepMap.has(stepId)) return;
// Persist any unsaved edits on the outgoing step before switching, so a
// later guide-wide reload (e.g. applyStyleAcross('guide')) doesn't
// discard them by re-fetching a stale on-disk copy.
if (this.pendingSave) await this.flushStep();
this.selectedStepId = stepId;
this.selectedAnnotationId = null;
this.canvas.select(null);
this.syncStepFields();
this.syncFocusedControls();
this.renderStepList();
this.renderCanvas();
this.renderAnnotationPanel();
this.renderBlocksPanel();
this.emitMeta();
}
@@ -1256,6 +1380,55 @@ class GuideEditor {
this.onToast(parent ? 'Substep added.' : 'Step added.');
}
/** All step ids whose ancestor chain leads back to `stepId`. */
getStepDescendantIds(stepId) {
const result = [];
const queue = [stepId];
while (queue.length) {
const current = queue.shift();
for (const s of this.steps) {
if (s.parentStepId === current) {
result.push(s.stepId);
queue.push(s.stepId);
}
}
}
return result;
}
async makeSubstepOf(stepId, targetStepId) {
const step = this.stepMap.get(stepId);
const target = this.stepMap.get(targetStepId);
if (!step || !target) return;
const numbers = stepNumberMap(this.steps);
const subtreeIds = new Set([stepId, ...this.getStepDescendantIds(stepId)]);
if (subtreeIds.has(targetStepId)) {
this.onToast('A step cannot be made a substep of itself or one of its own substeps.', { error: true });
return;
}
if (step.parentStepId === targetStepId) {
this.onToast(`${step.title || 'Untitled step'}” is already a substep of step ${numbers.get(targetStepId)}.`);
return;
}
step.parentStepId = targetStepId;
await api.step.save({ guideId: this.guideId, step });
// Move the step (with its own substeps) to sit right after the target
// step's existing substeps, so it becomes the target's last substep.
const order = this.steps.map((s) => s.stepId);
const remaining = order.filter((id) => !subtreeIds.has(id));
const targetSubtree = new Set([targetStepId, ...this.getStepDescendantIds(targetStepId)]);
let insertAt = remaining.indexOf(targetStepId) + 1;
while (insertAt < remaining.length && targetSubtree.has(remaining[insertAt])) insertAt++;
const movedBlock = order.filter((id) => subtreeIds.has(id));
remaining.splice(insertAt, 0, ...movedBlock);
await api.step.reorder({ guideId: this.guideId, order: remaining });
await this.reload(stepId);
this.onToast(`${step.title || 'Untitled step'}” is now a substep of step ${numbers.get(targetStepId)}.`);
}
async duplicateSelectedStep() {
const step = this.currentStep;
if (!step) return;
@@ -1282,12 +1455,16 @@ class GuideEditor {
if (!step) return;
const ok = await confirmDialog(`Delete “${step.title || 'Untitled step'}”?`, { danger: true, okLabel: 'Delete' });
if (!ok) return;
if (this.pendingSave) await this.flushStep();
const order = this.steps.map((s) => s.stepId);
const entry = await this.snapshotStepForDeletion(step);
await api.step.delete({ guideId: this.guideId, stepId: step.stepId });
this.pushCanvasHistory({ type: 'delete-step', steps: [entry], order });
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.');
this.onToast('Step deleted. Press Ctrl+Z to undo.');
}
async moveSelectedStep(delta) {
@@ -1327,18 +1504,18 @@ class GuideEditor {
async openCaptureMenu(event) {
const rect = event.target.getBoundingClientRect();
const session = (await api.capture.state())?.active;
contextMenu(rect.left, rect.bottom + 4, [
const items = [
{ label: 'Capture full screen', action: () => this.captureStep('fullscreen') },
{ label: 'Capture window', action: () => this.captureStep('window') },
{ label: 'Capture region…', action: () => this.captureStep('region') },
'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() },
]);
];
if (!session) {
items.push('sep', { label: 'Start capture session (hotkey)', action: () => this.startCaptureSession() });
}
contextMenu(rect.left, rect.bottom + 4, items);
}
async pasteClipboardStep() {
@@ -1446,12 +1623,6 @@ class GuideEditor {
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();
@@ -1598,8 +1769,12 @@ class GuideEditor {
}
async currentStepImageToBase64(step = this.currentStep) {
return this.stepImageToBase64(step, 'working');
}
async stepImageToBase64(step, which = 'working') {
if (!step || !step.image) return null;
const file = await api.step.imagePath({ guideId: this.guideId, stepId: step.stepId, which: 'working' });
const file = await api.step.imagePath({ guideId: this.guideId, stepId: step.stepId, which });
if (!file) return null;
return new Promise((resolve) => {
const img = new Image();
@@ -1617,6 +1792,62 @@ class GuideEditor {
});
}
/** Snapshot a step (and its images, if any) before deletion so it can be undone. */
async snapshotStepForDeletion(step) {
const position = this.steps.findIndex((s) => s.stepId === step.stepId);
const childIds = this.steps.filter((s) => s.parentStepId === step.stepId).map((s) => s.stepId);
let images = null;
if (step.image) {
const [original, working] = await Promise.all([
this.stepImageToBase64(step, 'original'),
this.stepImageToBase64(step, 'working'),
]);
images = { original, working };
}
return { step: clone(step), position, childIds, images };
}
/** Undo a 'delete-step' history record: recreate the deleted steps and their order. */
async restoreDeletedSteps(record) {
for (const entry of record.steps) {
await api.step.restore({
guideId: this.guideId,
step: entry.step,
originalBase64: entry.images?.original?.base64 || null,
workingBase64: entry.images?.working?.base64 || null,
position: entry.position,
});
}
await api.step.reorder({ guideId: this.guideId, order: record.order });
this.saveStepDebounced.cancel();
this.pendingSave = false;
await this.reload(record.steps[0].step.stepId);
for (const entry of record.steps) {
for (const childId of entry.childIds) {
const child = this.stepMap.get(childId);
if (child && child.parentStepId !== entry.step.stepId) {
child.parentStepId = entry.step.stepId;
await api.step.save({ guideId: this.guideId, step: child });
}
}
}
if (record.steps.some((entry) => entry.childIds.length)) await this.reload(record.steps[0].step.stepId);
this.onToast(`Restored ${record.steps.length} step${record.steps.length === 1 ? '' : 's'}.`);
}
/** Redo of an undone 'delete-step' record: delete those steps again. */
async deleteStepsAgain(record) {
for (const entry of record.steps) {
await api.step.delete({ guideId: this.guideId, stepId: entry.step.stepId });
}
const deletedIds = new Set(record.steps.map((entry) => entry.step.stepId));
const remaining = record.order.filter((id) => !deletedIds.has(id));
this.saveStepDebounced.cancel();
this.pendingSave = false;
await this.reload(remaining[0] || null);
this.onToast(`Deleted ${record.steps.length} step${record.steps.length === 1 ? '' : 's'}.`);
}
async onCanvasChange(annotations) {
const step = this.currentStep;
if (!step) return;
@@ -1727,12 +1958,16 @@ class GuideEditor {
case 'insertOrderedList':
document.execCommand('insertOrderedList');
break;
case 'formatBlock':
document.execCommand('formatBlock', false, block || 'blockquote');
case 'formatBlock': {
const want = block || 'blockquote';
const current = document.queryCommandValue('formatBlock').toLowerCase();
document.execCommand('formatBlock', false, current === want ? 'p' : want);
break;
}
case 'createLink': {
const url = window.prompt('Link URL');
if (url) document.execCommand('createLink', false, url);
const selectedText = window.getSelection().toString();
const text = selectedText || 'Text';
document.execCommand('insertText', false, `[${text}](Link)`);
break;
}
case 'removeFormat':
@@ -1746,6 +1981,7 @@ class GuideEditor {
this.pendingSave = true;
this.saveStepDebounced();
}
this.updateToolbarState();
}
onDocumentKeyDown(e) {
@@ -1832,7 +2068,7 @@ class GuideEditor {
}
return;
}
if (e.key === 'Delete' && this.selectedAnnotationId) {
if ((e.key === 'Delete' || e.key === 'Backspace') && this.selectedAnnotationId) {
e.preventDefault();
if (this.canvas.deleteSelected()) this.saveStepDebounced();
return;
+35
View File
@@ -487,6 +487,11 @@ kbd {
margin-bottom: 8px;
}
.rich-toolbar button { padding: 4px 8px; font-size: 12px; }
.rich-toolbar button.active {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-fg);
}
.rich-editor {
min-height: 110px;
max-height: 220px;
@@ -509,6 +514,12 @@ kbd {
border-radius: 10px;
background: var(--panel-2);
}
.rich-editor blockquote {
margin: 6px 0;
padding: 2px 0 2px 12px;
border-left: 3px solid var(--accent);
color: var(--muted);
}
.block-card,
.annotation-editor-inner {
@@ -518,12 +529,17 @@ kbd {
}
.block-card {
border: 1px solid var(--border);
border-left: 4px solid var(--border);
border-radius: 12px;
padding: 10px;
background: var(--panel-solid);
}
.block-card[draggable="true"] { cursor: grab; }
.block-card[draggable="true"]:active { cursor: grabbing; }
.block-card[data-level="info"] { border-left-color: #3b82f6; }
.block-card[data-level="success"] { border-left-color: #10b981; }
.block-card[data-level="warn"] { border-left-color: #f59e0b; }
.block-card[data-level="error"] { border-left-color: #ef4444; }
.annotation-list {
display: flex;
flex-direction: column;
@@ -731,6 +747,25 @@ fieldset legend {
}
.ctx-menu .mi:hover { background: var(--panel-2); }
.ctx-menu .mi.danger { color: var(--danger); }
.ctx-menu .mi.disabled {
color: var(--muted);
cursor: default;
}
.ctx-menu .mi.disabled:hover { background: none; }
.ctx-menu .mi.has-submenu {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.ctx-menu .mi .submenu-arrow {
color: var(--muted);
font-size: 12px;
}
.ctx-submenu {
max-height: min(360px, calc(100vh - 24px));
overflow-y: auto;
}
.ctx-menu hr {
margin: 6px 0;
border: 0;
+44 -4
View File
@@ -119,15 +119,55 @@ function promptDialog(title, { value = '', label = 'Name' } = {}) {
});
}
/** Context menu at (x, y); items: [{label, danger, action}] or 'sep'. */
/**
* Context menu at (x, y).
* items: [{label, danger, action}] | [{label, submenu}] | 'sep'.
* `submenu` is an array (or a function returning one) of the same item
* shapes, shown in a scrollable panel beside the item on hover.
*/
function contextMenu(x, y, items) {
document.querySelectorAll('.ctx-menu').forEach((n) => n.remove());
const menu = el('div.ctx-menu', { style: { left: `${x}px`, top: `${y}px` } });
let openSubmenu = null;
const closeSubmenu = () => {
if (openSubmenu) { openSubmenu.remove(); openSubmenu = null; }
};
const closeAll = () => { closeSubmenu(); menu.remove(); };
for (const item of items) {
if (item === 'sep') { menu.append(el('hr')); continue; }
if (item === 'sep') { menu.append(el('hr', { onMouseEnter: closeSubmenu })); continue; }
if (item.submenu) {
const mi = el('div.mi.has-submenu', {
onMouseEnter: () => {
closeSubmenu();
const subItems = typeof item.submenu === 'function' ? item.submenu() : item.submenu;
const sub = el('div.ctx-menu.ctx-submenu');
if (!subItems.length) {
sub.append(el('div.mi.disabled', {}, 'Nothing else to choose'));
}
for (const subItem of subItems) {
if (subItem === 'sep') { sub.append(el('hr')); continue; }
sub.append(el('div.mi', {
className: `mi${subItem.danger ? ' danger' : ''}`,
onClick: () => { closeAll(); subItem.action(); },
}, subItem.label));
}
document.body.append(sub);
const miRect = mi.getBoundingClientRect();
sub.style.left = `${miRect.right + 2}px`;
sub.style.top = `${miRect.top}px`;
const subRect = sub.getBoundingClientRect();
if (subRect.right > innerWidth) sub.style.left = `${Math.max(6, miRect.left - subRect.width - 2)}px`;
if (subRect.bottom > innerHeight) sub.style.top = `${Math.max(6, innerHeight - subRect.height - 6)}px`;
openSubmenu = sub;
},
}, el('span', {}, item.label), el('span.submenu-arrow', {}, ''));
menu.append(mi);
continue;
}
menu.append(el('div.mi', {
className: `mi${item.danger ? ' danger' : ''}`,
onClick: () => { menu.remove(); item.action(); },
onMouseEnter: closeSubmenu,
onClick: () => { closeAll(); item.action(); },
}, item.label));
}
document.body.append(menu);
@@ -135,7 +175,7 @@ function contextMenu(x, y, items) {
if (rect.right > innerWidth) menu.style.left = `${innerWidth - rect.width - 6}px`;
if (rect.bottom > innerHeight) menu.style.top = `${innerHeight - rect.height - 6}px`;
setTimeout(() => {
document.addEventListener('click', () => menu.remove(), { once: true });
document.addEventListener('click', () => closeAll(), { once: true });
}, 0);
}