Compare commits
26
Commits
570173cf4f
...
e588f93a19
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e588f93a19 | ||
|
|
4732c5daf3 | ||
|
|
db2382294e | ||
|
|
58b4182c54 | ||
|
|
88d1fc3efb | ||
|
|
2bb17b170e | ||
|
|
6408040623 | ||
|
|
a3425c36b4 | ||
|
|
5d4925dee4 | ||
|
|
06fba24645 | ||
|
|
fee394f6b7 | ||
|
|
f094bbbd73 | ||
|
|
0ead121327 | ||
|
|
420b32c0f8 | ||
|
|
bb959a4bb9 | ||
|
|
46d12cc829 | ||
|
|
8e2e1f41fb | ||
|
|
a58607f029 | ||
|
|
be88434e44 | ||
|
|
1776089c08 | ||
|
|
31bdbea7b5 | ||
|
|
9e88991f46 | ||
|
|
014b92675d | ||
|
|
b39fe553b2 | ||
|
|
602e70a7e1 | ||
|
|
f23b49c0d1 |
@@ -2,11 +2,9 @@ name: Template tests
|
||||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
tests:
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -9,19 +9,41 @@ const zlib = require('node:zlib');
|
||||
* 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.
|
||||
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) {
|
||||
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) {
|
||||
let out = '';
|
||||
for (const ch of String(text)) {
|
||||
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;
|
||||
}
|
||||
@@ -73,11 +95,42 @@ class PdfBuilder {
|
||||
|
||||
text(str, x, yTop, { size = 11, font = 'F1', color = [0, 0, 0] } = {}) {
|
||||
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(
|
||||
`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 } = {}) {
|
||||
const y = this.pageHeight - yTop - h;
|
||||
const ops = [];
|
||||
|
||||
+10
-4
@@ -392,13 +392,19 @@ function renderAnnotations(baseImg, annotations = []) {
|
||||
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) {
|
||||
if (!fv || !fv.enabled || !(fv.zoom > 1)) return img;
|
||||
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 cy = Math.min(Math.max(fv.panY * img.height, vh / 2), img.height - vh / 2);
|
||||
const region = crop(img, cx - vw / 2, cy - vh / 2, vw, vh);
|
||||
const panX = fv.panX ?? 0.5, panY = fv.panY ?? 0.5;
|
||||
const cropX = panX * (img.width - vw);
|
||||
const cropY = (1 - panY) * (img.height - vh);
|
||||
const region = crop(img, cropX, cropY, vw, vh);
|
||||
return resize(region, img.width, img.height);
|
||||
}
|
||||
|
||||
|
||||
+13
-9
@@ -3,7 +3,7 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { sanitizeHtml } = require('./sanitize');
|
||||
const { htmlToText, deepClone } = require('./util');
|
||||
const { htmlToText, linkifyMarkdownLinks, deepClone } = require('./util');
|
||||
const { systemPlaceholders, resolveScopes, expandPlaceholders } = require('./placeholders');
|
||||
const { decodePng } = require('./png');
|
||||
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 }),
|
||||
});
|
||||
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 topCounter = { n: 0 };
|
||||
@@ -66,15 +70,15 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
|
||||
skipped: step.skipped,
|
||||
forceNewPage: Boolean(step.forceNewPage),
|
||||
title: expand(step.title || ''),
|
||||
descriptionHtml: sanitizeHtml(expand(step.descriptionHtml || '')),
|
||||
descriptionText: htmlToText(expand(step.descriptionHtml || '')),
|
||||
descriptionHtml: sanitizeHtml(expandDesc(step.descriptionHtml)),
|
||||
descriptionText: htmlToText(expandDesc(step.descriptionHtml)),
|
||||
focusedView: step.focusedView,
|
||||
annotations: (step.annotations || []).map((a) => ({ ...a, text: expand(a.text || '') })),
|
||||
textBlocks: (step.textBlocks || []).map((tb) => ({
|
||||
...tb,
|
||||
title: expand(tb.title || ''),
|
||||
descriptionHtml: sanitizeHtml(expand(tb.descriptionHtml || '')),
|
||||
descriptionText: htmlToText(expand(tb.descriptionHtml || '')),
|
||||
descriptionHtml: sanitizeHtml(expandDesc(tb.descriptionHtml)),
|
||||
descriptionText: htmlToText(expandDesc(tb.descriptionHtml)),
|
||||
})),
|
||||
codeBlocks: (step.codeBlocks || []).map((cb) => ({ ...cb, code: blockText(cb) })),
|
||||
tableBlocks: (step.tableBlocks || []).map((tb) => ({
|
||||
@@ -86,8 +90,8 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
|
||||
return {
|
||||
...block,
|
||||
title: expand(block.title || ''),
|
||||
descriptionHtml: sanitizeHtml(expand(block.descriptionHtml || '')),
|
||||
descriptionText: htmlToText(expand(block.descriptionHtml || '')),
|
||||
descriptionHtml: sanitizeHtml(expandDesc(block.descriptionHtml)),
|
||||
descriptionText: htmlToText(expandDesc(block.descriptionHtml)),
|
||||
};
|
||||
}
|
||||
if (block.kind === 'code') return { ...block };
|
||||
@@ -116,8 +120,8 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
|
||||
guide: {
|
||||
id: guide.guideId,
|
||||
title: expand(guide.title),
|
||||
descriptionHtml: sanitizeHtml(expand(guide.descriptionHtml || '')),
|
||||
descriptionText: htmlToText(expand(guide.descriptionHtml || '')),
|
||||
descriptionHtml: sanitizeHtml(expandDesc(guide.descriptionHtml)),
|
||||
descriptionText: htmlToText(expandDesc(guide.descriptionHtml)),
|
||||
createdAt: guide.createdAt,
|
||||
updatedAt: guide.updatedAt,
|
||||
flags: guide.flags,
|
||||
|
||||
@@ -244,6 +244,27 @@ class GuideStore {
|
||||
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) {
|
||||
const guide = this.getGuide(guideId);
|
||||
const current = new Set(guide.stepsOrder);
|
||||
|
||||
@@ -95,6 +95,20 @@ function clamp(v, min, max) {
|
||||
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>. */
|
||||
function slugify(text, fallback = 'untitled') {
|
||||
const slug = String(text || '')
|
||||
@@ -116,6 +130,7 @@ module.exports = {
|
||||
readJsonSync,
|
||||
readJsonIfExists,
|
||||
htmlToText,
|
||||
linkifyMarkdownLinks,
|
||||
decodeEntities,
|
||||
escapeHtml,
|
||||
escapeXml,
|
||||
|
||||
@@ -55,6 +55,20 @@ function stepBlocks(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) {
|
||||
return blockText(block);
|
||||
}
|
||||
@@ -67,6 +81,7 @@ module.exports = {
|
||||
writeStepImages,
|
||||
renderAllImages,
|
||||
stepBlocks,
|
||||
stepContentGroups,
|
||||
codeBlockText,
|
||||
LEVEL_LABEL,
|
||||
};
|
||||
|
||||
+7
-11
@@ -4,7 +4,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { slugify, escapeXml } = require('../core/util');
|
||||
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
|
||||
@@ -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>`];
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -80,8 +81,10 @@ function exportConfluence(ast, outDir, template = {}) {
|
||||
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')) {
|
||||
if (block.kind === 'code') {
|
||||
for (const block of rest) {
|
||||
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>` : '';
|
||||
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') {
|
||||
@@ -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>`;
|
||||
}).join('\n');
|
||||
|
||||
|
||||
+23
-14
@@ -5,7 +5,7 @@ const path = require('node:path');
|
||||
const { zipSync } = require('../core/zip');
|
||||
const { escapeXml } = require('../core/util');
|
||||
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
|
||||
@@ -18,6 +18,15 @@ const DEFAULT_TEMPLATE = {
|
||||
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
|
||||
|
||||
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 }),
|
||||
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)));
|
||||
|
||||
const img = images.get(step.stepId);
|
||||
@@ -102,26 +112,25 @@ function exportDocx(ast, outDir, template = {}) {
|
||||
body.push(p(drawing(relCounter, img.width, img.height, tpl.imageWidthTwips)));
|
||||
}
|
||||
|
||||
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) {
|
||||
if (block.kind === 'code') {
|
||||
for (const block of rest) {
|
||||
if (block.kind === 'text') {
|
||||
emitTextBlock(block);
|
||||
} else if (block.kind === 'code') {
|
||||
body.push(p(run(codeBlockText(block), { size: 18, font: 'Courier New', color: '1F2937' }),
|
||||
'<w:shd w:val="clear" w:fill="F3F4F6"/>'));
|
||||
} else if (block.kind === 'table') {
|
||||
if (block.rows && block.rows.length) body.push(table(block.rows), p(''));
|
||||
}
|
||||
}
|
||||
emitTextBlocks(step, 'after-description');
|
||||
emitTextBlocks(step, 'after-image');
|
||||
}
|
||||
|
||||
function emitTextBlocks(step, position) {
|
||||
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}` : ''}`;
|
||||
body.push(p(
|
||||
run(label, { bold: true, size: 20 }) + (tb.descriptionText ? run('\n' + tb.descriptionText, { size: 20 }) : ''),
|
||||
'<w:shd w:val="clear" w:fill="F9FAFB"/>'
|
||||
));
|
||||
}
|
||||
function emitTextBlock(tb) {
|
||||
const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`;
|
||||
const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info;
|
||||
body.push(p(
|
||||
run(label, { bold: true, size: 20, color: style.color }) + (tb.descriptionText ? run('\n' + tb.descriptionText, { size: 20 }) : ''),
|
||||
`<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"?>
|
||||
|
||||
+16
-14
@@ -4,7 +4,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { escapeHtml } = require('../core/util');
|
||||
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:
|
||||
@@ -38,34 +38,32 @@ function stepLinkRewrite(html, ast) {
|
||||
});
|
||||
}
|
||||
|
||||
function blocksHtml(step, position) {
|
||||
return stepBlocks(step)
|
||||
.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 blockHtml(tb) {
|
||||
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>`;
|
||||
}
|
||||
|
||||
function stepBodyHtml(step, ast, images, tpl) {
|
||||
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>`);
|
||||
const img = images.get(step.stepId);
|
||||
if (img && tpl.includeImages) {
|
||||
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')) {
|
||||
if (block.kind === 'code') {
|
||||
for (const block of rest) {
|
||||
if (block.kind === 'text') {
|
||||
parts.push(blockHtml(block));
|
||||
} else if (block.kind === 'code') {
|
||||
parts.push(`<pre class="code"><code>${escapeHtml(codeBlockText(block))}</code></pre>`);
|
||||
} else if (block.kind === 'table') {
|
||||
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>'
|
||||
+ 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>');
|
||||
}
|
||||
}
|
||||
parts.push(blocksHtml(step, 'after-description'));
|
||||
parts.push(blocksHtml(step, 'after-image'));
|
||||
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; }
|
||||
table { border-collapse: collapse; margin: .6em 0; }
|
||||
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 strong { color: #b45309; }
|
||||
.block-error { border-color: #ef4444; background: #fef2f2; }
|
||||
.block-error strong { color: #b91c1c; }
|
||||
.block-success { border-color: #10b981; background: #ecfdf5; }
|
||||
.block-success strong { color: #047857; }
|
||||
.skipped { opacity: .55; }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
body { background: #111827; color: #e5e7eb; }
|
||||
|
||||
@@ -42,6 +42,8 @@ function htmlToMarkdown(html) {
|
||||
.replace(/<hr\s*\/?>/gi, '\n---\n')
|
||||
.replace(/<p>/gi, '\n')
|
||||
.replace(/<\/p>/gi, '\n')
|
||||
.replace(/<div>/gi, '\n')
|
||||
.replace(/<\/div>/gi, '\n')
|
||||
.replace(/<[^>]+>/g, '');
|
||||
|
||||
return decodeEntities(out).replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim();
|
||||
|
||||
+17
-15
@@ -2,7 +2,7 @@
|
||||
|
||||
const fs = require('node:fs');
|
||||
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');
|
||||
|
||||
/**
|
||||
@@ -45,7 +45,8 @@ function exportMarkdown(ast, outDir, template = {}) {
|
||||
lines.push(`${heading} ${step.number}. ${step.title || 'Untitled step'}`, '');
|
||||
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), '');
|
||||
|
||||
@@ -58,8 +59,10 @@ function exportMarkdown(ast, outDir, template = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) {
|
||||
if (block.kind === 'code') {
|
||||
for (const block of rest) {
|
||||
if (block.kind === 'text') {
|
||||
emitBlock(lines, block);
|
||||
} else if (block.kind === 'code') {
|
||||
lines.push(`\`\`\`${block.language || ''}`, codeBlockText(block), '```', '');
|
||||
} else if (block.kind === 'table') {
|
||||
if (!block.rows || !block.rows.length) continue;
|
||||
@@ -71,9 +74,6 @@ function exportMarkdown(ast, outDir, template = {}) {
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
emitBlocks(lines, step, 'after-description');
|
||||
emitBlocks(lines, step, 'after-image');
|
||||
}
|
||||
|
||||
const file = path.join(outDir, `${guideSlug(ast)}.md`);
|
||||
@@ -81,14 +81,16 @@ function exportMarkdown(ast, outDir, template = {}) {
|
||||
return { file, imageCount: images.size };
|
||||
}
|
||||
|
||||
function emitBlocks(lines, step, position) {
|
||||
for (const tb of stepBlocks(step).filter((b) => b.kind === 'text' && b.position === position)) {
|
||||
const label = LEVEL_LABEL[tb.level] || 'Note';
|
||||
lines.push(`> **${label}${tb.title ? `: ${tb.title}` : ''}**`);
|
||||
const body = htmlToMarkdown(tb.descriptionHtml);
|
||||
if (body) lines.push(`> ${body.replace(/\n/g, '\n> ')}`);
|
||||
lines.push('');
|
||||
}
|
||||
function emitBlock(lines, tb) {
|
||||
const label = LEVEL_LABEL[tb.level] || 'Note';
|
||||
// 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);
|
||||
if (body) lines.push(`> ${body.replace(/\n/g, '\n> ')}`);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
module.exports = { exportMarkdown, DEFAULT_TEMPLATE, anchorFor };
|
||||
|
||||
+156
-21
@@ -3,8 +3,100 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
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 { 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
|
||||
@@ -51,6 +143,31 @@ function exportPdf(ast, outDir, template = {}) {
|
||||
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();
|
||||
|
||||
@@ -60,7 +177,7 @@ function exportPdf(ast, outDir, template = {}) {
|
||||
y += 6;
|
||||
writeLines(ast.guide.title, { size: 26, font: 'F2' });
|
||||
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;
|
||||
writeLines(`${ast.steps.length} steps — generated ${ast.generatedAt.slice(0, 10)}`, { size: 10, color: [120, 120, 120] });
|
||||
pdf.addPage();
|
||||
@@ -90,8 +207,9 @@ function exportPdf(ast, outDir, template = {}) {
|
||||
pdf.rect(M, y, usableW, 0.8, { fill: [225, 228, 232] });
|
||||
y += 8;
|
||||
|
||||
emitBlocks(step, 'before-description');
|
||||
if (step.descriptionText) { writeLines(step.descriptionText); y += 4; }
|
||||
const { before, rest } = stepContentGroups(step);
|
||||
for (const tb of before) emitBlock(tb);
|
||||
if (step.descriptionHtml) writeDescription(step.descriptionHtml);
|
||||
|
||||
const img = images.get(step.stepId);
|
||||
if (img) {
|
||||
@@ -104,8 +222,10 @@ function exportPdf(ast, outDir, template = {}) {
|
||||
y += h + 10;
|
||||
}
|
||||
|
||||
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) {
|
||||
if (block.kind === 'code') {
|
||||
for (const block of rest) {
|
||||
if (block.kind === 'text') {
|
||||
emitBlock(block);
|
||||
} else if (block.kind === 'code') {
|
||||
const lines = String(codeBlockText(block) || '').split('\n');
|
||||
const lineH = 9 * 1.3;
|
||||
ensure(Math.min(lines.length, 4) * lineH + 12);
|
||||
@@ -138,26 +258,41 @@ function exportPdf(ast, outDir, template = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
emitBlocks(step, 'after-description');
|
||||
emitBlocks(step, 'after-image');
|
||||
y += 10;
|
||||
}
|
||||
|
||||
function emitBlocks(step, position) {
|
||||
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 bodyLines = tb.descriptionText ? pdf.wrapText(tb.descriptionText, 9.5, usableW - 18) : [];
|
||||
const blockH = 16 + bodyLines.length * 13;
|
||||
ensure(blockH + 4);
|
||||
pdf.rect(M, y, 3, blockH, { fill: tpl.accentColor });
|
||||
pdf.text(label, M + 10, y + 2, { size: 9.5, font: 'F2' });
|
||||
let by = y + 16;
|
||||
for (const line of bodyLines) {
|
||||
pdf.text(line, M + 10, by, { size: 9.5 });
|
||||
by += 13;
|
||||
function emitBlock(tb) {
|
||||
const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`;
|
||||
const { items, height: bodyH } = tb.descriptionHtml
|
||||
? 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);
|
||||
pdf.rect(M, y, usableW, blockH, { fill: style.tint });
|
||||
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;
|
||||
for (const item of items) {
|
||||
if (item.kind === 'hr') {
|
||||
pdf.rect(M + 10, by + 5, item.width, 0.8, { fill: [225, 228, 232] });
|
||||
by += 12;
|
||||
continue;
|
||||
}
|
||||
y += blockH + 6;
|
||||
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;
|
||||
}
|
||||
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
@@ -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 + 2], '```');
|
||||
assert.ok(lines.some((l) => /^\| Day \| Window \|$/.test(l)), 'table header row');
|
||||
// Warning text block became a blockquote with its content.
|
||||
const warnIdx = lines.findIndex((l) => l.startsWith('> **Warning: Access**'));
|
||||
// Warning text block became a GFM alert blockquote with its content.
|
||||
const warnIdx = lines.findIndex((l) => l.startsWith('> [!WARNING]'));
|
||||
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) => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
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) => {
|
||||
const root = makeTmpDir('dup');
|
||||
t.after(() => rmrf(root));
|
||||
|
||||
Reference in New Issue
Block a user