Mass guide editor changes #18
@@ -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:
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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
@@ -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}` }, `Rec - ${trigger}`),
|
el('span', { title: `Capture session — ${trigger}` }, `Rec - ${trigger}`),
|
||||||
|
|||||||
+70
-17
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
* 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
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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');
|
||||||
|
|
||||||
|
|||||||
+23
-14
@@ -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,26 +112,25 @@ 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"?>
|
||||||
|
|||||||
+16
-14
@@ -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; }
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
+17
-15
@@ -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';
|
// GitHub-Flavored Markdown alert syntax — renders with a colored,
|
||||||
lines.push(`> **${label}${tb.title ? `: ${tb.title}` : ''}**`);
|
// icon-labeled box on GitHub/Azure DevOps wikis and several other
|
||||||
const body = htmlToMarkdown(tb.descriptionHtml);
|
// viewers; degrades to a plain blockquote elsewhere.
|
||||||
if (body) lines.push(`> ${body.replace(/\n/g, '\n> ')}`);
|
lines.push(`> [!${label.toUpperCase()}]`);
|
||||||
lines.push('');
|
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 };
|
module.exports = { exportMarkdown, DEFAULT_TEMPLATE, anchorFor };
|
||||||
|
|||||||
+156
-21
@@ -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,26 +258,41 @@ 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 { items, height: bodyH } = tb.descriptionHtml
|
||||||
const bodyLines = tb.descriptionText ? pdf.wrapText(tb.descriptionText, 9.5, usableW - 18) : [];
|
? layoutDescription(pdf, tb.descriptionHtml, usableW - 18, 9.5)
|
||||||
const blockH = 16 + bodyLines.length * 13;
|
: { items: [], height: 0 };
|
||||||
ensure(blockH + 4);
|
const blockH = 16 + bodyH;
|
||||||
pdf.rect(M, y, 3, blockH, { fill: tpl.accentColor });
|
const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info;
|
||||||
pdf.text(label, M + 10, y + 2, { size: 9.5, font: 'F2' });
|
ensure(blockH + 4);
|
||||||
let by = y + 16;
|
pdf.rect(M, y, usableW, blockH, { fill: style.tint });
|
||||||
for (const line of bodyLines) {
|
pdf.rect(M, y, 3, blockH, { fill: style.accent });
|
||||||
pdf.text(line, M + 10, by, { size: 9.5 });
|
pdf.text(label, M + 10, y + 2, { size: 9.5, font: 'F2', color: style.accent });
|
||||||
by += 13;
|
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 });
|
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 + 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) => {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
Reference in New Issue
Block a user