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
+3 -5
View File
@@ -2,11 +2,9 @@ name: Template tests
on: on:
push: push:
pull_request: branches:
types: - main
- opened workflow_dispatch:
- synchronize
- reopened
jobs: jobs:
tests: tests:
+9
View File
@@ -419,6 +419,15 @@ function setupIpc() {
reindex(guideId); reindex(guideId);
return true; 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('steps:reorder', ({ guideId, order }) => store.reorderSteps(guideId, order));
h('step:imagePath', ({ guideId, stepId, which }) => { h('step:imagePath', ({ guideId, stepId, which }) => {
const p = store.stepImagePath(guideId, stepId, which || 'working'); const p = store.stepImagePath(guideId, stepId, which || 'working');
+1
View File
@@ -34,6 +34,7 @@ const api = {
add: invoke('step:add'), add: invoke('step:add'),
save: invoke('step:save'), save: invoke('step:save'),
delete: invoke('step:delete'), delete: invoke('step:delete'),
restore: invoke('step:restore'),
reorder: invoke('steps:reorder'), reorder: invoke('steps:reorder'),
imagePath: invoke('step:imagePath'), imagePath: invoke('step:imagePath'),
setWorkingImage: invoke('step:setWorkingImage'), setWorkingImage: invoke('step:setWorkingImage'),
+1 -1
View File
@@ -279,7 +279,7 @@ class StepForgeApp {
} }
send({ action: s.paused ? 'resume' : 'pause' }); send({ action: s.paused ? 'resume' : 'pause' });
}, },
}, notStarted ? 'Start recording' : s.paused ? 'Resume' : 'Pause'); }, s.paused ? 'Start recording' : 'Stop recording');
this.captureStatus.append( this.captureStatus.append(
el('span', { title: `Capture session — ${trigger}` }, `Recording - ${trigger}`), el('span', { title: `Capture session — ${trigger}` }, `Recording - ${trigger}`),
+70 -17
View File
@@ -28,6 +28,7 @@ class AnnotationCanvas {
this.selectedId = null; this.selectedId = null;
this.drag = null; this.drag = null;
this.cropRect = null; this.cropRect = null;
this.focusedView = null;
canvasEl.addEventListener('pointerdown', (e) => this.onDown(e)); canvasEl.addEventListener('pointerdown', (e) => this.onDown(e));
canvasEl.addEventListener('pointermove', (e) => this.onMove(e)); canvasEl.addEventListener('pointermove', (e) => this.onMove(e));
@@ -55,6 +56,14 @@ class AnnotationCanvas {
this.render(); 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) { setTool(tool) {
this.tool = tool; this.tool = tool;
this.cropRect = null; this.cropRect = null;
@@ -98,30 +107,64 @@ class AnnotationCanvas {
} }
// ---- coordinate helpers ---- // ---- 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) { toNorm(e) {
const rect = this.canvas.getBoundingClientRect(); const rect = this.canvas.getBoundingClientRect();
const r = this.viewRect();
return { return {
x: (e.clientX - rect.left) / rect.width, x: r.x + ((e.clientX - rect.left) / rect.width) * r.w,
y: (e.clientY - rect.top) / rect.height, y: r.y + ((e.clientY - rect.top) / rect.height) * r.h,
}; };
} }
px(ann) { px(ann) {
const r = this.viewRect();
return { return {
x: ann.x * this.canvas.width, x: (ann.x - r.x) / r.w * this.canvas.width,
y: ann.y * this.canvas.height, y: (ann.y - r.y) / r.h * this.canvas.height,
w: ann.w * this.canvas.width, w: ann.w / r.w * this.canvas.width,
h: ann.h * this.canvas.height, 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 ---- // ---- rendering ----
render() { render() {
const { ctx, canvas } = this; const { ctx, canvas } = this;
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
if (!this.image) return; if (!this.image) return;
ctx.imageSmoothingEnabled = true; 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)); 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); for (const ann of ordered) this.drawAnnotation(ann);
@@ -131,12 +174,17 @@ class AnnotationCanvas {
if (this.cropRect) this.drawCropOverlay(); 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) { 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) { 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) { 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.ellipse(x + w / 2, y + h / 2, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2);
ctx.clip(); ctx.clip();
const sw = w / zoom, sh = h / zoom; 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( ctx.drawImage(
this.image, this.image,
(x + w / 2 - sw / 2) / this.scale, (y + h / 2 - sh / 2) / this.scale, center.x - srcW / 2, center.y - srcH / 2,
sw / this.scale, sh / this.scale, srcW, srcH,
x, y, w, h x, y, w, h
); );
ctx.restore(); ctx.restore();
@@ -310,10 +361,11 @@ class AnnotationCanvas {
drawCropOverlay() { drawCropOverlay() {
const { ctx, canvas } = this; const { ctx, canvas } = this;
const r = this.cropRect; const r = this.cropRect;
const x = Math.min(r.x0, r.x1) * canvas.width; const view = this.viewRect();
const y = Math.min(r.y0, r.y1) * canvas.height; const x = (Math.min(r.x0, r.x1) - view.x) / view.w * canvas.width;
const w = Math.abs(r.x1 - r.x0) * canvas.width; const y = (Math.min(r.y0, r.y1) - view.y) / view.h * canvas.height;
const h = Math.abs(r.y1 - r.y0) * 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.save();
ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.fillStyle = 'rgba(0,0,0,0.5)';
ctx.beginPath(); ctx.beginPath();
@@ -486,8 +538,9 @@ class AnnotationCanvas {
nudgeSelected(dx, dy) { nudgeSelected(dx, dy) {
const sel = this.selected(); const sel = this.selected();
if (!sel) return false; if (!sel) return false;
sel.x += dx / this.canvas.width; const r = this.viewRect();
sel.y += dy / this.canvas.height; sel.x += dx / this.canvas.width * r.w;
sel.y += dy / this.canvas.height * r.h;
this.changed(); this.changed();
return true; return true;
} }
+2 -1
View File
@@ -646,10 +646,11 @@ const SHORTCUTS = [
['PageUp / PageDown', 'Previous / next step'], ['PageUp / PageDown', 'Previous / next step'],
['Alt+↑ / Alt+↓', 'Move step up / down'], ['Alt+↑ / Alt+↓', 'Move step up / down'],
['Ctrl+Delete', 'Delete current step'], ['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'], ['Ctrl+V', 'Paste annotation, or clipboard image as new step'],
]], ]],
['Canvas tools', [ ['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'], ['G N B H M U C', 'Tooltip · Number · Blur · Highlight · Magnify · Cursor · Crop'],
['Ctrl+C', 'Copy selected annotation'], ['Ctrl+C', 'Copy selected annotation'],
['Delete', 'Delete 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 clone = (value) => JSON.parse(JSON.stringify(value));
const BLOCK_KIND_ORDER = { text: 0, code: 1, table: 2 }; 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) { function blockText(block) {
for (const key of ['code', 'text', 'body', 'value', 'content']) { for (const key of ['code', 'text', 'body', 'value', 'content']) {
const value = block && block[key]; const value = block && block[key];
@@ -173,7 +204,7 @@ class GuideEditor {
this.root.innerHTML = ''; this.root.innerHTML = '';
const toolButtons = [ const toolButtons = [
['select', 'Select'], ['select', 'Select'],
['rect', 'Rect'], ['rect', 'Rectangle'],
['oval', 'Oval'], ['oval', 'Oval'],
['line', 'Line'], ['line', 'Line'],
['arrow', 'Arrow'], ['arrow', 'Arrow'],
@@ -181,7 +212,7 @@ class GuideEditor {
['tooltip', 'Tip'], ['tooltip', 'Tip'],
['number', '#'], ['number', '#'],
['blur', 'Blur'], ['blur', 'Blur'],
['highlight', 'Hi'], ['highlight', 'Highlight'],
['magnify', 'Mag'], ['magnify', 'Mag'],
['cursor', 'Cursor'], ['cursor', 'Cursor'],
['crop', 'Crop'], ['crop', 'Crop'],
@@ -284,8 +315,7 @@ class GuideEditor {
el('section', {}, el('section', {},
el('h3', {}, 'Guide'), el('h3', {}, 'Guide'),
this.dom.guideSummary = el('div.muted', {}), this.dom.guideSummary = el('div.muted', {}),
this.dom.linkedBtn = el('button', { type: 'button' }, 'Linked guide'), 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.saveNowBtn = el('button.primary', { type: 'button' }, 'Save now'),
this.dom.snapshotBtn = el('button', { type: 'button' }, 'Snapshot'), this.dom.snapshotBtn = el('button', { type: 'button' }, 'Snapshot'),
), ),
), ),
@@ -314,10 +344,32 @@ class GuideEditor {
} }
toolbarBtn(action, label, block = null) { toolbarBtn(action, label, block = null) {
return el('button', { const btn = el('button', {
type: 'button', type: 'button',
onClick: () => this.formatDescription(action, block), onClick: () => this.formatDescription(action, block),
}, label); }, 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() { bindShellEvents() {
@@ -330,7 +382,6 @@ class GuideEditor {
this.dom.deleteBtn.addEventListener('click', () => this.deleteSelectedStep()); this.dom.deleteBtn.addEventListener('click', () => this.deleteSelectedStep());
this.dom.saveNowBtn.addEventListener('click', () => this.saveAll()); this.dom.saveNowBtn.addEventListener('click', () => this.saveAll());
this.dom.snapshotBtn.addEventListener('click', () => this.createSnapshot()); this.dom.snapshotBtn.addEventListener('click', () => this.createSnapshot());
this.dom.linkedBtn.addEventListener('click', () => this.openLinkedGuide());
this.dom.zoomFitBtn.addEventListener('click', () => this.setZoom('fit')); this.dom.zoomFitBtn.addEventListener('click', () => this.setZoom('fit'));
this.dom.zoom100Btn.addEventListener('click', () => this.setZoom(1)); this.dom.zoom100Btn.addEventListener('click', () => this.setZoom(1));
this.dom.zoom125Btn.addEventListener('click', () => this.setZoom(1.25)); this.dom.zoom125Btn.addEventListener('click', () => this.setZoom(1.25));
@@ -397,6 +448,7 @@ class GuideEditor {
step.focusedView[field] = Number(node.value); step.focusedView[field] = Number(node.value);
this.pendingSave = true; this.pendingSave = true;
this.saveStepDebounced(); this.saveStepDebounced();
this.canvas.setFocusedView(step.focusedView);
}); });
bindFocusedSlider(this.dom.fvZoom, 'zoom'); bindFocusedSlider(this.dom.fvZoom, 'zoom');
bindFocusedSlider(this.dom.fvPanX, 'panX'); bindFocusedSlider(this.dom.fvPanX, 'panX');
@@ -407,7 +459,14 @@ class GuideEditor {
this.dom.addTableBlockBtn.addEventListener('click', () => this.addBlock('table')); this.dom.addTableBlockBtn.addEventListener('click', () => this.addBlock('table'));
this.dom.descEditor.addEventListener('focus', () => { 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'); 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', () => { this.dom.descEditor.addEventListener('input', () => {
if (!this.currentStep) return; if (!this.currentStep) return;
@@ -415,6 +474,12 @@ class GuideEditor {
this.pendingSave = true; this.pendingSave = true;
this.saveStepDebounced(); this.saveStepDebounced();
this.emitMeta(); 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) => { this.dom.descEditor.addEventListener('paste', (e) => {
@@ -438,7 +503,6 @@ class GuideEditor {
this.renderCanvas(); this.renderCanvas();
this.renderAnnotationPanel(); this.renderAnnotationPanel();
this.renderBlocksPanel(); this.renderBlocksPanel();
this.renderGuidePanel();
this.emitMeta(); this.emitMeta();
} }
@@ -451,6 +515,7 @@ class GuideEditor {
this.dom.fvPanX.value = fv.panX ?? 0.5; this.dom.fvPanX.value = fv.panX ?? 0.5;
this.dom.fvPanY.value = fv.panY ?? 0.5; this.dom.fvPanY.value = fv.panY ?? 0.5;
} }
this.canvas.setFocusedView(fv);
} }
// ---- text / code / table blocks ---------------------------------------- // ---- text / code / table blocks ----------------------------------------
@@ -624,6 +689,7 @@ class GuideEditor {
}, header); }, header);
if (kind === 'text') { if (kind === 'text') {
card.dataset.level = block.level || 'info';
const position = makeSelect(block.position, [ const position = makeSelect(block.position, [
{ value: 'before-title', label: 'Before title' }, { value: 'before-title', label: 'Before title' },
{ value: 'after-title', label: 'After title' }, { value: 'after-title', label: 'After title' },
@@ -646,7 +712,7 @@ class GuideEditor {
body.dataset.blockField = 'body'; body.dataset.blockField = 'body';
body.value = (block.descriptionHtml || '').replace(/<[^>]+>/g, ''); body.value = (block.descriptionHtml || '').replace(/<[^>]+>/g, '');
position.addEventListener('change', () => { block.position = position.value; save(); }); 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(); }); title.addEventListener('input', () => { block.title = title.value; save(); });
body.addEventListener('input', () => { block.descriptionHtml = `<p>${escapeHtml(body.value)}</p>`; save(); }); body.addEventListener('input', () => { block.descriptionHtml = `<p>${escapeHtml(body.value)}</p>`; save(); });
card.append( card.append(
@@ -716,6 +782,18 @@ class GuideEditor {
this.selectStep(step.stepId); this.selectStep(step.stepId);
contextMenu(e.clientX, e.clientY, [ contextMenu(e.clientX, e.clientY, [
{ label: 'Add substep', action: () => this.addSubstep(step.stepId) }, { 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() }, { label: 'Duplicate step', action: () => this.duplicateSelectedStep() },
'sep', 'sep',
{ label: 'Move up', action: () => this.moveSelectedStep(-1) }, { label: 'Move up', action: () => this.moveSelectedStep(-1) },
@@ -795,13 +873,21 @@ class GuideEditor {
if (!ids.length) return; if (!ids.length) return;
const ok = await confirmDialog(`Delete ${ids.length} step${ids.length === 1 ? '' : 's'}?`, { danger: true, okLabel: 'Delete' }); const ok = await confirmDialog(`Delete ${ids.length} step${ids.length === 1 ? '' : 's'}?`, { danger: true, okLabel: 'Delete' });
if (!ok) return; 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) { for (const stepId of ids) {
await api.step.delete({ guideId: this.guideId, stepId }); await api.step.delete({ guideId: this.guideId, stepId });
} }
if (entries.length) this.pushCanvasHistory({ type: 'delete-step', steps: entries, order });
this.stepSelectMode = false; this.stepSelectMode = false;
this.selectedSteps = new Set(); this.selectedSteps = new Set();
await this.reload(null); 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() { syncStepFields() {
@@ -852,6 +938,7 @@ class GuideEditor {
if (token !== this.imageLoadToken) return; if (token !== this.imageLoadToken) return;
this.canvas.setImage(img, img.naturalWidth || img.width, img.naturalHeight || img.height); this.canvas.setImage(img, img.naturalWidth || img.width, img.naturalHeight || img.height);
this.canvas.setAnnotations(step.annotations || []); this.canvas.setAnnotations(step.annotations || []);
this.canvas.setFocusedView(step.focusedView);
this.canvas.setTool(this.currentTool); this.canvas.setTool(this.currentTool);
this.canvas.setZoom(this.currentZoom); this.canvas.setZoom(this.currentZoom);
}; };
@@ -883,7 +970,7 @@ class GuideEditor {
dataset: { annId: ann.id }, dataset: { annId: ann.id },
style: { cursor: 'pointer', borderColor: selected ? 'var(--accent)' : '' }, 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)}`))); 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 style = selected.style || {};
const typeSelect = makeSelect(selected.type, [ const typeSelect = makeSelect(selected.type, [
'rect', 'oval', 'line', 'arrow', 'text', 'tooltip', 'number', 'blur', 'highlight', 'magnify', 'cursor', '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 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 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 strokeInput = el('input', { type: 'color', value: style.stroke || '#E5484D' });
@@ -932,31 +1019,38 @@ class GuideEditor {
this.emitMeta(); this.emitMeta();
}; };
const annSection = el('div', { className: 'annotation-editor-inner' }, const fields = new Set(ANNOTATION_FIELDS[selected.type] || []);
labeledRow('Type', typeSelect), const strokeLabel = (selected.type === 'text' || selected.type === 'number') ? 'Color' : 'Stroke';
labeledRow('Text', textInput), const typeLabel = ANNOTATION_TYPE_LABELS[selected.type] || selected.type;
labeledRow('Value', valueInput),
labeledRow('Stroke', strokeInput), const rows = [labeledRow('Type', typeSelect)];
labeledRow('Fill', fillInput), if (fields.has('text')) rows.push(labeledRow('Text', textInput));
labeledRow('Stroke width', strokeWidthInput), if (fields.has('value')) rows.push(labeledRow('Value', valueInput));
labeledRow('Font size', fontSizeInput), if (fields.has('stroke')) rows.push(labeledRow(strokeLabel, strokeInput));
labeledRow('Text color', textColorInput), if (fields.has('fill')) rows.push(labeledRow('Fill', fillInput));
labeledRow('Zoom', zoomInput), if (fields.has('strokeWidth')) rows.push(labeledRow('Stroke width', strokeWidthInput));
labeledRow('Radius', radiusInput), if (fields.has('fontSize')) rows.push(labeledRow('Font size', fontSizeInput));
labeledRow('Tail', tailInput), 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('div.row', {},
el('button', { el('button', {
type: 'button', type: 'button',
onClick: () => { title: `Overwrite the style of every "${typeLabel}" annotation on this step with the style shown above.`,
this.canvas.deleteSelected(); onClick: () => this.applyStyleAcross('step'),
}, }, 'This step'),
}, 'Delete annotation'), el('button', {
), type: 'button',
el('div.row', {}, title: `Overwrite the style of every "${typeLabel}" annotation across all steps in this guide with the style shown above.`,
el('button', { type: 'button', title: 'Copy this style to every annotation of the same type in this step', onClick: () => this.applyStyleAcross('step') }, 'Style → step'), onClick: () => this.applyStyleAcross('guide'),
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'), }, 'Entire guide'),
), ),
); );
const annSection = el('div', { className: 'annotation-editor-inner' }, ...rows);
this.dom.annotationEditor.append(annSection); this.dom.annotationEditor.append(annSection);
typeSelect.addEventListener('change', () => { 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) { defaultStyleForTool(tool) {
switch (tool) { switch (tool) {
case 'highlight': return { fill: '#ffe066', stroke: '#ffbf00', strokeWidth: 1 }; case 'highlight': return { fill: '#ffe066', stroke: '#ffbf00', strokeWidth: 1 };
@@ -1067,7 +1154,8 @@ class GuideEditor {
pushCanvasHistory(recordOrLabel = 'change') { pushCanvasHistory(recordOrLabel = 'change') {
if (!this.currentStep) return; 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 ? recordOrLabel
: { step: clone(this.currentStep) }; : { step: clone(this.currentStep) };
this.canvasHistory.push(record); this.canvasHistory.push(record);
@@ -1108,27 +1196,39 @@ class GuideEditor {
} }
async undo() { async undo() {
if (!this.currentStep) return;
if (!this.canvasHistory.length) { if (!this.canvasHistory.length) {
this.onToast('Nothing to undo.'); this.onToast('Nothing to undo.');
return; 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); const current = await this.snapshotCurrentStep(true);
if (current) this.canvasFuture.push(current); if (current) this.canvasFuture.push(current);
const previous = this.canvasHistory.pop();
await this.restoreHistoryRecord(previous); await this.restoreHistoryRecord(previous);
this.renderAll(); this.renderAll();
} }
async redo() { async redo() {
if (!this.currentStep) return;
if (!this.canvasFuture.length) { if (!this.canvasFuture.length) {
this.onToast('Nothing to redo.'); this.onToast('Nothing to redo.');
return; 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); const current = await this.snapshotCurrentStep(true);
if (current) this.canvasHistory.push(current); if (current) this.canvasHistory.push(current);
const next = this.canvasFuture.pop();
await this.restoreHistoryRecord(next); await this.restoreHistoryRecord(next);
this.renderAll(); this.renderAll();
} }
@@ -1189,24 +1289,48 @@ class GuideEditor {
async saveAll() { async saveAll() {
if (this.currentStep) await this.flushStep(); if (this.currentStep) await this.flushStep();
if (this.guide) await this.flushGuide(); 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.'); this.onToast('Saved.');
} }
async createSnapshot() { async createSnapshot() {
if (!this.guideId) return; 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.'); this.onToast('Snapshot created.');
} }
async selectStep(stepId) { async selectStep(stepId) {
if (!this.stepMap.has(stepId)) return; 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.selectedStepId = stepId;
this.selectedAnnotationId = null; this.selectedAnnotationId = null;
this.canvas.select(null); this.canvas.select(null);
this.syncStepFields(); this.syncStepFields();
this.syncFocusedControls();
this.renderStepList(); this.renderStepList();
this.renderCanvas(); this.renderCanvas();
this.renderAnnotationPanel(); this.renderAnnotationPanel();
this.renderBlocksPanel();
this.emitMeta(); this.emitMeta();
} }
@@ -1256,6 +1380,55 @@ class GuideEditor {
this.onToast(parent ? 'Substep added.' : 'Step added.'); 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() { async duplicateSelectedStep() {
const step = this.currentStep; const step = this.currentStep;
if (!step) return; if (!step) return;
@@ -1282,12 +1455,16 @@ class GuideEditor {
if (!step) return; if (!step) return;
const ok = await confirmDialog(`Delete “${step.title || 'Untitled step'}”?`, { danger: true, okLabel: 'Delete' }); const ok = await confirmDialog(`Delete “${step.title || 'Untitled step'}”?`, { danger: true, okLabel: 'Delete' });
if (!ok) return; 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 }); 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] const next = this.steps[this.steps.findIndex((s) => s.stepId === step.stepId) + 1]
|| this.steps[this.steps.findIndex((s) => s.stepId === step.stepId) - 1] || this.steps[this.steps.findIndex((s) => s.stepId === step.stepId) - 1]
|| null; || null;
await this.reload(next && next.stepId); await this.reload(next && next.stepId);
this.onToast('Step deleted.'); this.onToast('Step deleted. Press Ctrl+Z to undo.');
} }
async moveSelectedStep(delta) { async moveSelectedStep(delta) {
@@ -1327,18 +1504,18 @@ class GuideEditor {
async openCaptureMenu(event) { async openCaptureMenu(event) {
const rect = event.target.getBoundingClientRect(); const rect = event.target.getBoundingClientRect();
const session = (await api.capture.state())?.active; 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 full screen', action: () => this.captureStep('fullscreen') },
{ label: 'Capture window', action: () => this.captureStep('window') }, { label: 'Capture window', action: () => this.captureStep('window') },
{ label: 'Capture region…', action: () => this.captureStep('region') }, { label: 'Capture region…', action: () => this.captureStep('region') },
'sep', 'sep',
{ label: 'Paste image as step', action: () => this.pasteClipboardStep() }, { label: 'Paste image as step', action: () => this.pasteClipboardStep() },
{ label: 'Import images…', action: () => this.importImageSteps() }, { label: 'Import images…', action: () => this.importImageSteps() },
'sep', ];
session if (!session) {
? { label: 'Finish capture session', action: () => this.finishCaptureSession() } items.push('sep', { label: 'Start capture session (hotkey)', action: () => this.startCaptureSession() });
: { label: 'Start capture session (hotkey)', action: () => this.startCaptureSession() }, }
]); contextMenu(rect.left, rect.bottom + 4, items);
} }
async pasteClipboardStep() { async pasteClipboardStep() {
@@ -1446,12 +1623,6 @@ class GuideEditor {
this.emitMeta(); this.emitMeta();
} }
async finishCaptureSession() {
await api.capture.session({ action: 'finish', guideId: this.guideId });
this.onToast('Capture session finished.');
this.emitMeta();
}
async openSettings() { async openSettings() {
const settings = await api.settings.all(); const settings = await api.settings.all();
const placeholders = await api.settings.globalPlaceholders(); const placeholders = await api.settings.globalPlaceholders();
@@ -1598,8 +1769,12 @@ class GuideEditor {
} }
async currentStepImageToBase64(step = this.currentStep) { async currentStepImageToBase64(step = this.currentStep) {
return this.stepImageToBase64(step, 'working');
}
async stepImageToBase64(step, which = 'working') {
if (!step || !step.image) return null; 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; if (!file) return null;
return new Promise((resolve) => { return new Promise((resolve) => {
const img = new Image(); 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) { async onCanvasChange(annotations) {
const step = this.currentStep; const step = this.currentStep;
if (!step) return; if (!step) return;
@@ -1727,12 +1958,16 @@ class GuideEditor {
case 'insertOrderedList': case 'insertOrderedList':
document.execCommand('insertOrderedList'); document.execCommand('insertOrderedList');
break; break;
case 'formatBlock': case 'formatBlock': {
document.execCommand('formatBlock', false, block || 'blockquote'); const want = block || 'blockquote';
const current = document.queryCommandValue('formatBlock').toLowerCase();
document.execCommand('formatBlock', false, current === want ? 'p' : want);
break; break;
}
case 'createLink': { case 'createLink': {
const url = window.prompt('Link URL'); const selectedText = window.getSelection().toString();
if (url) document.execCommand('createLink', false, url); const text = selectedText || 'Text';
document.execCommand('insertText', false, `[${text}](Link)`);
break; break;
} }
case 'removeFormat': case 'removeFormat':
@@ -1746,6 +1981,7 @@ class GuideEditor {
this.pendingSave = true; this.pendingSave = true;
this.saveStepDebounced(); this.saveStepDebounced();
} }
this.updateToolbarState();
} }
onDocumentKeyDown(e) { onDocumentKeyDown(e) {
@@ -1832,7 +2068,7 @@ class GuideEditor {
} }
return; return;
} }
if (e.key === 'Delete' && this.selectedAnnotationId) { if ((e.key === 'Delete' || e.key === 'Backspace') && this.selectedAnnotationId) {
e.preventDefault(); e.preventDefault();
if (this.canvas.deleteSelected()) this.saveStepDebounced(); if (this.canvas.deleteSelected()) this.saveStepDebounced();
return; return;
+35
View File
@@ -487,6 +487,11 @@ kbd {
margin-bottom: 8px; margin-bottom: 8px;
} }
.rich-toolbar button { padding: 4px 8px; font-size: 12px; } .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 { .rich-editor {
min-height: 110px; min-height: 110px;
max-height: 220px; max-height: 220px;
@@ -509,6 +514,12 @@ kbd {
border-radius: 10px; border-radius: 10px;
background: var(--panel-2); 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, .block-card,
.annotation-editor-inner { .annotation-editor-inner {
@@ -518,12 +529,17 @@ kbd {
} }
.block-card { .block-card {
border: 1px solid var(--border); border: 1px solid var(--border);
border-left: 4px solid var(--border);
border-radius: 12px; border-radius: 12px;
padding: 10px; padding: 10px;
background: var(--panel-solid); background: var(--panel-solid);
} }
.block-card[draggable="true"] { cursor: grab; } .block-card[draggable="true"] { cursor: grab; }
.block-card[draggable="true"]:active { cursor: grabbing; } .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 { .annotation-list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -731,6 +747,25 @@ fieldset legend {
} }
.ctx-menu .mi:hover { background: var(--panel-2); } .ctx-menu .mi:hover { background: var(--panel-2); }
.ctx-menu .mi.danger { color: var(--danger); } .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 { .ctx-menu hr {
margin: 6px 0; margin: 6px 0;
border: 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) { function contextMenu(x, y, items) {
document.querySelectorAll('.ctx-menu').forEach((n) => n.remove()); document.querySelectorAll('.ctx-menu').forEach((n) => n.remove());
const menu = el('div.ctx-menu', { style: { left: `${x}px`, top: `${y}px` } }); 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) { 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', { menu.append(el('div.mi', {
className: `mi${item.danger ? ' danger' : ''}`, className: `mi${item.danger ? ' danger' : ''}`,
onClick: () => { menu.remove(); item.action(); }, onMouseEnter: closeSubmenu,
onClick: () => { closeAll(); item.action(); },
}, item.label)); }, item.label));
} }
document.body.append(menu); 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.right > innerWidth) menu.style.left = `${innerWidth - rect.width - 6}px`;
if (rect.bottom > innerHeight) menu.style.top = `${innerHeight - rect.height - 6}px`; if (rect.bottom > innerHeight) menu.style.top = `${innerHeight - rect.height - 6}px`;
setTimeout(() => { setTimeout(() => {
document.addEventListener('click', () => menu.remove(), { once: true }); document.addEventListener('click', () => closeAll(), { once: true });
}, 0); }, 0);
} }
+125
View File
@@ -0,0 +1,125 @@
'use strict';
const { decodeEntities } = require('./util');
/**
* Parse sanitized description HTML (see core/sanitize.js) into a flat list
* of blocks with inline formatting runs, for renderers (PDF) that need to
* preserve bold/italic/links/lists rather than flattening to plain text.
*
* Each block: { type, runs, indent, n? }
* type: 'p' | 'h1'..'h4' | 'blockquote' | 'li' | 'oli' | 'hr'
* runs: [{ text, bold, italic, code, href }] (absent for 'hr')
* indent: list/quote nesting depth (0 = top level)
* n: list item number, only for 'oli'
*/
const TAG_RE = /<\s*(\/?)\s*([a-zA-Z][a-zA-Z0-9]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
const HREF_RE = /href\s*=\s*"([^"]*)"|href\s*=\s*'([^']*)'/i;
function htmlToBlocks(html) {
const blocks = [];
let current = null;
const styleStack = [{}];
const context = []; // nesting of <ul>/<ol>/<blockquote>
const indentLevel = () => context.length;
const currentStyle = () => styleStack[styleStack.length - 1];
const flushBlock = () => {
if (!current) return;
if (current.type === 'hr' || current.runs.some((r) => r.text.trim() !== '')) blocks.push(current);
current = null;
};
const startBlock = (type, extra = {}) => {
flushBlock();
current = { type, runs: [], indent: indentLevel(), ...extra };
};
const pushText = (text) => {
if (!text) return;
if (!current) current = { type: 'p', runs: [], indent: indentLevel() };
current.runs.push({ text, ...currentStyle() });
};
let last = 0;
let m;
while ((m = TAG_RE.exec(html)) !== null) {
if (m.index > last) pushText(decodeEntities(html.slice(last, m.index)));
last = TAG_RE.lastIndex;
const closing = Boolean(m[1]);
const tag = m[2].toLowerCase();
const rawAttrs = m[3];
switch (tag) {
case 'p':
case 'div':
if (closing) flushBlock();
else startBlock('p');
break;
case 'h1':
case 'h2':
case 'h3':
case 'h4':
if (closing) flushBlock();
else startBlock(tag);
break;
case 'blockquote':
if (closing) { flushBlock(); context.pop(); }
else { context.push({ kind: 'blockquote' }); startBlock('blockquote'); }
break;
case 'ul':
case 'ol':
if (closing) context.pop();
else context.push({ kind: tag, counter: 0 });
break;
case 'li':
if (closing) flushBlock();
else {
const ctx = context[context.length - 1];
const depth = Math.max(0, indentLevel() - 1);
if (ctx && ctx.kind === 'ol') { ctx.counter += 1; startBlock('oli', { n: ctx.counter, indent: depth }); }
else startBlock('li', { indent: depth });
}
break;
case 'br':
flushBlock();
break;
case 'hr':
flushBlock();
blocks.push({ type: 'hr' });
break;
case 'b':
case 'strong':
if (closing) styleStack.pop();
else styleStack.push({ ...currentStyle(), bold: true });
break;
case 'i':
case 'em':
if (closing) styleStack.pop();
else styleStack.push({ ...currentStyle(), italic: true });
break;
case 'code':
case 'pre':
if (closing) styleStack.pop();
else styleStack.push({ ...currentStyle(), code: true });
break;
case 'a':
if (closing) styleStack.pop();
else {
const hrefM = HREF_RE.exec(rawAttrs);
styleStack.push({ ...currentStyle(), href: hrefM ? (hrefM[1] ?? hrefM[2]) : undefined });
}
break;
default:
// Unrecognized/transparent allowed tags (table*, span, u, s, sub, sup):
// no block or style change.
break;
}
}
pushText(decodeEntities(html.slice(last)));
flushBlock();
return blocks;
}
module.exports = { htmlToBlocks };
+57 -4
View File
@@ -9,19 +9,41 @@ const zlib = require('node:zlib');
* points; converted to PDF's bottom-left space internally. * points; converted to PDF's bottom-left space internally.
*/ */
const FONTS = { F1: 'Helvetica', F2: 'Helvetica-Bold', F3: 'Courier' }; const FONTS = { F1: 'Helvetica', F2: 'Helvetica-Bold', F3: 'Courier', F4: 'Helvetica-Oblique', F5: 'Helvetica-BoldOblique' };
// Approximate average glyph width factors (per 1pt font size) for wrapping. // Approximate average glyph width factors (per 1pt font size) for wrapping.
const FONT_WIDTH_FACTOR = { F1: 0.51, F2: 0.55, F3: 0.6 }; const FONT_WIDTH_FACTOR = { F1: 0.51, F2: 0.55, F3: 0.6, F4: 0.51, F5: 0.55 };
function esc(text) { function esc(text) {
return String(text).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); return String(text).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)');
} }
// "Smart typography" characters commonly produced by rich-text editors and
// pasted content (e.g. from Word/Google Docs) that have a printable glyph in
// WinAnsiEncoding (cp1252) outside the Latin-1 range; map to that byte
// instead of falling back to "?".
const WINANSI_EXTRA = {
0x20ac: 0x80, // €
0x2026: 0x85, // … ellipsis
0x2030: 0x89, // ‰
0x2039: 0x8b, //
0x203a: 0x9b, //
0x2018: 0x91, // ' left single quote
0x2019: 0x92, // ' right single quote
0x201c: 0x93, // " left double quote
0x201d: 0x94, // " right double quote
0x2022: 0x95, // • bullet
0x2013: 0x96, // en dash
0x2014: 0x97, // — em dash
0x2122: 0x99, // ™
};
function toLatin1(text) { function toLatin1(text) {
let out = ''; let out = '';
for (const ch of String(text)) { for (const ch of String(text)) {
const code = ch.codePointAt(0); const code = ch.codePointAt(0);
out += code <= 0xff ? ch : '?'; if (code <= 0xff) out += ch;
else if (WINANSI_EXTRA[code] !== undefined) out += String.fromCharCode(WINANSI_EXTRA[code]);
else out += '?';
} }
return out; return out;
} }
@@ -73,11 +95,42 @@ class PdfBuilder {
text(str, x, yTop, { size = 11, font = 'F1', color = [0, 0, 0] } = {}) { text(str, x, yTop, { size = 11, font = 'F1', color = [0, 0, 0] } = {}) {
const y = this.pageHeight - yTop - size; const y = this.pageHeight - yTop - size;
// Tabs have no glyph in WinAnsiEncoding and render as "?"; expand to spaces.
const clean = String(str).replace(/\t/g, ' ');
this.currentPage.ops.push( this.currentPage.ops.push(
`BT /${font} ${size} Tf ${col(color)} rg 1 0 0 1 ${x.toFixed(2)} ${y.toFixed(2)} Tm (${esc(toLatin1(str))}) Tj ET` `BT /${font} ${size} Tf ${col(color)} rg 1 0 0 1 ${x.toFixed(2)} ${y.toFixed(2)} Tm (${esc(toLatin1(clean))}) Tj ET`
); );
} }
/**
* Render a line made of mixed-font/color segments (e.g. bold/italic runs)
* as one continuous text object: consecutive Tj operators without an
* intervening Tm advance the cursor by each font's real glyph widths, so
* spacing matches the viewer's metrics exactly (unlike our approximate
* textWidth(), which is only used for word-wrap decisions).
*/
textRun(parts, x, yTop, size) {
const y = this.pageHeight - yTop - size;
const ops = [`BT 1 0 0 1 ${x.toFixed(2)} ${y.toFixed(2)} Tm`];
let curFont = null;
let curColor = null;
for (const part of parts) {
if (!part.text) continue;
if (part.font !== curFont) {
ops.push(`/${part.font} ${size} Tf`);
curFont = part.font;
}
if (!curColor || part.color[0] !== curColor[0] || part.color[1] !== curColor[1] || part.color[2] !== curColor[2]) {
ops.push(`${col(part.color)} rg`);
curColor = part.color;
}
const clean = String(part.text).replace(/\t/g, ' ');
ops.push(`(${esc(toLatin1(clean))}) Tj`);
}
ops.push('ET');
this.currentPage.ops.push(ops.join(' '));
}
rect(x, yTop, w, h, { fill = null, stroke = null, lineWidth = 1 } = {}) { rect(x, yTop, w, h, { fill = null, stroke = null, lineWidth = 1 } = {}) {
const y = this.pageHeight - yTop - h; const y = this.pageHeight - yTop - h;
const ops = []; const ops = [];
+10 -4
View File
@@ -392,13 +392,19 @@ function renderAnnotations(baseImg, annotations = []) {
return img; return img;
} }
/** Apply focused view (zoom/pan crop, then scale back to original size). */ /**
* Apply focused view (zoom/pan crop, then scale back to original size).
* panX/panY are 0..1 fractions of the available pan range, not absolute
* image positions: 0 puts the crop at the left/bottom edge, 1 at the
* right/top edge, 0.5 centers it. Y is inverted so panY increases upward.
*/
function applyFocusedView(img, fv) { function applyFocusedView(img, fv) {
if (!fv || !fv.enabled || !(fv.zoom > 1)) return img; if (!fv || !fv.enabled || !(fv.zoom > 1)) return img;
const vw = img.width / fv.zoom, vh = img.height / fv.zoom; const vw = img.width / fv.zoom, vh = img.height / fv.zoom;
const cx = Math.min(Math.max(fv.panX * img.width, vw / 2), img.width - vw / 2); const panX = fv.panX ?? 0.5, panY = fv.panY ?? 0.5;
const cy = Math.min(Math.max(fv.panY * img.height, vh / 2), img.height - vh / 2); const cropX = panX * (img.width - vw);
const region = crop(img, cx - vw / 2, cy - vh / 2, vw, vh); const cropY = (1 - panY) * (img.height - vh);
const region = crop(img, cropX, cropY, vw, vh);
return resize(region, img.width, img.height); return resize(region, img.width, img.height);
} }
+13 -9
View File
@@ -3,7 +3,7 @@
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { sanitizeHtml } = require('./sanitize'); const { sanitizeHtml } = require('./sanitize');
const { htmlToText, deepClone } = require('./util'); const { htmlToText, linkifyMarkdownLinks, deepClone } = require('./util');
const { systemPlaceholders, resolveScopes, expandPlaceholders } = require('./placeholders'); const { systemPlaceholders, resolveScopes, expandPlaceholders } = require('./placeholders');
const { decodePng } = require('./png'); const { decodePng } = require('./png');
const { renderAnnotations, applyFocusedView } = require('./raster'); const { renderAnnotations, applyFocusedView } = require('./raster');
@@ -32,6 +32,10 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
system: systemPlaceholders(guide, { now, stepCount: includedIds.length }), system: systemPlaceholders(guide, { now, stepCount: includedIds.length }),
}); });
const expand = (text) => expandPlaceholders(text, values); const expand = (text) => expandPlaceholders(text, values);
// Description fields additionally turn literal "[text](url)" markdown
// link syntax (as inserted by the editor's Link button) into real <a>
// tags before sanitizing, so exporters render an actual link.
const expandDesc = (html) => linkifyMarkdownLinks(expand(html || ''));
const steps = []; const steps = [];
const topCounter = { n: 0 }; const topCounter = { n: 0 };
@@ -66,15 +70,15 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
skipped: step.skipped, skipped: step.skipped,
forceNewPage: Boolean(step.forceNewPage), forceNewPage: Boolean(step.forceNewPage),
title: expand(step.title || ''), title: expand(step.title || ''),
descriptionHtml: sanitizeHtml(expand(step.descriptionHtml || '')), descriptionHtml: sanitizeHtml(expandDesc(step.descriptionHtml)),
descriptionText: htmlToText(expand(step.descriptionHtml || '')), descriptionText: htmlToText(expandDesc(step.descriptionHtml)),
focusedView: step.focusedView, focusedView: step.focusedView,
annotations: (step.annotations || []).map((a) => ({ ...a, text: expand(a.text || '') })), annotations: (step.annotations || []).map((a) => ({ ...a, text: expand(a.text || '') })),
textBlocks: (step.textBlocks || []).map((tb) => ({ textBlocks: (step.textBlocks || []).map((tb) => ({
...tb, ...tb,
title: expand(tb.title || ''), title: expand(tb.title || ''),
descriptionHtml: sanitizeHtml(expand(tb.descriptionHtml || '')), descriptionHtml: sanitizeHtml(expandDesc(tb.descriptionHtml)),
descriptionText: htmlToText(expand(tb.descriptionHtml || '')), descriptionText: htmlToText(expandDesc(tb.descriptionHtml)),
})), })),
codeBlocks: (step.codeBlocks || []).map((cb) => ({ ...cb, code: blockText(cb) })), codeBlocks: (step.codeBlocks || []).map((cb) => ({ ...cb, code: blockText(cb) })),
tableBlocks: (step.tableBlocks || []).map((tb) => ({ tableBlocks: (step.tableBlocks || []).map((tb) => ({
@@ -86,8 +90,8 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
return { return {
...block, ...block,
title: expand(block.title || ''), title: expand(block.title || ''),
descriptionHtml: sanitizeHtml(expand(block.descriptionHtml || '')), descriptionHtml: sanitizeHtml(expandDesc(block.descriptionHtml)),
descriptionText: htmlToText(expand(block.descriptionHtml || '')), descriptionText: htmlToText(expandDesc(block.descriptionHtml)),
}; };
} }
if (block.kind === 'code') return { ...block }; if (block.kind === 'code') return { ...block };
@@ -116,8 +120,8 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
guide: { guide: {
id: guide.guideId, id: guide.guideId,
title: expand(guide.title), title: expand(guide.title),
descriptionHtml: sanitizeHtml(expand(guide.descriptionHtml || '')), descriptionHtml: sanitizeHtml(expandDesc(guide.descriptionHtml)),
descriptionText: htmlToText(expand(guide.descriptionHtml || '')), descriptionText: htmlToText(expandDesc(guide.descriptionHtml)),
createdAt: guide.createdAt, createdAt: guide.createdAt,
updatedAt: guide.updatedAt, updatedAt: guide.updatedAt,
flags: guide.flags, flags: guide.flags,
+21
View File
@@ -244,6 +244,27 @@ class GuideStore {
this.saveGuide(guide); this.saveGuide(guide);
} }
/**
* Recreate a previously-deleted step, preserving its stepId, and splice it
* back into the guide's order. Used to undo step deletion.
*/
restoreStep(guideId, step, images, position) {
const guide = this.getGuide(guideId);
const restored = normalizeStep(deepClone(step));
validateStep(restored);
const dir = this.stepDir(guideId, restored.stepId);
fs.mkdirSync(dir, { recursive: true });
if (restored.image && images) {
if (images.original) atomicWriteFileSync(path.join(dir, restored.image.originalPath), images.original);
if (images.working) atomicWriteFileSync(path.join(dir, restored.image.workingPath), images.working);
}
writeJsonSync(path.join(dir, 'step.json'), restored);
const at = Number.isInteger(position) ? Math.min(position, guide.stepsOrder.length) : guide.stepsOrder.length;
guide.stepsOrder.splice(at, 0, restored.stepId);
this.saveGuide(guide);
return restored;
}
reorderSteps(guideId, newOrder) { reorderSteps(guideId, newOrder) {
const guide = this.getGuide(guideId); const guide = this.getGuide(guideId);
const current = new Set(guide.stepsOrder); const current = new Set(guide.stepsOrder);
+15
View File
@@ -95,6 +95,20 @@ function clamp(v, min, max) {
return Math.min(max, Math.max(min, v)); return Math.min(max, Math.max(min, v));
} }
// Matches literal markdown-style links typed in the description editor,
// e.g. "[Settings](https://example.com)". Excludes < > so it never spans
// across an existing HTML tag boundary.
const MD_LINK_RE = /\[([^[\]<>]*)\]\(([^()<>]+)\)/g;
/**
* Turn literal "[text](url)" markdown link syntax (as inserted by the
* description editor's Link button) into real <a href> tags, so exporters
* that consume HTML render an actual link instead of the raw brackets.
*/
function linkifyMarkdownLinks(html) {
return String(html || '').replace(MD_LINK_RE, (m, label, href) => `<a href="${escapeHtml(href)}">${label}</a>`);
}
/** Filesystem-safe slug for export folder names like steps-<title>. */ /** Filesystem-safe slug for export folder names like steps-<title>. */
function slugify(text, fallback = 'untitled') { function slugify(text, fallback = 'untitled') {
const slug = String(text || '') const slug = String(text || '')
@@ -116,6 +130,7 @@ module.exports = {
readJsonSync, readJsonSync,
readJsonIfExists, readJsonIfExists,
htmlToText, htmlToText,
linkifyMarkdownLinks,
decodeEntities, decodeEntities,
escapeHtml, escapeHtml,
escapeXml, escapeXml,
+15
View File
@@ -55,6 +55,20 @@ function stepBlocks(step) {
return step.blocks || orderedBlocks(step); return step.blocks || orderedBlocks(step);
} }
/**
* Split a step's blocks into the groups exporters lay out around the
* description/image: text blocks pinned to 'before-description', and
* everything else (code/table blocks plus 'after-description' and
* 'after-image' text blocks) in the same relative order they appear in the
* editor's Blocks list.
*/
function stepContentGroups(step) {
const all = stepBlocks(step);
const before = all.filter((b) => b.kind === 'text' && b.position === 'before-description');
const rest = all.filter((b) => !(b.kind === 'text' && b.position === 'before-description'));
return { before, rest };
}
function codeBlockText(block) { function codeBlockText(block) {
return blockText(block); return blockText(block);
} }
@@ -67,6 +81,7 @@ module.exports = {
writeStepImages, writeStepImages,
renderAllImages, renderAllImages,
stepBlocks, stepBlocks,
stepContentGroups,
codeBlockText, codeBlockText,
LEVEL_LABEL, LEVEL_LABEL,
}; };
+7 -11
View File
@@ -4,7 +4,7 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { slugify, escapeXml } = require('../core/util'); const { slugify, escapeXml } = require('../core/util');
const { encodePng } = require('../core/png'); const { encodePng } = require('../core/png');
const { guideSlug, renderAllImages, stepBlocks, codeBlockText } = require('./common'); const { guideSlug, renderAllImages, stepContentGroups, codeBlockText } = require('./common');
/** /**
* Confluence storage-format export. Writes a single XHTML document plus a * Confluence storage-format export. Writes a single XHTML document plus a
@@ -67,7 +67,8 @@ function exportConfluence(ast, outDir, template = {}) {
const parts = [`<a id="${anchorFor(step)}"></a>`, `<h2>${escapeXml(step.number)}. ${escapeXml(step.title || 'Untitled step')}</h2>`]; const parts = [`<a id="${anchorFor(step)}"></a>`, `<h2>${escapeXml(step.number)}. ${escapeXml(step.title || 'Untitled step')}</h2>`];
if (step.skipped) parts.push('<p><em>(skipped)</em></p>'); if (step.skipped) parts.push('<p><em>(skipped)</em></p>');
for (const tb of stepBlocks(step).filter((block) => block.kind === 'text' && block.position === 'before-description')) { const { before, rest } = stepContentGroups(step);
for (const tb of before) {
parts.push(blockMacro(tb, ast)); parts.push(blockMacro(tb, ast));
} }
@@ -80,8 +81,10 @@ function exportConfluence(ast, outDir, template = {}) {
parts.push(`<p><ac:image><ri:attachment ri:filename="${escapeXml(attachment)}" /></ac:image></p>`); parts.push(`<p><ac:image><ri:attachment ri:filename="${escapeXml(attachment)}" /></ac:image></p>`);
} }
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) { for (const block of rest) {
if (block.kind === 'code') { if (block.kind === 'text') {
parts.push(blockMacro(block, ast));
} else if (block.kind === 'code') {
const lang = block.language ? `<ac:parameter ac:name="language">${escapeXml(block.language)}</ac:parameter>` : ''; const lang = block.language ? `<ac:parameter ac:name="language">${escapeXml(block.language)}</ac:parameter>` : '';
parts.push(`<ac:structured-macro ac:name="code">${lang}<ac:plain-text-body>${cdata(codeBlockText(block))}</ac:plain-text-body></ac:structured-macro>`); parts.push(`<ac:structured-macro ac:name="code">${lang}<ac:plain-text-body>${cdata(codeBlockText(block))}</ac:plain-text-body></ac:structured-macro>`);
} else if (block.kind === 'table') { } else if (block.kind === 'table') {
@@ -97,13 +100,6 @@ function exportConfluence(ast, outDir, template = {}) {
} }
} }
for (const tb of stepBlocks(step).filter((block) => block.kind === 'text' && block.position === 'after-description')) {
parts.push(blockMacro(tb, ast));
}
for (const tb of stepBlocks(step).filter((block) => block.kind === 'text' && block.position === 'after-image')) {
parts.push(blockMacro(tb, ast));
}
return `<div class="step">${parts.join('\n')}</div>`; return `<div class="step">${parts.join('\n')}</div>`;
}).join('\n'); }).join('\n');
+20 -11
View File
@@ -5,7 +5,7 @@ const path = require('node:path');
const { zipSync } = require('../core/zip'); const { zipSync } = require('../core/zip');
const { escapeXml } = require('../core/util'); const { escapeXml } = require('../core/util');
const { encodePng } = require('../core/png'); const { encodePng } = require('../core/png');
const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common'); const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
/** /**
* DOCX exporter: WordprocessingML built directly (no dependency), one * DOCX exporter: WordprocessingML built directly (no dependency), one
@@ -18,6 +18,15 @@ const DEFAULT_TEMPLATE = {
imageWidthTwips: 9000, // ~15.9cm inside A4 margins imageWidthTwips: 9000, // ~15.9cm inside A4 margins
}; };
// Callout styling per text-block level, matching the colors used in the
// HTML/PDF exports so a "Tip" looks distinct from a "Warning" at a glance.
const LEVEL_STYLE = {
info: { fill: 'EFF6FF', color: '1D4ED8' }, // blue — Note
success: { fill: 'ECFDF5', color: '047857' }, // green — Tip
warn: { fill: 'FFFBEB', color: 'B45309' }, // amber — Warning
error: { fill: 'FEF2F2', color: 'B91C1C' }, // red — Important
};
const EMU_PER_PX = 9525; // 96 dpi const EMU_PER_PX = 9525; // 96 dpi
function p(children, props = '') { function p(children, props = '') {
@@ -90,7 +99,8 @@ function exportDocx(ast, outDir, template = {}) {
body.push(p(run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }), body.push(p(run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }),
step.forceNewPage ? '<w:pageBreakBefore/>' : '')); step.forceNewPage ? '<w:pageBreakBefore/>' : ''));
emitTextBlocks(step, 'before-description'); const { before, rest } = stepContentGroups(step);
for (const tb of before) emitTextBlock(tb);
if (step.descriptionText) body.push(p(run(step.descriptionText))); if (step.descriptionText) body.push(p(run(step.descriptionText)));
const img = images.get(step.stepId); const img = images.get(step.stepId);
@@ -102,27 +112,26 @@ function exportDocx(ast, outDir, template = {}) {
body.push(p(drawing(relCounter, img.width, img.height, tpl.imageWidthTwips))); body.push(p(drawing(relCounter, img.width, img.height, tpl.imageWidthTwips)));
} }
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) { for (const block of rest) {
if (block.kind === 'code') { if (block.kind === 'text') {
emitTextBlock(block);
} else if (block.kind === 'code') {
body.push(p(run(codeBlockText(block), { size: 18, font: 'Courier New', color: '1F2937' }), body.push(p(run(codeBlockText(block), { size: 18, font: 'Courier New', color: '1F2937' }),
'<w:shd w:val="clear" w:fill="F3F4F6"/>')); '<w:shd w:val="clear" w:fill="F3F4F6"/>'));
} else if (block.kind === 'table') { } else if (block.kind === 'table') {
if (block.rows && block.rows.length) body.push(table(block.rows), p('')); if (block.rows && block.rows.length) body.push(table(block.rows), p(''));
} }
} }
emitTextBlocks(step, 'after-description');
emitTextBlocks(step, 'after-image');
} }
function emitTextBlocks(step, position) { function emitTextBlock(tb) {
for (const tb of stepBlocks(step).filter((b) => b.kind === 'text' && b.position === position)) {
const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`; const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`;
const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info;
body.push(p( body.push(p(
run(label, { bold: true, size: 20 }) + (tb.descriptionText ? run('\n' + tb.descriptionText, { size: 20 }) : ''), run(label, { bold: true, size: 20, color: style.color }) + (tb.descriptionText ? run('\n' + tb.descriptionText, { size: 20 }) : ''),
'<w:shd w:val="clear" w:fill="F9FAFB"/>' `<w:shd w:val="clear" w:fill="${style.fill}"/><w:pBdr><w:left w:val="single" w:sz="24" w:space="4" w:color="${style.color}"/></w:pBdr>`
)); ));
} }
}
const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main" <w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
+16 -14
View File
@@ -4,7 +4,7 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { escapeHtml } = require('../core/util'); const { escapeHtml } = require('../core/util');
const { encodePng } = require('../core/png'); const { encodePng } = require('../core/png');
const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common'); const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
/** /**
* HTML exporters. Both variants are fully self-contained single files: * HTML exporters. Both variants are fully self-contained single files:
@@ -38,34 +38,32 @@ function stepLinkRewrite(html, ast) {
}); });
} }
function blocksHtml(step, position) { function blockHtml(tb) {
return stepBlocks(step) return `<div class="block block-${tb.level}"><strong>${escapeHtml(LEVEL_LABEL[tb.level] || 'Note')}${tb.title ? `: ${escapeHtml(tb.title)}` : ''}</strong>${tb.descriptionHtml ? `<div>${tb.descriptionHtml}</div>` : ''}</div>`;
.filter((tb) => tb.position === position)
.map((tb) => `<div class="block block-${tb.level}"><strong>${escapeHtml(LEVEL_LABEL[tb.level] || 'Note')}${tb.title ? `: ${escapeHtml(tb.title)}` : ''}</strong>${tb.descriptionHtml ? `<div>${tb.descriptionHtml}</div>` : ''}</div>`)
.join('\n');
} }
function stepBodyHtml(step, ast, images, tpl) { function stepBodyHtml(step, ast, images, tpl) {
const parts = []; const parts = [];
parts.push(blocksHtml(step, 'before-description')); const { before, rest } = stepContentGroups(step);
for (const tb of before) parts.push(blockHtml(tb));
if (step.descriptionHtml) parts.push(`<div class="desc">${stepLinkRewrite(step.descriptionHtml, ast)}</div>`); if (step.descriptionHtml) parts.push(`<div class="desc">${stepLinkRewrite(step.descriptionHtml, ast)}</div>`);
const img = images.get(step.stepId); const img = images.get(step.stepId);
if (img && tpl.includeImages) { if (img && tpl.includeImages) {
parts.push(`<img class="shot" alt="Step ${escapeHtml(step.number)}" src="${dataUri(img)}" width="${img.width}">`); parts.push(`<img class="shot" alt="Step ${escapeHtml(step.number)}" src="${dataUri(img)}" width="${img.width}">`);
} }
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) { for (const block of rest) {
if (block.kind === 'code') { if (block.kind === 'text') {
parts.push(blockHtml(block));
} else if (block.kind === 'code') {
parts.push(`<pre class="code"><code>${escapeHtml(codeBlockText(block))}</code></pre>`); parts.push(`<pre class="code"><code>${escapeHtml(codeBlockText(block))}</code></pre>`);
} else if (block.kind === 'table') { } else if (block.kind === 'table') {
if (!block.rows || !block.rows.length) continue; if (!block.rows || !block.rows.length) continue;
const [head, ...rest] = block.rows; const [head, ...bodyRows] = block.rows;
parts.push('<table><thead><tr>' + head.map((c) => `<th>${escapeHtml(c)}</th>`).join('') + '</tr></thead><tbody>' parts.push('<table><thead><tr>' + head.map((c) => `<th>${escapeHtml(c)}</th>`).join('') + '</tr></thead><tbody>'
+ rest.map((r) => '<tr>' + r.map((c) => `<td>${escapeHtml(c)}</td>`).join('') + '</tr>').join('') + bodyRows.map((r) => '<tr>' + r.map((c) => `<td>${escapeHtml(c)}</td>`).join('') + '</tr>').join('')
+ '</tbody></table>'); + '</tbody></table>');
} }
} }
parts.push(blocksHtml(step, 'after-description'));
parts.push(blocksHtml(step, 'after-image'));
return parts.filter(Boolean).join('\n'); return parts.filter(Boolean).join('\n');
} }
@@ -78,10 +76,14 @@ const BASE_CSS = `
pre.code { background: #f3f4f6; padding: 12px; border-radius: 6px; overflow-x: auto; } pre.code { background: #f3f4f6; padding: 12px; border-radius: 6px; overflow-x: auto; }
table { border-collapse: collapse; margin: .6em 0; } table { border-collapse: collapse; margin: .6em 0; }
th, td { border: 1px solid #d1d5db; padding: 4px 10px; text-align: left; } th, td { border: 1px solid #d1d5db; padding: 4px 10px; text-align: left; }
.block { border-left: 4px solid #9ca3af; background: #f9fafb; padding: 8px 12px; margin: .6em 0; border-radius: 0 6px 6px 0; } .block { border-left: 4px solid #3b82f6; background: #eff6ff; padding: 8px 12px; margin: .6em 0; border-radius: 0 6px 6px 0; }
.block strong { color: #1d4ed8; }
.block-warn { border-color: #f59e0b; background: #fffbeb; } .block-warn { border-color: #f59e0b; background: #fffbeb; }
.block-warn strong { color: #b45309; }
.block-error { border-color: #ef4444; background: #fef2f2; } .block-error { border-color: #ef4444; background: #fef2f2; }
.block-error strong { color: #b91c1c; }
.block-success { border-color: #10b981; background: #ecfdf5; } .block-success { border-color: #10b981; background: #ecfdf5; }
.block-success strong { color: #047857; }
.skipped { opacity: .55; } .skipped { opacity: .55; }
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
body { background: #111827; color: #e5e7eb; } body { background: #111827; color: #e5e7eb; }
+2
View File
@@ -42,6 +42,8 @@ function htmlToMarkdown(html) {
.replace(/<hr\s*\/?>/gi, '\n---\n') .replace(/<hr\s*\/?>/gi, '\n---\n')
.replace(/<p>/gi, '\n') .replace(/<p>/gi, '\n')
.replace(/<\/p>/gi, '\n') .replace(/<\/p>/gi, '\n')
.replace(/<div>/gi, '\n')
.replace(/<\/div>/gi, '\n')
.replace(/<[^>]+>/g, ''); .replace(/<[^>]+>/g, '');
return decodeEntities(out).replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim(); return decodeEntities(out).replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
+13 -11
View File
@@ -2,7 +2,7 @@
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { guideSlug, writeStepImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common'); const { guideSlug, writeStepImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
const { htmlToMarkdown } = require('./htmlmd'); const { htmlToMarkdown } = require('./htmlmd');
/** /**
@@ -45,7 +45,8 @@ function exportMarkdown(ast, outDir, template = {}) {
lines.push(`${heading} ${step.number}. ${step.title || 'Untitled step'}`, ''); lines.push(`${heading} ${step.number}. ${step.title || 'Untitled step'}`, '');
if (step.skipped) lines.push('*(skipped)*', ''); if (step.skipped) lines.push('*(skipped)*', '');
emitBlocks(lines, step, 'before-description'); const { before, rest } = stepContentGroups(step);
for (const tb of before) emitBlock(lines, tb);
if (step.descriptionHtml) lines.push(htmlToMarkdown(step.descriptionHtml), ''); if (step.descriptionHtml) lines.push(htmlToMarkdown(step.descriptionHtml), '');
@@ -58,8 +59,10 @@ function exportMarkdown(ast, outDir, template = {}) {
} }
} }
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) { for (const block of rest) {
if (block.kind === 'code') { if (block.kind === 'text') {
emitBlock(lines, block);
} else if (block.kind === 'code') {
lines.push(`\`\`\`${block.language || ''}`, codeBlockText(block), '```', ''); lines.push(`\`\`\`${block.language || ''}`, codeBlockText(block), '```', '');
} else if (block.kind === 'table') { } else if (block.kind === 'table') {
if (!block.rows || !block.rows.length) continue; if (!block.rows || !block.rows.length) continue;
@@ -71,9 +74,6 @@ function exportMarkdown(ast, outDir, template = {}) {
lines.push(''); lines.push('');
} }
} }
emitBlocks(lines, step, 'after-description');
emitBlocks(lines, step, 'after-image');
} }
const file = path.join(outDir, `${guideSlug(ast)}.md`); const file = path.join(outDir, `${guideSlug(ast)}.md`);
@@ -81,14 +81,16 @@ function exportMarkdown(ast, outDir, template = {}) {
return { file, imageCount: images.size }; return { file, imageCount: images.size };
} }
function emitBlocks(lines, step, position) { function emitBlock(lines, tb) {
for (const tb of stepBlocks(step).filter((b) => b.kind === 'text' && b.position === position)) {
const label = LEVEL_LABEL[tb.level] || 'Note'; const label = LEVEL_LABEL[tb.level] || 'Note';
lines.push(`> **${label}${tb.title ? `: ${tb.title}` : ''}**`); // GitHub-Flavored Markdown alert syntax — renders with a colored,
// icon-labeled box on GitHub/Azure DevOps wikis and several other
// viewers; degrades to a plain blockquote elsewhere.
lines.push(`> [!${label.toUpperCase()}]`);
if (tb.title) lines.push(`> **${tb.title}**`);
const body = htmlToMarkdown(tb.descriptionHtml); const body = htmlToMarkdown(tb.descriptionHtml);
if (body) lines.push(`> ${body.replace(/\n/g, '\n> ')}`); if (body) lines.push(`> ${body.replace(/\n/g, '\n> ')}`);
lines.push(''); lines.push('');
}
} }
module.exports = { exportMarkdown, DEFAULT_TEMPLATE, anchorFor }; module.exports = { exportMarkdown, DEFAULT_TEMPLATE, anchorFor };
+153 -18
View File
@@ -3,8 +3,100 @@
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const { PdfBuilder } = require('../core/pdf'); const { PdfBuilder } = require('../core/pdf');
const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common'); const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common');
const { htmlToText } = require('../core/util'); const { htmlToText } = require('../core/util');
const { htmlToBlocks } = require('../core/htmlblocks');
const LIST_INDENT = 14;
const QUOTE_INDENT = 10;
const HEADING_BUMP = { h1: 2.5, h2: 2, h3: 1.5, h4: 1 };
// Callout styling per text-block level, matching the colors used in the
// HTML/editor UI so a "Tip" looks distinct from a "Warning" at a glance.
const LEVEL_STYLE = {
info: { accent: [59, 130, 246], tint: [239, 246, 255] }, // blue — Note
success: { accent: [16, 185, 129], tint: [236, 253, 245] }, // green — Tip
warn: { accent: [245, 158, 11], tint: [255, 251, 235] }, // amber — Warning
error: { accent: [239, 68, 68], tint: [254, 242, 242] }, // red — Important
};
function fontForRun(run) {
if (run.code) return 'F3';
if (run.bold && run.italic) return 'F5';
if (run.bold) return 'F2';
if (run.italic) return 'F4';
return 'F1';
}
/** Split formatted runs into words (font/link tagged) and greedily wrap to maxWidth. */
function wrapRuns(pdf, runs, size, maxWidth) {
const words = [];
for (const run of runs) {
const font = fontForRun(run);
for (const part of run.text.split(/(\s+)/)) {
if (part === '') continue;
words.push({ text: part, font, href: run.href });
}
}
const lines = [];
let line = [];
let w = 0;
for (const word of words) {
const isSpace = /^\s+$/.test(word.text);
const ww = pdf.textWidth(word.text, size, word.font);
if (!isSpace && line.length && w + ww > maxWidth) {
lines.push(line);
line = [];
w = 0;
}
if (isSpace && !line.length) continue;
line.push(word);
w += ww;
}
if (line.length) lines.push(line);
return lines;
}
/**
* Lay out description HTML into render-ready items, preserving bold,
* italic, links, lists, blockquotes and headings.
* Returns { items, height }; items: { kind: 'hr', width } | { kind: 'text',
* lines, size, lineHeight, indent, prefix, muted }.
*/
function layoutDescription(pdf, html, maxWidth, baseSize) {
const items = [];
let height = 0;
for (const block of htmlToBlocks(html || '')) {
if (block.type === 'hr') {
items.push({ kind: 'hr', width: maxWidth });
height += 12;
continue;
}
let size = baseSize;
let runs = block.runs;
let indent = (block.indent || 0) * LIST_INDENT;
let prefix = null;
let muted = false;
if (HEADING_BUMP[block.type]) {
size = baseSize + HEADING_BUMP[block.type];
runs = runs.map((r) => ({ ...r, bold: true }));
} else if (block.type === 'li') {
prefix = '•';
indent += LIST_INDENT;
} else if (block.type === 'oli') {
prefix = `${block.n}.`;
indent += LIST_INDENT;
} else if (block.type === 'blockquote') {
indent += QUOTE_INDENT;
muted = true;
}
const lines = wrapRuns(pdf, runs, size, maxWidth - indent);
const lineHeight = size * 1.35;
items.push({ kind: 'text', lines, size, lineHeight, indent, prefix, muted });
height += lines.length * lineHeight + 4;
}
return { items, height };
}
/** /**
* PDF exporter: cover block, optional TOC, one section per step with the * PDF exporter: cover block, optional TOC, one section per step with the
@@ -51,6 +143,31 @@ function exportPdf(ast, outDir, template = {}) {
y += fs_ * leading; y += fs_ * leading;
} }
}; };
/** Render laid-out description items, preserving bold/italic/links/lists. */
const writeDescription = (html, { size: baseSize = 10.5, color = [0, 0, 0], indent: indentBase = 0 } = {}) => {
const { items } = layoutDescription(pdf, html, usableW - indentBase, baseSize);
for (const item of items) {
if (item.kind === 'hr') {
ensure(12);
pdf.rect(M + indentBase, y + 5, item.width, 0.8, { fill: [225, 228, 232] });
y += 12;
continue;
}
item.lines.forEach((line, idx) => {
ensure(item.lineHeight);
const textX = M + indentBase + item.indent;
if (idx === 0 && item.prefix) pdf.text(item.prefix, textX - LIST_INDENT, y, { size: item.size, font: 'F1', color });
const parts = line.map((word) => ({
text: word.text,
font: word.font,
color: word.href ? tpl.accentColor : (item.muted ? [100, 100, 100] : color),
}));
pdf.textRun(parts, textX, y, item.size);
y += item.lineHeight;
});
y += 4;
}
};
pdf.addPage(); pdf.addPage();
@@ -60,7 +177,7 @@ function exportPdf(ast, outDir, template = {}) {
y += 6; y += 6;
writeLines(ast.guide.title, { size: 26, font: 'F2' }); writeLines(ast.guide.title, { size: 26, font: 'F2' });
y += 8; y += 8;
if (ast.guide.descriptionText) writeLines(ast.guide.descriptionText, { size: 12, color: [70, 70, 70] }); if (ast.guide.descriptionHtml) writeDescription(ast.guide.descriptionHtml, { size: 12, color: [70, 70, 70] });
y += 14; y += 14;
writeLines(`${ast.steps.length} steps — generated ${ast.generatedAt.slice(0, 10)}`, { size: 10, color: [120, 120, 120] }); writeLines(`${ast.steps.length} steps — generated ${ast.generatedAt.slice(0, 10)}`, { size: 10, color: [120, 120, 120] });
pdf.addPage(); pdf.addPage();
@@ -90,8 +207,9 @@ function exportPdf(ast, outDir, template = {}) {
pdf.rect(M, y, usableW, 0.8, { fill: [225, 228, 232] }); pdf.rect(M, y, usableW, 0.8, { fill: [225, 228, 232] });
y += 8; y += 8;
emitBlocks(step, 'before-description'); const { before, rest } = stepContentGroups(step);
if (step.descriptionText) { writeLines(step.descriptionText); y += 4; } for (const tb of before) emitBlock(tb);
if (step.descriptionHtml) writeDescription(step.descriptionHtml);
const img = images.get(step.stepId); const img = images.get(step.stepId);
if (img) { if (img) {
@@ -104,8 +222,10 @@ function exportPdf(ast, outDir, template = {}) {
y += h + 10; y += h + 10;
} }
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) { for (const block of rest) {
if (block.kind === 'code') { if (block.kind === 'text') {
emitBlock(block);
} else if (block.kind === 'code') {
const lines = String(codeBlockText(block) || '').split('\n'); const lines = String(codeBlockText(block) || '').split('\n');
const lineH = 9 * 1.3; const lineH = 9 * 1.3;
ensure(Math.min(lines.length, 4) * lineH + 12); ensure(Math.min(lines.length, 4) * lineH + 12);
@@ -138,27 +258,42 @@ function exportPdf(ast, outDir, template = {}) {
} }
} }
emitBlocks(step, 'after-description');
emitBlocks(step, 'after-image');
y += 10; y += 10;
} }
function emitBlocks(step, position) { function emitBlock(tb) {
for (const tb of stepBlocks(step).filter((b) => b.kind === 'text' && b.position === position)) {
const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`; const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`;
const bodyLines = tb.descriptionText ? pdf.wrapText(tb.descriptionText, 9.5, usableW - 18) : []; const { items, height: bodyH } = tb.descriptionHtml
const blockH = 16 + bodyLines.length * 13; ? layoutDescription(pdf, tb.descriptionHtml, usableW - 18, 9.5)
: { items: [], height: 0 };
const blockH = 16 + bodyH;
const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info;
ensure(blockH + 4); ensure(blockH + 4);
pdf.rect(M, y, 3, blockH, { fill: tpl.accentColor }); pdf.rect(M, y, usableW, blockH, { fill: style.tint });
pdf.text(label, M + 10, y + 2, { size: 9.5, font: 'F2' }); pdf.rect(M, y, 3, blockH, { fill: style.accent });
pdf.text(label, M + 10, y + 2, { size: 9.5, font: 'F2', color: style.accent });
let by = y + 16; let by = y + 16;
for (const line of bodyLines) { for (const item of items) {
pdf.text(line, M + 10, by, { size: 9.5 }); if (item.kind === 'hr') {
by += 13; pdf.rect(M + 10, by + 5, item.width, 0.8, { fill: [225, 228, 232] });
by += 12;
continue;
}
item.lines.forEach((line, idx) => {
const textX = M + 10 + item.indent;
if (idx === 0 && item.prefix) pdf.text(item.prefix, textX - LIST_INDENT, by, { size: item.size, font: 'F1' });
const parts = line.map((word) => ({
text: word.text,
font: word.font,
color: word.href ? tpl.accentColor : (item.muted ? [100, 100, 100] : [0, 0, 0]),
}));
pdf.textRun(parts, textX, by, item.size);
by += item.lineHeight;
});
by += 4;
} }
y += blockH + 6; y += blockH + 6;
} }
}
fs.mkdirSync(outDir, { recursive: true }); fs.mkdirSync(outDir, { recursive: true });
const file = path.join(outDir, `${guideSlug(ast)}.pdf`); const file = path.join(outDir, `${guideSlug(ast)}.pdf`);
+4 -3
View File
@@ -109,10 +109,11 @@ test('Markdown export: TOC anchors resolve, images exist, blocks rendered', (t)
assert.equal(lines[fenceStart + 1], '0 2 * * * /usr/local/bin/acmesync --backup'); assert.equal(lines[fenceStart + 1], '0 2 * * * /usr/local/bin/acmesync --backup');
assert.equal(lines[fenceStart + 2], '```'); assert.equal(lines[fenceStart + 2], '```');
assert.ok(lines.some((l) => /^\| Day \| Window \|$/.test(l)), 'table header row'); assert.ok(lines.some((l) => /^\| Day \| Window \|$/.test(l)), 'table header row');
// Warning text block became a blockquote with its content. // Warning text block became a GFM alert blockquote with its content.
const warnIdx = lines.findIndex((l) => l.startsWith('> **Warning: Access**')); const warnIdx = lines.findIndex((l) => l.startsWith('> [!WARNING]'));
assert.ok(warnIdx > 0); assert.ok(warnIdx > 0);
assert.equal(lines[warnIdx + 1], '> Admins only.'); assert.equal(lines[warnIdx + 1], '> **Access**');
assert.equal(lines[warnIdx + 2], '> Admins only.');
}); });
test('Confluence export writes storage-format XML and image attachments', (t) => { test('Confluence export writes storage-format XML and image attachments', (t) => {
+25
View File
@@ -69,6 +69,31 @@ test('step reorder, delete with substep reparenting, and order integrity', (t) =
assert.equal(store.getStep(guide.guideId, c.stepId).parentStepId, null); assert.equal(store.getStep(guide.guideId, c.stepId).parentStepId, null);
}); });
test('restoreStep recreates a deleted step with its original id, data, and images', (t) => {
const root = makeTmpDir('restore');
t.after(() => rmrf(root));
const store = new GuideStore(root);
const guide = store.createGuide({ title: 'Restore test' });
const a = store.addStep(guide.guideId, { kind: 'empty', title: 'A' });
const b = store.addStep(guide.guideId, { title: 'B', annotations: [{ id: 'ann1', type: 'arrow', x: 1, y: 2 }] }, TINY_PNG, { width: 1, height: 1 });
const c = store.addStep(guide.guideId, { kind: 'empty', title: 'C' });
const deleted = store.getStep(guide.guideId, b.stepId);
store.deleteStep(guide.guideId, b.stepId);
assert.deepEqual(store.getGuide(guide.guideId).stepsOrder, [a.stepId, c.stepId]);
const restored = store.restoreStep(guide.guideId, deleted, { original: TINY_PNG, working: TINY_PNG }, 1);
assert.equal(restored.stepId, b.stepId);
assert.equal(restored.title, 'B');
assert.equal(restored.annotations[0].type, 'arrow');
const after = store.getGuide(guide.guideId);
assert.deepEqual(after.stepsOrder, [a.stepId, b.stepId, c.stepId]);
assert.deepEqual(store.getStep(guide.guideId, b.stepId).annotations, deleted.annotations);
assert.deepEqual(fs.readFileSync(store.stepImagePath(guide.guideId, b.stepId, 'original')), TINY_PNG);
assert.deepEqual(fs.readFileSync(store.stepImagePath(guide.guideId, b.stepId, 'working')), TINY_PNG);
});
test('duplicate guide produces independent deep copy with fresh ids', (t) => { test('duplicate guide produces independent deep copy with fresh ids', (t) => {
const root = makeTmpDir('dup'); const root = makeTmpDir('dup');
t.after(() => rmrf(root)); t.after(() => rmrf(root));