From f23b49c0d199797cbf23e760bd09bd3d8f263f7c Mon Sep 17 00:00:00 2001 From: Twest2 Date: Sat, 13 Jun 2026 18:09:17 -0500 Subject: [PATCH 01/25] Make the focused-view crop live in the editor canvas The annotation canvas now renders as a viewport into the focused-view crop region, matching what core/raster.js produces on export. Sliders update the canvas live; annotation data stays in full-image-normalized coordinates and remains correctly positioned, sized, and editable. Co-Authored-By: Claude Sonnet 4.6 --- app/renderer/canvas.js | 85 +++++++++++++++++++++++++++++++++--------- app/renderer/editor.js | 3 ++ 2 files changed, 71 insertions(+), 17 deletions(-) diff --git a/app/renderer/canvas.js b/app/renderer/canvas.js index c0383fc..9fe3df0 100644 --- a/app/renderer/canvas.js +++ b/app/renderer/canvas.js @@ -28,6 +28,7 @@ class AnnotationCanvas { this.selectedId = null; this.drag = null; this.cropRect = null; + this.focusedView = null; canvasEl.addEventListener('pointerdown', (e) => this.onDown(e)); canvasEl.addEventListener('pointermove', (e) => this.onMove(e)); @@ -55,6 +56,14 @@ class AnnotationCanvas { this.render(); } + // The focused view crops and zooms the canvas itself to match what + // exports produce. Annotation data stays in full-image-normalized + // coordinates; only the on-screen viewport changes. + setFocusedView(focusedView) { + this.focusedView = focusedView || null; + this.render(); + } + setTool(tool) { this.tool = tool; this.cropRect = null; @@ -98,30 +107,62 @@ class AnnotationCanvas { } // ---- coordinate helpers ---- + // The focused-view crop region, in coordinates normalized to the full + // image (0..1). Identity rect when focused view is off, matching + // core/raster.js applyFocusedView. + 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 cx = Math.min(Math.max(fv.panX ?? 0.5, w / 2), 1 - w / 2); + const cy = Math.min(Math.max(fv.panY ?? 0.5, h / 2), 1 - h / 2); + return { x: cx - w / 2, y: cy - h / 2, w, h }; + } + toNorm(e) { const rect = this.canvas.getBoundingClientRect(); + const r = this.viewRect(); return { - x: (e.clientX - rect.left) / rect.width, - y: (e.clientY - rect.top) / rect.height, + x: r.x + ((e.clientX - rect.left) / rect.width) * r.w, + y: r.y + ((e.clientY - rect.top) / rect.height) * r.h, }; } px(ann) { + const r = this.viewRect(); return { - x: ann.x * this.canvas.width, - y: ann.y * this.canvas.height, - w: ann.w * this.canvas.width, - h: ann.h * this.canvas.height, + x: (ann.x - r.x) / r.w * this.canvas.width, + y: (ann.y - r.y) / r.h * this.canvas.height, + w: ann.w / r.w * this.canvas.width, + h: ann.h / r.h * this.canvas.height, }; } + // Converts a canvas-pixel point to image-pixel coordinates in the + // full (uncropped) source image, for sampling `this.image` directly. + toImagePx(x, y) { + const r = this.viewRect(); + return { + x: (x / this.canvas.width * r.w + r.x) * this.imgW, + y: (y / this.canvas.height * r.h + r.y) * this.imgH, + }; + } + + toImageLen(len, axis) { + const r = this.viewRect(); + return axis === 'y' ? len / this.canvas.height * r.h * this.imgH : len / this.canvas.width * r.w * this.imgW; + } + // ---- rendering ---- render() { const { ctx, canvas } = this; ctx.clearRect(0, 0, canvas.width, canvas.height); if (!this.image) return; ctx.imageSmoothingEnabled = true; - ctx.drawImage(this.image, 0, 0, canvas.width, canvas.height); + const r = this.viewRect(); + ctx.drawImage(this.image, + r.x * this.imgW, r.y * this.imgH, r.w * this.imgW, r.h * this.imgH, + 0, 0, canvas.width, canvas.height); const ordered = [...this.annotations].sort((a, b) => (DRAW_ORDER[a.type] ?? 3) - (DRAW_ORDER[b.type] ?? 3)); for (const ann of ordered) this.drawAnnotation(ann); @@ -131,12 +172,17 @@ class AnnotationCanvas { if (this.cropRect) this.drawCropOverlay(); } + // Annotation strokes/fonts are sized relative to the full image, then + // magnified by the focused-view crop on export (core/raster.js) — divide + // by the view rect so the canvas preview matches that magnification. strokePx(ann) { - return Math.max(1, ((ann.style && ann.style.strokeWidth) || 3) * this.canvas.width / 1000); + const r = this.viewRect(); + return Math.max(1, ((ann.style && ann.style.strokeWidth) || 3) * this.canvas.width / 1000 / r.w); } fontPx(ann) { - return Math.max(9, ((ann.style && ann.style.fontSize) || 0.022) * this.canvas.height); + const r = this.viewRect(); + return Math.max(9, ((ann.style && ann.style.fontSize) || 0.022) * this.canvas.height / r.h); } drawAnnotation(ann) { @@ -202,10 +248,13 @@ class AnnotationCanvas { ctx.ellipse(x + w / 2, y + h / 2, Math.abs(w / 2), Math.abs(h / 2), 0, 0, Math.PI * 2); ctx.clip(); const sw = w / zoom, sh = h / zoom; + const center = this.toImagePx(x + w / 2, y + h / 2); + const srcW = this.toImageLen(sw, 'x'); + const srcH = this.toImageLen(sh, 'y'); ctx.drawImage( this.image, - (x + w / 2 - sw / 2) / this.scale, (y + h / 2 - sh / 2) / this.scale, - sw / this.scale, sh / this.scale, + center.x - srcW / 2, center.y - srcH / 2, + srcW, srcH, x, y, w, h ); ctx.restore(); @@ -310,10 +359,11 @@ class AnnotationCanvas { drawCropOverlay() { const { ctx, canvas } = this; const r = this.cropRect; - const x = Math.min(r.x0, r.x1) * canvas.width; - const y = Math.min(r.y0, r.y1) * canvas.height; - const w = Math.abs(r.x1 - r.x0) * canvas.width; - const h = Math.abs(r.y1 - r.y0) * canvas.height; + const view = this.viewRect(); + const x = (Math.min(r.x0, r.x1) - view.x) / view.w * canvas.width; + const y = (Math.min(r.y0, r.y1) - view.y) / view.h * canvas.height; + const w = Math.abs(r.x1 - r.x0) / view.w * canvas.width; + const h = Math.abs(r.y1 - r.y0) / view.h * canvas.height; ctx.save(); ctx.fillStyle = 'rgba(0,0,0,0.5)'; ctx.beginPath(); @@ -486,8 +536,9 @@ class AnnotationCanvas { nudgeSelected(dx, dy) { const sel = this.selected(); if (!sel) return false; - sel.x += dx / this.canvas.width; - sel.y += dy / this.canvas.height; + const r = this.viewRect(); + sel.x += dx / this.canvas.width * r.w; + sel.y += dy / this.canvas.height * r.h; this.changed(); return true; } diff --git a/app/renderer/editor.js b/app/renderer/editor.js index f8369bf..566c43a 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -397,6 +397,7 @@ class GuideEditor { step.focusedView[field] = Number(node.value); this.pendingSave = true; this.saveStepDebounced(); + this.canvas.setFocusedView(step.focusedView); }); bindFocusedSlider(this.dom.fvZoom, 'zoom'); bindFocusedSlider(this.dom.fvPanX, 'panX'); @@ -451,6 +452,7 @@ class GuideEditor { this.dom.fvPanX.value = fv.panX ?? 0.5; this.dom.fvPanY.value = fv.panY ?? 0.5; } + this.canvas.setFocusedView(fv); } // ---- text / code / table blocks ---------------------------------------- @@ -852,6 +854,7 @@ class GuideEditor { if (token !== this.imageLoadToken) return; this.canvas.setImage(img, img.naturalWidth || img.width, img.naturalHeight || img.height); this.canvas.setAnnotations(step.annotations || []); + this.canvas.setFocusedView(step.focusedView); this.canvas.setTool(this.currentTool); this.canvas.setZoom(this.currentZoom); }; From 602e70a7e156a9d9a7a67f00bbb48dea37f7f5be Mon Sep 17 00:00:00 2001 From: Twest2 Date: Sat, 13 Jun 2026 18:21:00 -0500 Subject: [PATCH 02/25] Linearize focused-view pan to the available range and flip Pan Y panX/panY now represent 0..1 fractions of the pannable range rather than absolute image positions, so the slider's full travel always covers edge-to-edge regardless of zoom level. Pan Y is inverted so sliding right moves the view up. Updated both the editor canvas and core/raster.js export so they stay in sync. Co-Authored-By: Claude Sonnet 4.6 --- app/renderer/canvas.js | 10 ++++++---- core/raster.js | 14 ++++++++++---- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/app/renderer/canvas.js b/app/renderer/canvas.js index 9fe3df0..37c011f 100644 --- a/app/renderer/canvas.js +++ b/app/renderer/canvas.js @@ -109,14 +109,16 @@ class AnnotationCanvas { // ---- coordinate helpers ---- // The focused-view crop region, in coordinates normalized to the full // image (0..1). Identity rect when focused view is off, matching - // core/raster.js applyFocusedView. + // 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 cx = Math.min(Math.max(fv.panX ?? 0.5, w / 2), 1 - w / 2); - const cy = Math.min(Math.max(fv.panY ?? 0.5, h / 2), 1 - h / 2); - return { x: cx - w / 2, y: cy - h / 2, w, h }; + 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) { diff --git a/core/raster.js b/core/raster.js index 1317e20..6f085b6 100644 --- a/core/raster.js +++ b/core/raster.js @@ -392,13 +392,19 @@ function renderAnnotations(baseImg, annotations = []) { return img; } -/** Apply focused view (zoom/pan crop, then scale back to original size). */ +/** + * Apply focused view (zoom/pan crop, then scale back to original size). + * panX/panY are 0..1 fractions of the available pan range, not absolute + * image positions: 0 puts the crop at the left/bottom edge, 1 at the + * right/top edge, 0.5 centers it. Y is inverted so panY increases upward. + */ function applyFocusedView(img, fv) { if (!fv || !fv.enabled || !(fv.zoom > 1)) return img; const vw = img.width / fv.zoom, vh = img.height / fv.zoom; - const cx = Math.min(Math.max(fv.panX * img.width, vw / 2), img.width - vw / 2); - const cy = Math.min(Math.max(fv.panY * img.height, vh / 2), img.height - vh / 2); - const region = crop(img, cx - vw / 2, cy - vh / 2, vw, vh); + const panX = fv.panX ?? 0.5, panY = fv.panY ?? 0.5; + const cropX = panX * (img.width - vw); + const cropY = (1 - panY) * (img.height - vh); + const region = crop(img, cropX, cropY, vw, vh); return resize(region, img.width, img.height); } From b39fe553b22cc542997270cb1d8c1ae242378e73 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Sat, 13 Jun 2026 18:24:33 -0500 Subject: [PATCH 03/25] Allow Backspace to delete the selected annotation Co-Authored-By: Claude Sonnet 4.6 --- app/renderer/editor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/renderer/editor.js b/app/renderer/editor.js index 566c43a..e027498 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -1835,7 +1835,7 @@ class GuideEditor { } return; } - if (e.key === 'Delete' && this.selectedAnnotationId) { + if ((e.key === 'Delete' || e.key === 'Backspace') && this.selectedAnnotationId) { e.preventDefault(); if (this.canvas.deleteSelected()) this.saveStepDebounced(); return; From 014b92675d3614192b956f40080386fb3d348ec7 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Sat, 13 Jun 2026 18:37:24 -0500 Subject: [PATCH 04/25] Show only relevant settings per annotation type, drop redundant delete button The annotation editor always rendered every style field regardless of type and a "Delete annotation" button duplicating Delete/Backspace. --- app/renderer/editor.js | 53 ++++++++++++++++++++++++++---------------- 1 file changed, 33 insertions(+), 20 deletions(-) diff --git a/app/renderer/editor.js b/app/renderer/editor.js index e027498..7a6ac19 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -8,6 +8,22 @@ const dialogs = window.StepForgeDialogs || {}; const clone = (value) => JSON.parse(JSON.stringify(value)); const BLOCK_KIND_ORDER = { text: 0, code: 1, table: 2 }; +// Which style fields are meaningful for each annotation type, so the +// annotation editor only shows controls that actually affect that type. +const ANNOTATION_FIELDS = { + rect: ['stroke', 'fill', 'strokeWidth'], + oval: ['stroke', 'fill', 'strokeWidth'], + line: ['stroke', 'strokeWidth'], + arrow: ['stroke', 'strokeWidth'], + text: ['text', 'stroke', 'fontSize'], + tooltip: ['text', 'fill', 'stroke', 'strokeWidth', 'fontSize', 'textColor', 'tail'], + number: ['value', 'stroke', 'textColor'], + blur: ['radius'], + highlight: [], + magnify: ['zoom', 'stroke', 'strokeWidth'], + cursor: [], +}; + function blockText(block) { for (const key of ['code', 'text', 'body', 'value', 'content']) { const value = block && block[key]; @@ -935,31 +951,28 @@ class GuideEditor { this.emitMeta(); }; - const annSection = el('div', { className: 'annotation-editor-inner' }, - labeledRow('Type', typeSelect), - labeledRow('Text', textInput), - labeledRow('Value', valueInput), - labeledRow('Stroke', strokeInput), - labeledRow('Fill', fillInput), - labeledRow('Stroke width', strokeWidthInput), - labeledRow('Font size', fontSizeInput), - labeledRow('Text color', textColorInput), - labeledRow('Zoom', zoomInput), - labeledRow('Radius', radiusInput), - labeledRow('Tail', tailInput), - el('div.row', {}, - el('button', { - type: 'button', - onClick: () => { - this.canvas.deleteSelected(); - }, - }, 'Delete annotation'), - ), + const fields = new Set(ANNOTATION_FIELDS[selected.type] || []); + const strokeLabel = (selected.type === 'text' || selected.type === 'number') ? 'Color' : 'Stroke'; + + const rows = [labeledRow('Type', typeSelect)]; + if (fields.has('text')) rows.push(labeledRow('Text', textInput)); + if (fields.has('value')) rows.push(labeledRow('Value', valueInput)); + if (fields.has('stroke')) rows.push(labeledRow(strokeLabel, strokeInput)); + if (fields.has('fill')) rows.push(labeledRow('Fill', fillInput)); + if (fields.has('strokeWidth')) rows.push(labeledRow('Stroke width', strokeWidthInput)); + if (fields.has('fontSize')) rows.push(labeledRow('Font size', fontSizeInput)); + if (fields.has('textColor')) rows.push(labeledRow('Text color', textColorInput)); + if (fields.has('zoom')) rows.push(labeledRow('Zoom', zoomInput)); + if (fields.has('radius')) rows.push(labeledRow('Radius', radiusInput)); + if (fields.has('tail')) rows.push(labeledRow('Tail', tailInput)); + rows.push( el('div.row', {}, el('button', { type: 'button', title: 'Copy this style to every annotation of the same type in this step', onClick: () => this.applyStyleAcross('step') }, 'Style → step'), el('button', { type: 'button', title: 'Copy this style to every annotation of the same type in the whole guide', onClick: () => this.applyStyleAcross('guide') }, 'Style → guide'), ), ); + + const annSection = el('div', { className: 'annotation-editor-inner' }, ...rows); this.dom.annotationEditor.append(annSection); typeSelect.addEventListener('change', () => { From 9e88991f462f9bf3ef31f1296c2d4b8d96c35bd3 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Sat, 13 Jun 2026 19:14:30 -0500 Subject: [PATCH 05/25] Clarify the style-copy buttons' scope with a heading and richer tooltips MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renamed "Style → step/guide" to "This step"/"Entire guide" under a heading naming the annotation type, with hover text spelling out that it overwrites every matching annotation's style in that scope. --- app/renderer/editor.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/renderer/editor.js b/app/renderer/editor.js index 7a6ac19..0db416f 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -966,9 +966,18 @@ class GuideEditor { 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 "${selected.type}" annotation:`), el('div.row', {}, - el('button', { type: 'button', title: 'Copy this style to every annotation of the same type in this step', onClick: () => this.applyStyleAcross('step') }, 'Style → step'), - el('button', { type: 'button', title: 'Copy this style to every annotation of the same type in the whole guide', onClick: () => this.applyStyleAcross('guide') }, 'Style → guide'), + el('button', { + type: 'button', + title: `Overwrite the style of every "${selected.type}" annotation on this step with the style shown above.`, + onClick: () => this.applyStyleAcross('step'), + }, 'This step'), + el('button', { + type: 'button', + title: `Overwrite the style of every "${selected.type}" annotation across all steps in this guide with the style shown above.`, + onClick: () => this.applyStyleAcross('guide'), + }, 'Entire guide'), ), ); From 31bdbea7b588c68d7776e1a14945ceff9a3fc7fe Mon Sep 17 00:00:00 2001 From: Twest2 Date: Sat, 13 Jun 2026 19:38:05 -0500 Subject: [PATCH 06/25] Replace the disabled "Local guide" label with a "Share as file" action The guide panel's link button was a dead disabled control for local guides. It now shares the guide as a .sfgz archive instead, while linked guides keep the existing archive-management dialog. --- app/renderer/editor.js | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/app/renderer/editor.js b/app/renderer/editor.js index 0db416f..fc253e1 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -300,7 +300,7 @@ class GuideEditor { el('section', {}, el('h3', {}, 'Guide'), this.dom.guideSummary = el('div.muted', {}), - this.dom.linkedBtn = el('button', { type: 'button' }, 'Linked guide'), + this.dom.shareBtn = el('button', { type: 'button' }, 'Share as file'), this.dom.saveNowBtn = el('button.primary', { type: 'button' }, 'Save now'), this.dom.snapshotBtn = el('button', { type: 'button' }, 'Snapshot'), ), @@ -346,7 +346,10 @@ class GuideEditor { this.dom.deleteBtn.addEventListener('click', () => this.deleteSelectedStep()); this.dom.saveNowBtn.addEventListener('click', () => this.saveAll()); this.dom.snapshotBtn.addEventListener('click', () => this.createSnapshot()); - this.dom.linkedBtn.addEventListener('click', () => this.openLinkedGuide()); + this.dom.shareBtn.addEventListener('click', () => { + if (this.guide && this.guide.linkedSource) this.openLinkedGuide(); + else this.shareAsFile(); + }); this.dom.zoomFitBtn.addEventListener('click', () => this.setZoom('fit')); this.dom.zoom100Btn.addEventListener('click', () => this.setZoom(1)); this.dom.zoom125Btn.addEventListener('click', () => this.setZoom(1.25)); @@ -1046,8 +1049,11 @@ 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; + const linked = Boolean(this.guide.linkedSource); + this.dom.shareBtn.textContent = linked ? 'Linked guide' : 'Share as file'; + this.dom.shareBtn.title = linked + ? 'Manage this guide\'s connection to its shared archive file' + : 'Export this guide as a shareable .sfgz archive file'; this.dom.snapshotBtn.textContent = 'Snapshot'; } From 1776089c082cc194959731154143ee2e2fec02be Mon Sep 17 00:00:00 2001 From: Twest2 Date: Sat, 13 Jun 2026 19:50:22 -0500 Subject: [PATCH 07/25] Remove the guide-link panel button; Save now syncs linked archives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For linked guides, "Save now" now also writes back to the linked .sfgz archive (reporting a lock conflict if another session holds it), making the dedicated link/share button in the Guide panel redundant. The toolbar Share button and More > Linked guide… menu still cover one-off exports and archive management. --- app/renderer/editor.js | 28 +++++++++++----------------- 1 file changed, 11 insertions(+), 17 deletions(-) diff --git a/app/renderer/editor.js b/app/renderer/editor.js index fc253e1..bcea341 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -300,8 +300,7 @@ class GuideEditor { el('section', {}, el('h3', {}, 'Guide'), this.dom.guideSummary = el('div.muted', {}), - this.dom.shareBtn = el('button', { type: 'button' }, 'Share as file'), - this.dom.saveNowBtn = el('button.primary', { type: 'button' }, 'Save now'), + this.dom.saveNowBtn = el('button.primary', { type: 'button', title: 'Save changes. For guides linked to a shared archive, also writes the archive file.' }, 'Save now'), this.dom.snapshotBtn = el('button', { type: 'button' }, 'Snapshot'), ), ), @@ -346,10 +345,6 @@ class GuideEditor { this.dom.deleteBtn.addEventListener('click', () => this.deleteSelectedStep()); this.dom.saveNowBtn.addEventListener('click', () => this.saveAll()); this.dom.snapshotBtn.addEventListener('click', () => this.createSnapshot()); - this.dom.shareBtn.addEventListener('click', () => { - if (this.guide && this.guide.linkedSource) this.openLinkedGuide(); - else this.shareAsFile(); - }); this.dom.zoomFitBtn.addEventListener('click', () => this.setZoom('fit')); this.dom.zoom100Btn.addEventListener('click', () => this.setZoom(1)); this.dom.zoom125Btn.addEventListener('click', () => this.setZoom(1.25)); @@ -458,7 +453,6 @@ class GuideEditor { this.renderCanvas(); this.renderAnnotationPanel(); this.renderBlocksPanel(); - this.renderGuidePanel(); this.emitMeta(); } @@ -1047,16 +1041,6 @@ class GuideEditor { }); } - renderGuidePanel() { - if (!this.guide) return; - const linked = Boolean(this.guide.linkedSource); - this.dom.shareBtn.textContent = linked ? 'Linked guide' : 'Share as file'; - this.dom.shareBtn.title = linked - ? 'Manage this guide\'s connection to its shared archive file' - : 'Export this guide as a shareable .sfgz archive file'; - this.dom.snapshotBtn.textContent = 'Snapshot'; - } - defaultStyleForTool(tool) { switch (tool) { case 'highlight': return { fill: '#ffe066', stroke: '#ffbf00', strokeWidth: 1 }; @@ -1220,6 +1204,16 @@ class GuideEditor { async saveAll() { if (this.currentStep) await this.flushStep(); if (this.guide) await this.flushGuide(); + if (this.guide && this.guide.linkedSource) { + const result = await api.archive.saveLinked({ guideId: this.guideId, force: false }); + if (result.saved) { + this.guide.linkedSource.lastSavedAt = new Date().toISOString(); + this.onToast('Saved and synced to linked archive.'); + } else { + this.onToast('Saved locally, but the linked archive is locked by another session.', { error: true }); + } + return; + } this.onToast('Saved.'); } From be88434e44a858cb15b28efd345e239c3253ec69 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Sat, 13 Jun 2026 20:07:55 -0500 Subject: [PATCH 08/25] Prompt for a name and save before creating a manual snapshot Clicking Snapshot now opens a named-prompt dialog (matching the app's modal styling) and flushes the current step/guide first, so the snapshot reflects unsaved edits and is easy to identify later. --- app/renderer/editor.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/app/renderer/editor.js b/app/renderer/editor.js index bcea341..4d6c289 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -1219,7 +1219,15 @@ class GuideEditor { async createSnapshot() { if (!this.guideId) return; - await api.snapshots.create({ guideId: this.guideId, label: 'manual' }); + const label = await dialogs.promptText({ + title: 'Create snapshot', + label: 'Snapshot name', + placeholder: 'manual', + }); + if (label == null) return; + if (this.currentStep) await this.flushStep(); + if (this.guide) await this.flushGuide(); + await api.snapshots.create({ guideId: this.guideId, label: label.trim() || 'manual' }); this.onToast('Snapshot created.'); } From a58607f0299b7b69a5e09289bb3224be90681933 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Sat, 13 Jun 2026 20:23:31 -0500 Subject: [PATCH 09/25] =?UTF-8?q?Add=20a=20"Make=20substep=20of=E2=80=A6"?= =?UTF-8?q?=20option=20to=20the=20step=20context=20menu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lets a step be re-parented under another step by entering that step's displayed number, moving it (with its own substeps) to the end of the target's substeps and updating the numbering accordingly. --- app/renderer/editor.js | 61 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/app/renderer/editor.js b/app/renderer/editor.js index 4d6c289..8453e9b 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -731,6 +731,7 @@ class GuideEditor { this.selectStep(step.stepId); contextMenu(e.clientX, e.clientY, [ { label: 'Add substep', action: () => this.addSubstep(step.stepId) }, + { label: 'Make substep of…', action: () => this.makeSubstepOf(step.stepId) }, { label: 'Duplicate step', action: () => this.duplicateSelectedStep() }, 'sep', { label: 'Move up', action: () => this.moveSelectedStep(-1) }, @@ -1289,6 +1290,66 @@ class GuideEditor { this.onToast(parent ? 'Substep added.' : 'Step added.'); } + /** All step ids whose ancestor chain leads back to `stepId`. */ + getStepDescendantIds(stepId) { + const result = []; + const queue = [stepId]; + while (queue.length) { + const current = queue.shift(); + for (const s of this.steps) { + if (s.parentStepId === current) { + result.push(s.stepId); + queue.push(s.stepId); + } + } + } + return result; + } + + async makeSubstepOf(stepId) { + const step = this.stepMap.get(stepId); + if (!step) return; + const numbers = stepNumberMap(this.steps); + const input = await dialogs.promptText({ + title: 'Make substep', + label: 'Make substep of step #', + placeholder: 'e.g. 2', + }); + if (input == null) return; + const target = input.trim().replace(/^#/, ''); + const targetId = this.steps.find((s) => numbers.get(s.stepId) === target)?.stepId; + if (!targetId) { + this.onToast(`No step numbered "${target}".`, { error: true }); + return; + } + const subtreeIds = new Set([stepId, ...this.getStepDescendantIds(stepId)]); + if (subtreeIds.has(targetId)) { + this.onToast('A step cannot be made a substep of itself or one of its own substeps.', { error: true }); + return; + } + if (step.parentStepId === targetId) { + this.onToast(`“${step.title || 'Untitled step'}” is already a substep of step ${target}.`); + return; + } + + step.parentStepId = targetId; + 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([targetId, ...this.getStepDescendantIds(targetId)]); + let insertAt = remaining.indexOf(targetId) + 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 ${target}.`); + } + async duplicateSelectedStep() { const step = this.currentStep; if (!step) return; From 8e2e1f41fb909c84fe8934f980ebd69f44e161f8 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Sat, 13 Jun 2026 20:33:29 -0500 Subject: [PATCH 10/25] =?UTF-8?q?Show=20"Make=20substep=20of=E2=80=A6"=20a?= =?UTF-8?q?s=20a=20hover=20submenu=20listing=20all=20steps?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the number-entry prompt with a scrollable side panel (so large guides don't overflow the screen) listing every eligible step; clicking one immediately re-parents the step. Context menus now support submenu items in general via contextMenu(). --- app/renderer/editor.js | 44 +++++++++++++++++++------------------- app/renderer/style.css | 19 +++++++++++++++++ app/renderer/util.js | 48 ++++++++++++++++++++++++++++++++++++++---- 3 files changed, 85 insertions(+), 26 deletions(-) diff --git a/app/renderer/editor.js b/app/renderer/editor.js index 8453e9b..b688d7b 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -731,7 +731,18 @@ class GuideEditor { this.selectStep(step.stepId); contextMenu(e.clientX, e.clientY, [ { label: 'Add substep', action: () => this.addSubstep(step.stepId) }, - { label: 'Make substep of…', action: () => this.makeSubstepOf(step.stepId) }, + { + label: 'Make substep of…', + submenu: () => { + const subtreeIds = new Set([step.stepId, ...this.getStepDescendantIds(step.stepId)]); + return this.steps + .filter((s) => !subtreeIds.has(s.stepId)) + .map((s) => ({ + label: `${numbers.get(s.stepId)} ${s.title || 'Untitled step'}`, + action: () => this.makeSubstepOf(step.stepId, s.stepId), + })); + }, + }, { label: 'Duplicate step', action: () => this.duplicateSelectedStep() }, 'sep', { label: 'Move up', action: () => this.moveSelectedStep(-1) }, @@ -1306,48 +1317,37 @@ class GuideEditor { return result; } - async makeSubstepOf(stepId) { + async makeSubstepOf(stepId, targetStepId) { const step = this.stepMap.get(stepId); - if (!step) return; + const target = this.stepMap.get(targetStepId); + if (!step || !target) return; const numbers = stepNumberMap(this.steps); - const input = await dialogs.promptText({ - title: 'Make substep', - label: 'Make substep of step #', - placeholder: 'e.g. 2', - }); - if (input == null) return; - const target = input.trim().replace(/^#/, ''); - const targetId = this.steps.find((s) => numbers.get(s.stepId) === target)?.stepId; - if (!targetId) { - this.onToast(`No step numbered "${target}".`, { error: true }); - return; - } const subtreeIds = new Set([stepId, ...this.getStepDescendantIds(stepId)]); - if (subtreeIds.has(targetId)) { + 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 === targetId) { - this.onToast(`“${step.title || 'Untitled step'}” is already a substep of step ${target}.`); + if (step.parentStepId === targetStepId) { + this.onToast(`“${step.title || 'Untitled step'}” is already a substep of step ${numbers.get(targetStepId)}.`); return; } - step.parentStepId = targetId; + 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([targetId, ...this.getStepDescendantIds(targetId)]); - let insertAt = remaining.indexOf(targetId) + 1; + 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 ${target}.`); + this.onToast(`“${step.title || 'Untitled step'}” is now a substep of step ${numbers.get(targetStepId)}.`); } async duplicateSelectedStep() { diff --git a/app/renderer/style.css b/app/renderer/style.css index 7d8febd..9f1c37e 100644 --- a/app/renderer/style.css +++ b/app/renderer/style.css @@ -731,6 +731,25 @@ fieldset legend { } .ctx-menu .mi:hover { background: var(--panel-2); } .ctx-menu .mi.danger { color: var(--danger); } +.ctx-menu .mi.disabled { + color: var(--muted); + cursor: default; +} +.ctx-menu .mi.disabled:hover { background: none; } +.ctx-menu .mi.has-submenu { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; +} +.ctx-menu .mi .submenu-arrow { + color: var(--muted); + font-size: 12px; +} +.ctx-submenu { + max-height: min(360px, calc(100vh - 24px)); + overflow-y: auto; +} .ctx-menu hr { margin: 6px 0; border: 0; diff --git a/app/renderer/util.js b/app/renderer/util.js index 3d553ef..c521fe7 100644 --- a/app/renderer/util.js +++ b/app/renderer/util.js @@ -119,15 +119,55 @@ function promptDialog(title, { value = '', label = 'Name' } = {}) { }); } -/** Context menu at (x, y); items: [{label, danger, action}] or 'sep'. */ +/** + * Context menu at (x, y). + * items: [{label, danger, action}] | [{label, submenu}] | 'sep'. + * `submenu` is an array (or a function returning one) of the same item + * shapes, shown in a scrollable panel beside the item on hover. + */ function contextMenu(x, y, items) { document.querySelectorAll('.ctx-menu').forEach((n) => n.remove()); const menu = el('div.ctx-menu', { style: { left: `${x}px`, top: `${y}px` } }); + let openSubmenu = null; + const closeSubmenu = () => { + if (openSubmenu) { openSubmenu.remove(); openSubmenu = null; } + }; + const closeAll = () => { closeSubmenu(); menu.remove(); }; for (const item of items) { - if (item === 'sep') { menu.append(el('hr')); continue; } + if (item === 'sep') { menu.append(el('hr', { onMouseEnter: closeSubmenu })); continue; } + if (item.submenu) { + const mi = el('div.mi.has-submenu', { + onMouseEnter: () => { + closeSubmenu(); + const subItems = typeof item.submenu === 'function' ? item.submenu() : item.submenu; + const sub = el('div.ctx-menu.ctx-submenu'); + if (!subItems.length) { + sub.append(el('div.mi.disabled', {}, 'Nothing else to choose')); + } + for (const subItem of subItems) { + if (subItem === 'sep') { sub.append(el('hr')); continue; } + sub.append(el('div.mi', { + className: `mi${subItem.danger ? ' danger' : ''}`, + onClick: () => { closeAll(); subItem.action(); }, + }, subItem.label)); + } + document.body.append(sub); + const miRect = mi.getBoundingClientRect(); + sub.style.left = `${miRect.right + 2}px`; + sub.style.top = `${miRect.top}px`; + const subRect = sub.getBoundingClientRect(); + if (subRect.right > innerWidth) sub.style.left = `${Math.max(6, miRect.left - subRect.width - 2)}px`; + if (subRect.bottom > innerHeight) sub.style.top = `${Math.max(6, innerHeight - subRect.height - 6)}px`; + openSubmenu = sub; + }, + }, el('span', {}, item.label), el('span.submenu-arrow', {}, '›')); + menu.append(mi); + continue; + } menu.append(el('div.mi', { className: `mi${item.danger ? ' danger' : ''}`, - onClick: () => { menu.remove(); item.action(); }, + onMouseEnter: closeSubmenu, + onClick: () => { closeAll(); item.action(); }, }, item.label)); } document.body.append(menu); @@ -135,7 +175,7 @@ function contextMenu(x, y, items) { if (rect.right > innerWidth) menu.style.left = `${innerWidth - rect.width - 6}px`; if (rect.bottom > innerHeight) menu.style.top = `${innerHeight - rect.height - 6}px`; setTimeout(() => { - document.addEventListener('click', () => menu.remove(), { once: true }); + document.addEventListener('click', () => closeAll(), { once: true }); }, 0); } From 46d12cc8298a1cac48dbbaed426f78e13baaf44d Mon Sep 17 00:00:00 2001 From: Twest2 Date: Sat, 13 Jun 2026 20:47:16 -0500 Subject: [PATCH 11/25] Insert markdown-style [text](url) links from the Link toolbar button Previously this wrapped the selection in a real tag via createLink. Now it inserts literal [text](url) markdown syntax, using the selection as the link text or prompting for it if nothing is selected. --- app/renderer/editor.js | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/renderer/editor.js b/app/renderer/editor.js index b688d7b..36e1c52 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -1825,8 +1825,12 @@ class GuideEditor { document.execCommand('formatBlock', false, block || 'blockquote'); break; case 'createLink': { + const selectedText = window.getSelection().toString(); + const text = selectedText || window.prompt('Link text'); + if (!text) break; const url = window.prompt('Link URL'); - if (url) document.execCommand('createLink', false, url); + if (!url) break; + document.execCommand('insertText', false, `[${text}](${url})`); break; } case 'removeFormat': From bb959a4bb93dc0810d7aa23f414f17b15dbdb96e Mon Sep 17 00:00:00 2001 From: Twest2 Date: Sat, 13 Jun 2026 21:08:31 -0500 Subject: [PATCH 12/25] Description editor: preserve newlines on export, toggle Quote, highlight active toolbar buttons - Enter now starts a new

(defaultParagraphSeparator) so line breaks in the description box show up as line breaks in HTML/Markdown/Confluence exports; htmlToMarkdown also handles

-separated paragraphs. - Clicking Quote while already in a blockquote toggles back to a paragraph. - Toolbar buttons (Bold, Italic, Bullet, Number, Quote) turn blue to reflect the formatting state at the current cursor/selection. --- app/renderer/editor.js | 45 +++++++++++++++++++++++++++++++++++++++--- app/renderer/style.css | 5 +++++ exporters/htmlmd.js | 2 ++ 3 files changed, 49 insertions(+), 3 deletions(-) diff --git a/app/renderer/editor.js b/app/renderer/editor.js index 36e1c52..814bad2 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -329,10 +329,32 @@ class GuideEditor { } toolbarBtn(action, label, block = null) { - return el('button', { + const btn = el('button', { type: 'button', onClick: () => this.formatDescription(action, block), }, label); + btn.dataset.action = action; + if (block) btn.dataset.block = block; + return btn; + } + + /** Reflect the current selection's formatting state on the toolbar buttons. */ + updateToolbarState() { + if (!this.dom.richToolbar) return; + for (const btn of this.dom.richToolbar.children) { + const { action, block } = btn.dataset; + let active = false; + try { + if (action === 'formatBlock') { + active = document.queryCommandValue('formatBlock').toLowerCase() === (block || 'blockquote'); + } else if (action === 'bold' || action === 'italic' || action === 'insertUnorderedList' || action === 'insertOrderedList') { + active = document.queryCommandState(action); + } + } catch { + active = false; + } + btn.classList.toggle('active', active); + } } bindShellEvents() { @@ -422,7 +444,14 @@ class GuideEditor { this.dom.addTableBlockBtn.addEventListener('click', () => this.addBlock('table')); this.dom.descEditor.addEventListener('focus', () => { + // Make Enter start a new paragraph (

) rather than a plain

, + // so line breaks survive sanitization and show up in exports. + document.execCommand('defaultParagraphSeparator', false, 'p'); if (this.currentStep) this.pushCanvasHistory('description'); + this.updateToolbarState(); + }); + this.dom.descEditor.addEventListener('blur', () => { + for (const btn of this.dom.richToolbar.children) btn.classList.remove('active'); }); this.dom.descEditor.addEventListener('input', () => { if (!this.currentStep) return; @@ -430,6 +459,12 @@ class GuideEditor { this.pendingSave = true; this.saveStepDebounced(); this.emitMeta(); + this.updateToolbarState(); + }); + this.dom.descEditor.addEventListener('keyup', () => this.updateToolbarState()); + this.dom.descEditor.addEventListener('mouseup', () => this.updateToolbarState()); + document.addEventListener('selectionchange', () => { + if (document.activeElement === this.dom.descEditor) this.updateToolbarState(); }); this.dom.descEditor.addEventListener('paste', (e) => { @@ -1821,9 +1856,12 @@ class GuideEditor { case 'insertOrderedList': document.execCommand('insertOrderedList'); break; - case 'formatBlock': - document.execCommand('formatBlock', false, block || 'blockquote'); + case 'formatBlock': { + const want = block || 'blockquote'; + const current = document.queryCommandValue('formatBlock').toLowerCase(); + document.execCommand('formatBlock', false, current === want ? 'p' : want); break; + } case 'createLink': { const selectedText = window.getSelection().toString(); const text = selectedText || window.prompt('Link text'); @@ -1844,6 +1882,7 @@ class GuideEditor { this.pendingSave = true; this.saveStepDebounced(); } + this.updateToolbarState(); } onDocumentKeyDown(e) { diff --git a/app/renderer/style.css b/app/renderer/style.css index 9f1c37e..137b1a0 100644 --- a/app/renderer/style.css +++ b/app/renderer/style.css @@ -487,6 +487,11 @@ kbd { margin-bottom: 8px; } .rich-toolbar button { padding: 4px 8px; font-size: 12px; } +.rich-toolbar button.active { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-fg); +} .rich-editor { min-height: 110px; max-height: 220px; diff --git a/exporters/htmlmd.js b/exporters/htmlmd.js index fdc224c..067fd59 100644 --- a/exporters/htmlmd.js +++ b/exporters/htmlmd.js @@ -42,6 +42,8 @@ function htmlToMarkdown(html) { .replace(//gi, '\n---\n') .replace(/

/gi, '\n') .replace(/<\/p>/gi, '\n') + .replace(/

/gi, '\n') + .replace(/<\/div>/gi, '\n') .replace(/<[^>]+>/g, ''); return decodeEntities(out).replace(/[ \t]+\n/g, '\n').replace(/\n{3,}/g, '\n\n').trim(); From 420b32c0f81865454ed2880da8d1dc1443ecce58 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Sat, 13 Jun 2026 21:11:58 -0500 Subject: [PATCH 13/25] Description editor: insert link placeholder inline instead of prompting Clicking Link no longer pops up "Link text"/"Link URL" dialogs; it inserts [Text](Link) (or [selectedText](Link)) directly so the placeholders can be edited in place. --- app/renderer/editor.js | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/app/renderer/editor.js b/app/renderer/editor.js index 814bad2..2d6a51b 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -1864,11 +1864,8 @@ class GuideEditor { } case 'createLink': { const selectedText = window.getSelection().toString(); - const text = selectedText || window.prompt('Link text'); - if (!text) break; - const url = window.prompt('Link URL'); - if (!url) break; - document.execCommand('insertText', false, `[${text}](${url})`); + const text = selectedText || 'Text'; + document.execCommand('insertText', false, `[${text}](Link)`); break; } case 'removeFormat': From 0ead1213277c7a06021398824a6a4cae473c8797 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Sat, 13 Jun 2026 21:35:55 -0500 Subject: [PATCH 14/25] Render rich text, links, and special characters correctly in PDF exports PDF exports now preserve bold/italic/links/lists/blockquotes from the description editor (via a new HTML block/run parser) instead of flattening to plain text. Literal "[text](url)" links inserted by the editor's Link button are converted to real tags before rendering. Tabs and common "smart typography" characters (smart quotes, dashes, ellipsis, bullet) are mapped to their WinAnsiEncoding glyphs instead of rendering as "?". Co-Authored-By: Claude Sonnet 4.6 --- core/htmlblocks.js | 125 ++++++++++++++++++++++++++++++++++++++++ core/pdf.js | 32 +++++++++-- core/renderast.js | 22 ++++--- core/util.js | 15 +++++ exporters/pdf.js | 139 ++++++++++++++++++++++++++++++++++++++++++--- 5 files changed, 313 insertions(+), 20 deletions(-) create mode 100644 core/htmlblocks.js diff --git a/core/htmlblocks.js b/core/htmlblocks.js new file mode 100644 index 0000000..5da76f6 --- /dev/null +++ b/core/htmlblocks.js @@ -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
    /
      /
      + + 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 }; diff --git a/core/pdf.js b/core/pdf.js index efcca80..89bc6f2 100644 --- a/core/pdf.js +++ b/core/pdf.js @@ -9,19 +9,41 @@ const zlib = require('node:zlib'); * points; converted to PDF's bottom-left space internally. */ -const FONTS = { F1: 'Helvetica', F2: 'Helvetica-Bold', F3: 'Courier' }; +const FONTS = { F1: 'Helvetica', F2: 'Helvetica-Bold', F3: 'Courier', F4: 'Helvetica-Oblique', F5: 'Helvetica-BoldOblique' }; // Approximate average glyph width factors (per 1pt font size) for wrapping. -const FONT_WIDTH_FACTOR = { F1: 0.51, F2: 0.55, F3: 0.6 }; +const FONT_WIDTH_FACTOR = { F1: 0.51, F2: 0.55, F3: 0.6, F4: 0.51, F5: 0.55 }; function esc(text) { return String(text).replace(/\\/g, '\\\\').replace(/\(/g, '\\(').replace(/\)/g, '\\)'); } +// "Smart typography" characters commonly produced by rich-text editors and +// pasted content (e.g. from Word/Google Docs) that have a printable glyph in +// WinAnsiEncoding (cp1252) outside the Latin-1 range; map to that byte +// instead of falling back to "?". +const WINANSI_EXTRA = { + 0x20ac: 0x80, // € + 0x2026: 0x85, // … ellipsis + 0x2030: 0x89, // ‰ + 0x2039: 0x8b, // ‹ + 0x203a: 0x9b, // › + 0x2018: 0x91, // ' left single quote + 0x2019: 0x92, // ' right single quote + 0x201c: 0x93, // " left double quote + 0x201d: 0x94, // " right double quote + 0x2022: 0x95, // • bullet + 0x2013: 0x96, // – en dash + 0x2014: 0x97, // — em dash + 0x2122: 0x99, // ™ +}; + function toLatin1(text) { let out = ''; for (const ch of String(text)) { const code = ch.codePointAt(0); - out += code <= 0xff ? ch : '?'; + if (code <= 0xff) out += ch; + else if (WINANSI_EXTRA[code] !== undefined) out += String.fromCharCode(WINANSI_EXTRA[code]); + else out += '?'; } return out; } @@ -73,8 +95,10 @@ class PdfBuilder { text(str, x, yTop, { size = 11, font = 'F1', color = [0, 0, 0] } = {}) { const y = this.pageHeight - yTop - size; + // Tabs have no glyph in WinAnsiEncoding and render as "?"; expand to spaces. + const clean = String(str).replace(/\t/g, ' '); this.currentPage.ops.push( - `BT /${font} ${size} Tf ${col(color)} rg 1 0 0 1 ${x.toFixed(2)} ${y.toFixed(2)} Tm (${esc(toLatin1(str))}) Tj ET` + `BT /${font} ${size} Tf ${col(color)} rg 1 0 0 1 ${x.toFixed(2)} ${y.toFixed(2)} Tm (${esc(toLatin1(clean))}) Tj ET` ); } diff --git a/core/renderast.js b/core/renderast.js index a7cecef..773f583 100644 --- a/core/renderast.js +++ b/core/renderast.js @@ -3,7 +3,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { sanitizeHtml } = require('./sanitize'); -const { htmlToText, deepClone } = require('./util'); +const { htmlToText, linkifyMarkdownLinks, deepClone } = require('./util'); const { systemPlaceholders, resolveScopes, expandPlaceholders } = require('./placeholders'); const { decodePng } = require('./png'); const { renderAnnotations, applyFocusedView } = require('./raster'); @@ -32,6 +32,10 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte system: systemPlaceholders(guide, { now, stepCount: includedIds.length }), }); const expand = (text) => expandPlaceholders(text, values); + // Description fields additionally turn literal "[text](url)" markdown + // link syntax (as inserted by the editor's Link button) into real + // tags before sanitizing, so exporters render an actual link. + const expandDesc = (html) => linkifyMarkdownLinks(expand(html || '')); const steps = []; const topCounter = { n: 0 }; @@ -66,15 +70,15 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte skipped: step.skipped, forceNewPage: Boolean(step.forceNewPage), title: expand(step.title || ''), - descriptionHtml: sanitizeHtml(expand(step.descriptionHtml || '')), - descriptionText: htmlToText(expand(step.descriptionHtml || '')), + descriptionHtml: sanitizeHtml(expandDesc(step.descriptionHtml)), + descriptionText: htmlToText(expandDesc(step.descriptionHtml)), focusedView: step.focusedView, annotations: (step.annotations || []).map((a) => ({ ...a, text: expand(a.text || '') })), textBlocks: (step.textBlocks || []).map((tb) => ({ ...tb, title: expand(tb.title || ''), - descriptionHtml: sanitizeHtml(expand(tb.descriptionHtml || '')), - descriptionText: htmlToText(expand(tb.descriptionHtml || '')), + descriptionHtml: sanitizeHtml(expandDesc(tb.descriptionHtml)), + descriptionText: htmlToText(expandDesc(tb.descriptionHtml)), })), codeBlocks: (step.codeBlocks || []).map((cb) => ({ ...cb, code: blockText(cb) })), tableBlocks: (step.tableBlocks || []).map((tb) => ({ @@ -86,8 +90,8 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte return { ...block, title: expand(block.title || ''), - descriptionHtml: sanitizeHtml(expand(block.descriptionHtml || '')), - descriptionText: htmlToText(expand(block.descriptionHtml || '')), + descriptionHtml: sanitizeHtml(expandDesc(block.descriptionHtml)), + descriptionText: htmlToText(expandDesc(block.descriptionHtml)), }; } if (block.kind === 'code') return { ...block }; @@ -116,8 +120,8 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte guide: { id: guide.guideId, title: expand(guide.title), - descriptionHtml: sanitizeHtml(expand(guide.descriptionHtml || '')), - descriptionText: htmlToText(expand(guide.descriptionHtml || '')), + descriptionHtml: sanitizeHtml(expandDesc(guide.descriptionHtml)), + descriptionText: htmlToText(expandDesc(guide.descriptionHtml)), createdAt: guide.createdAt, updatedAt: guide.updatedAt, flags: guide.flags, diff --git a/core/util.js b/core/util.js index f94ee79..8e4d992 100644 --- a/core/util.js +++ b/core/util.js @@ -95,6 +95,20 @@ function clamp(v, min, max) { return Math.min(max, Math.max(min, v)); } +// Matches literal markdown-style links typed in the description editor, +// e.g. "[Settings](https://example.com)". Excludes < > so it never spans +// across an existing HTML tag boundary. +const MD_LINK_RE = /\[([^[\]<>]*)\]\(([^()<>]+)\)/g; + +/** + * Turn literal "[text](url)" markdown link syntax (as inserted by the + * description editor's Link button) into real 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) => `${label}`); +} + /** Filesystem-safe slug for export folder names like steps-. */ function slugify(text, fallback = 'untitled') { const slug = String(text || '') @@ -116,6 +130,7 @@ module.exports = { readJsonSync, readJsonIfExists, htmlToText, + linkifyMarkdownLinks, decodeEntities, escapeHtml, escapeXml, diff --git a/exporters/pdf.js b/exporters/pdf.js index 950c10e..e5eb121 100644 --- a/exporters/pdf.js +++ b/exporters/pdf.js @@ -5,6 +5,89 @@ const path = require('node:path'); const { PdfBuilder } = require('../core/pdf'); const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common'); const { htmlToText } = require('../core/util'); +const { htmlToBlocks } = require('../core/htmlblocks'); + +const LIST_INDENT = 14; +const QUOTE_INDENT = 10; +const HEADING_BUMP = { h1: 2.5, h2: 2, h3: 1.5, h4: 1 }; + +function fontForRun(run) { + if (run.code) return 'F3'; + if (run.bold && run.italic) return 'F5'; + if (run.bold) return 'F2'; + if (run.italic) return 'F4'; + return 'F1'; +} + +/** Split formatted runs into words (font/link tagged) and greedily wrap to maxWidth. */ +function wrapRuns(pdf, runs, size, maxWidth) { + const words = []; + for (const run of runs) { + const font = fontForRun(run); + for (const part of run.text.split(/(\s+)/)) { + if (part === '') continue; + words.push({ text: part, font, href: run.href }); + } + } + const lines = []; + let line = []; + let w = 0; + for (const word of words) { + const isSpace = /^\s+$/.test(word.text); + const ww = pdf.textWidth(word.text, size, word.font); + if (!isSpace && line.length && w + ww > maxWidth) { + lines.push(line); + line = []; + w = 0; + } + if (isSpace && !line.length) continue; + line.push(word); + w += ww; + } + if (line.length) lines.push(line); + return lines; +} + +/** + * Lay out description HTML into render-ready items, preserving bold, + * italic, links, lists, blockquotes and headings. + * Returns { items, height }; items: { kind: 'hr', width } | { kind: 'text', + * lines, size, lineHeight, indent, prefix, muted }. + */ +function layoutDescription(pdf, html, maxWidth, baseSize) { + const items = []; + let height = 0; + for (const block of htmlToBlocks(html || '')) { + if (block.type === 'hr') { + items.push({ kind: 'hr', width: maxWidth }); + height += 12; + continue; + } + let size = baseSize; + let runs = block.runs; + let indent = (block.indent || 0) * LIST_INDENT; + let prefix = null; + let muted = false; + if (HEADING_BUMP[block.type]) { + size = baseSize + HEADING_BUMP[block.type]; + runs = runs.map((r) => ({ ...r, bold: true })); + } else if (block.type === 'li') { + prefix = '•'; + indent += LIST_INDENT; + } else if (block.type === 'oli') { + prefix = `${block.n}.`; + indent += LIST_INDENT; + } else if (block.type === 'blockquote') { + indent += QUOTE_INDENT; + muted = true; + } + const lines = wrapRuns(pdf, runs, size, maxWidth - indent); + const lineHeight = size * 1.35; + items.push({ kind: 'text', lines, size, lineHeight, indent, prefix, muted }); + height += lines.length * lineHeight + 4; + } + return { items, height }; +} /** * PDF exporter: cover block, optional TOC, one section per step with the @@ -51,6 +134,31 @@ function exportPdf(ast, outDir, template = {}) { y += fs_ * leading; } }; + /** Render laid-out description items, preserving bold/italic/links/lists. */ + const writeDescription = (html, { size: baseSize = 10.5, color = [0, 0, 0], indent: indentBase = 0 } = {}) => { + const { items } = layoutDescription(pdf, html, usableW - indentBase, baseSize); + for (const item of items) { + if (item.kind === 'hr') { + ensure(12); + pdf.rect(M + indentBase, y + 5, item.width, 0.8, { fill: [225, 228, 232] }); + y += 12; + continue; + } + item.lines.forEach((line, idx) => { + ensure(item.lineHeight); + const textX = M + indentBase + item.indent; + if (idx === 0 && item.prefix) pdf.text(item.prefix, textX - LIST_INDENT, y, { size: item.size, font: 'F1', color }); + let x = textX; + for (const word of line) { + const c = word.href ? tpl.accentColor : (item.muted ? [100, 100, 100] : color); + pdf.text(word.text, x, y, { size: item.size, font: word.font, color: c }); + x += pdf.textWidth(word.text, item.size, word.font); + } + y += item.lineHeight; + }); + y += 4; + } + }; pdf.addPage(); @@ -60,7 +168,7 @@ function exportPdf(ast, outDir, template = {}) { y += 6; writeLines(ast.guide.title, { size: 26, font: 'F2' }); y += 8; - if (ast.guide.descriptionText) writeLines(ast.guide.descriptionText, { size: 12, color: [70, 70, 70] }); + if (ast.guide.descriptionHtml) writeDescription(ast.guide.descriptionHtml, { size: 12, color: [70, 70, 70] }); y += 14; writeLines(`${ast.steps.length} steps — generated ${ast.generatedAt.slice(0, 10)}`, { size: 10, color: [120, 120, 120] }); pdf.addPage(); @@ -91,7 +199,7 @@ function exportPdf(ast, outDir, template = {}) { y += 8; emitBlocks(step, 'before-description'); - if (step.descriptionText) { writeLines(step.descriptionText); y += 4; } + if (step.descriptionHtml) writeDescription(step.descriptionHtml); const img = images.get(step.stepId); if (img) { @@ -146,15 +254,32 @@ function exportPdf(ast, outDir, template = {}) { function emitBlocks(step, position) { for (const tb of stepBlocks(step).filter((b) => b.kind === 'text' && b.position === position)) { const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`; - const bodyLines = tb.descriptionText ? pdf.wrapText(tb.descriptionText, 9.5, usableW - 18) : []; - const blockH = 16 + bodyLines.length * 13; + const { items, height: bodyH } = tb.descriptionHtml + ? layoutDescription(pdf, tb.descriptionHtml, usableW - 18, 9.5) + : { items: [], height: 0 }; + const blockH = 16 + bodyH; ensure(blockH + 4); pdf.rect(M, y, 3, blockH, { fill: tpl.accentColor }); pdf.text(label, M + 10, y + 2, { size: 9.5, font: 'F2' }); let by = y + 16; - for (const line of bodyLines) { - pdf.text(line, M + 10, by, { size: 9.5 }); - by += 13; + 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; + } + 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' }); + let x = textX; + for (const word of line) { + const c = word.href ? tpl.accentColor : (item.muted ? [100, 100, 100] : [0, 0, 0]); + pdf.text(word.text, x, by, { size: item.size, font: word.font, color: c }); + x += pdf.textWidth(word.text, item.size, word.font); + } + by += item.lineHeight; + }); + by += 4; } y += blockH + 6; } From f094bbbd73e6b2a290b75fa35fddb5347c2b80bd Mon Sep 17 00:00:00 2001 From: Twest2 <git@twestbrook.com> Date: Sat, 13 Jun 2026 21:42:26 -0500 Subject: [PATCH 15/25] Fix inflated word spacing in PDF description text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit writeDescription/emitBlocks rendered each word as a separately positioned Tj, advancing by our approximate average-glyph-width estimate — which is much wider than real space/character widths, producing "This is bold" style gaps. Render each line as one continuous text object instead (PdfBuilder.textRun), so consecutive Tj operators advance using the viewer's real font metrics. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- core/pdf.js | 29 +++++++++++++++++++++++++++++ exporters/pdf.js | 24 ++++++++++++------------ 2 files changed, 41 insertions(+), 12 deletions(-) diff --git a/core/pdf.js b/core/pdf.js index 89bc6f2..21febb7 100644 --- a/core/pdf.js +++ b/core/pdf.js @@ -102,6 +102,35 @@ class PdfBuilder { ); } + /** + * Render a line made of mixed-font/color segments (e.g. bold/italic runs) + * as one continuous text object: consecutive Tj operators without an + * intervening Tm advance the cursor by each font's real glyph widths, so + * spacing matches the viewer's metrics exactly (unlike our approximate + * textWidth(), which is only used for word-wrap decisions). + */ + textRun(parts, x, yTop, size) { + const y = this.pageHeight - yTop - size; + const ops = [`BT 1 0 0 1 ${x.toFixed(2)} ${y.toFixed(2)} Tm`]; + let curFont = null; + let curColor = null; + for (const part of parts) { + if (!part.text) continue; + if (part.font !== curFont) { + ops.push(`/${part.font} ${size} Tf`); + curFont = part.font; + } + if (!curColor || part.color[0] !== curColor[0] || part.color[1] !== curColor[1] || part.color[2] !== curColor[2]) { + ops.push(`${col(part.color)} rg`); + curColor = part.color; + } + const clean = String(part.text).replace(/\t/g, ' '); + ops.push(`(${esc(toLatin1(clean))}) Tj`); + } + ops.push('ET'); + this.currentPage.ops.push(ops.join(' ')); + } + rect(x, yTop, w, h, { fill = null, stroke = null, lineWidth = 1 } = {}) { const y = this.pageHeight - yTop - h; const ops = []; diff --git a/exporters/pdf.js b/exporters/pdf.js index e5eb121..bf3d5e7 100644 --- a/exporters/pdf.js +++ b/exporters/pdf.js @@ -148,12 +148,12 @@ function exportPdf(ast, outDir, template = {}) { 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 }); - let x = textX; - for (const word of line) { - const c = word.href ? tpl.accentColor : (item.muted ? [100, 100, 100] : color); - pdf.text(word.text, x, y, { size: item.size, font: word.font, color: c }); - x += pdf.textWidth(word.text, item.size, word.font); - } + 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; @@ -271,12 +271,12 @@ function exportPdf(ast, outDir, template = {}) { 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' }); - let x = textX; - for (const word of line) { - const c = word.href ? tpl.accentColor : (item.muted ? [100, 100, 100] : [0, 0, 0]); - pdf.text(word.text, x, by, { size: item.size, font: word.font, color: c }); - x += pdf.textWidth(word.text, item.size, word.font); - } + 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; From fee394f6b77a60c1ab6c95fade19fba8b70b4b73 Mon Sep 17 00:00:00 2001 From: Twest2 <git@twestbrook.com> Date: Sat, 13 Jun 2026 23:02:03 -0500 Subject: [PATCH 16/25] Add quote indicator and distinct callout styling for Note/Tip/Warning/Important - Description editor: blockquotes get a left accent line and muted color so it's clear when typing in "quote mode" (matches the existing active Quote toolbar button highlight). - Editor block cards: each text block's left border is colored by its level (info/success/warn/error) so Note/Tip/Warning/Important are distinguishable at a glance. - PDF/HTML/DOCX exports: callouts now get a level-specific accent color and tinted background/shading (blue/green/amber/red), instead of all looking identical except for the label text. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --- app/renderer/editor.js | 3 ++- app/renderer/style.css | 11 +++++++++++ exporters/docx.js | 14 ++++++++++++-- exporters/html.js | 6 +++++- exporters/pdf.js | 15 +++++++++++++-- 5 files changed, 43 insertions(+), 6 deletions(-) diff --git a/app/renderer/editor.js b/app/renderer/editor.js index 2d6a51b..af20fd7 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -674,6 +674,7 @@ class GuideEditor { }, header); if (kind === 'text') { + card.dataset.level = block.level || 'info'; const position = makeSelect(block.position, [ { value: 'before-title', label: 'Before title' }, { value: 'after-title', label: 'After title' }, @@ -696,7 +697,7 @@ class GuideEditor { body.dataset.blockField = 'body'; body.value = (block.descriptionHtml || '').replace(/<[^>]+>/g, ''); position.addEventListener('change', () => { block.position = position.value; save(); }); - level.addEventListener('change', () => { block.level = level.value; save(); }); + level.addEventListener('change', () => { block.level = level.value; card.dataset.level = level.value; save(); }); title.addEventListener('input', () => { block.title = title.value; save(); }); body.addEventListener('input', () => { block.descriptionHtml = `<p>${escapeHtml(body.value)}</p>`; save(); }); card.append( diff --git a/app/renderer/style.css b/app/renderer/style.css index 137b1a0..06f6675 100644 --- a/app/renderer/style.css +++ b/app/renderer/style.css @@ -514,6 +514,12 @@ kbd { border-radius: 10px; background: var(--panel-2); } +.rich-editor blockquote { + margin: 6px 0; + padding: 2px 0 2px 12px; + border-left: 3px solid var(--accent); + color: var(--muted); +} .block-card, .annotation-editor-inner { @@ -523,12 +529,17 @@ kbd { } .block-card { border: 1px solid var(--border); + border-left: 4px solid var(--border); border-radius: 12px; padding: 10px; background: var(--panel-solid); } .block-card[draggable="true"] { cursor: grab; } .block-card[draggable="true"]:active { cursor: grabbing; } +.block-card[data-level="info"] { border-left-color: #3b82f6; } +.block-card[data-level="success"] { border-left-color: #10b981; } +.block-card[data-level="warn"] { border-left-color: #f59e0b; } +.block-card[data-level="error"] { border-left-color: #ef4444; } .annotation-list { display: flex; flex-direction: column; diff --git a/exporters/docx.js b/exporters/docx.js index 5ca8972..ef761ce 100644 --- a/exporters/docx.js +++ b/exporters/docx.js @@ -18,6 +18,15 @@ const DEFAULT_TEMPLATE = { imageWidthTwips: 9000, // ~15.9cm inside A4 margins }; +// Callout styling per text-block level, matching the colors used in the +// HTML/PDF exports so a "Tip" looks distinct from a "Warning" at a glance. +const LEVEL_STYLE = { + info: { fill: 'EFF6FF', color: '1D4ED8' }, // blue — Note + success: { fill: 'ECFDF5', color: '047857' }, // green — Tip + warn: { fill: 'FFFBEB', color: 'B45309' }, // amber — Warning + error: { fill: 'FEF2F2', color: 'B91C1C' }, // red — Important +}; + const EMU_PER_PX = 9525; // 96 dpi function p(children, props = '') { @@ -117,9 +126,10 @@ function exportDocx(ast, outDir, template = {}) { function emitTextBlocks(step, position) { for (const tb of stepBlocks(step).filter((b) => b.kind === 'text' && b.position === position)) { const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`; + const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info; body.push(p( - run(label, { bold: true, size: 20 }) + (tb.descriptionText ? run('\n' + tb.descriptionText, { size: 20 }) : ''), - '<w:shd w:val="clear" w:fill="F9FAFB"/>' + run(label, { bold: true, size: 20, color: style.color }) + (tb.descriptionText ? run('\n' + tb.descriptionText, { size: 20 }) : ''), + `<w:shd w:val="clear" w:fill="${style.fill}"/><w:pBdr><w:left w:val="single" w:sz="24" w:space="4" w:color="${style.color}"/></w:pBdr>` )); } } diff --git a/exporters/html.js b/exporters/html.js index 8b458c6..4318f4f 100644 --- a/exporters/html.js +++ b/exporters/html.js @@ -78,10 +78,14 @@ const BASE_CSS = ` pre.code { background: #f3f4f6; padding: 12px; border-radius: 6px; overflow-x: auto; } table { border-collapse: collapse; margin: .6em 0; } th, td { border: 1px solid #d1d5db; padding: 4px 10px; text-align: left; } - .block { border-left: 4px solid #9ca3af; background: #f9fafb; padding: 8px 12px; margin: .6em 0; border-radius: 0 6px 6px 0; } + .block { border-left: 4px solid #3b82f6; background: #eff6ff; padding: 8px 12px; margin: .6em 0; border-radius: 0 6px 6px 0; } + .block strong { color: #1d4ed8; } .block-warn { border-color: #f59e0b; background: #fffbeb; } + .block-warn strong { color: #b45309; } .block-error { border-color: #ef4444; background: #fef2f2; } + .block-error strong { color: #b91c1c; } .block-success { border-color: #10b981; background: #ecfdf5; } + .block-success strong { color: #047857; } .skipped { opacity: .55; } @media (prefers-color-scheme: dark) { body { background: #111827; color: #e5e7eb; } diff --git a/exporters/pdf.js b/exporters/pdf.js index bf3d5e7..1f5eedb 100644 --- a/exporters/pdf.js +++ b/exporters/pdf.js @@ -11,6 +11,15 @@ 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'; @@ -258,9 +267,11 @@ function exportPdf(ast, outDir, template = {}) { ? layoutDescription(pdf, tb.descriptionHtml, usableW - 18, 9.5) : { items: [], height: 0 }; const blockH = 16 + bodyH; + const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info; ensure(blockH + 4); - pdf.rect(M, y, 3, blockH, { fill: tpl.accentColor }); - pdf.text(label, M + 10, y + 2, { size: 9.5, font: 'F2' }); + pdf.rect(M, y, usableW, blockH, { fill: style.tint }); + pdf.rect(M, y, 3, blockH, { fill: style.accent }); + pdf.text(label, M + 10, y + 2, { size: 9.5, font: 'F2', color: style.accent }); let by = y + 16; for (const item of items) { if (item.kind === 'hr') { From 06fba2464554434a3aed730672b1fef8ef912e78 Mon Sep 17 00:00:00 2001 From: Twest2 <git@twestbrook.com> Date: Sat, 13 Jun 2026 23:11:02 -0500 Subject: [PATCH 17/25] Use GitHub-Flavored Markdown alert syntax for Note/Tip/Warning/Important blocks Markdown export now emits > [!NOTE]/[!TIP]/[!WARNING]/[!IMPORTANT] for text-block callouts, giving them the same colored/icon-labeled treatment as the PDF/HTML/DOCX exports on renderers that support GFM alerts (GitHub, Azure DevOps wikis, etc.), while degrading gracefully to a plain blockquote elsewhere. --- exporters/markdown.js | 6 +++++- tests/unit/exporters-text.test.js | 7 ++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/exporters/markdown.js b/exporters/markdown.js index 3100f97..5f46027 100644 --- a/exporters/markdown.js +++ b/exporters/markdown.js @@ -84,7 +84,11 @@ function exportMarkdown(ast, outDir, template = {}) { function emitBlocks(lines, step, position) { for (const tb of stepBlocks(step).filter((b) => b.kind === 'text' && b.position === position)) { const label = LEVEL_LABEL[tb.level] || 'Note'; - lines.push(`> **${label}${tb.title ? `: ${tb.title}` : ''}**`); + // GitHub-Flavored Markdown alert syntax — renders with a colored, + // icon-labeled box on GitHub/Azure DevOps wikis and several other + // viewers; degrades to a plain blockquote elsewhere. + lines.push(`> [!${label.toUpperCase()}]`); + if (tb.title) lines.push(`> **${tb.title}**`); const body = htmlToMarkdown(tb.descriptionHtml); if (body) lines.push(`> ${body.replace(/\n/g, '\n> ')}`); lines.push(''); diff --git a/tests/unit/exporters-text.test.js b/tests/unit/exporters-text.test.js index 45c2dc4..119749a 100644 --- a/tests/unit/exporters-text.test.js +++ b/tests/unit/exporters-text.test.js @@ -109,10 +109,11 @@ test('Markdown export: TOC anchors resolve, images exist, blocks rendered', (t) assert.equal(lines[fenceStart + 1], '0 2 * * * /usr/local/bin/acmesync --backup'); assert.equal(lines[fenceStart + 2], '```'); assert.ok(lines.some((l) => /^\| Day \| Window \|$/.test(l)), 'table header row'); - // Warning text block became a blockquote with its content. - const warnIdx = lines.findIndex((l) => l.startsWith('> **Warning: Access**')); + // Warning text block became a GFM alert blockquote with its content. + const warnIdx = lines.findIndex((l) => l.startsWith('> [!WARNING]')); assert.ok(warnIdx > 0); - assert.equal(lines[warnIdx + 1], '> Admins only.'); + assert.equal(lines[warnIdx + 1], '> **Access**'); + assert.equal(lines[warnIdx + 2], '> Admins only.'); }); test('Confluence export writes storage-format XML and image attachments', (t) => { From 5d4925dee486f55e345f3b6dd1e27f4b352f0e70 Mon Sep 17 00:00:00 2001 From: Twest2 <git@twestbrook.com> Date: Sun, 14 Jun 2026 09:11:00 -0500 Subject: [PATCH 18/25] Fix export block ordering, stale per-step UI, and cross-step block loss Exporters now interleave text/code/table blocks in the same order they appear in the editor's Blocks panel (via a shared stepContentGroups helper) instead of grouping by kind, so exported docs match the guide editor's ordering. selectStep() now also refreshes the Focused View controls and Blocks panel (previously only done by renderAll), so switching steps no longer leaves the previous step's blocks/focused-view sliders on screen. It also flushes any pending edits on the outgoing step before switching, so a later guide-wide reload (e.g. applying an annotation style to the whole guide) can't discard unsaved text-block edits on other steps. --- app/renderer/editor.js | 6 ++++ exporters/common.js | 15 +++++++++ exporters/confluence.js | 18 ++++------ exporters/docx.js | 29 ++++++++-------- exporters/html.js | 24 ++++++------- exporters/markdown.js | 36 ++++++++++---------- exporters/pdf.js | 75 ++++++++++++++++++++--------------------- 7 files changed, 107 insertions(+), 96 deletions(-) diff --git a/app/renderer/editor.js b/app/renderer/editor.js index af20fd7..cae9ee1 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -1281,13 +1281,19 @@ class GuideEditor { async selectStep(stepId) { if (!this.stepMap.has(stepId)) return; + // Persist any unsaved edits on the outgoing step before switching, so a + // later guide-wide reload (e.g. applyStyleAcross('guide')) doesn't + // discard them by re-fetching a stale on-disk copy. + if (this.pendingSave) await this.flushStep(); this.selectedStepId = stepId; this.selectedAnnotationId = null; this.canvas.select(null); this.syncStepFields(); + this.syncFocusedControls(); this.renderStepList(); this.renderCanvas(); this.renderAnnotationPanel(); + this.renderBlocksPanel(); this.emitMeta(); } diff --git a/exporters/common.js b/exporters/common.js index ee87bf8..a0f1809 100644 --- a/exporters/common.js +++ b/exporters/common.js @@ -55,6 +55,20 @@ function stepBlocks(step) { return step.blocks || orderedBlocks(step); } +/** + * Split a step's blocks into the groups exporters lay out around the + * description/image: text blocks pinned to 'before-description', and + * everything else (code/table blocks plus 'after-description' and + * 'after-image' text blocks) in the same relative order they appear in the + * editor's Blocks list. + */ +function stepContentGroups(step) { + const all = stepBlocks(step); + const before = all.filter((b) => b.kind === 'text' && b.position === 'before-description'); + const rest = all.filter((b) => !(b.kind === 'text' && b.position === 'before-description')); + return { before, rest }; +} + function codeBlockText(block) { return blockText(block); } @@ -67,6 +81,7 @@ module.exports = { writeStepImages, renderAllImages, stepBlocks, + stepContentGroups, codeBlockText, LEVEL_LABEL, }; diff --git a/exporters/confluence.js b/exporters/confluence.js index 43e2a2b..2f00a76 100644 --- a/exporters/confluence.js +++ b/exporters/confluence.js @@ -4,7 +4,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { slugify, escapeXml } = require('../core/util'); const { encodePng } = require('../core/png'); -const { guideSlug, renderAllImages, stepBlocks, codeBlockText } = require('./common'); +const { guideSlug, renderAllImages, stepContentGroups, codeBlockText } = require('./common'); /** * Confluence storage-format export. Writes a single XHTML document plus a @@ -67,7 +67,8 @@ function exportConfluence(ast, outDir, template = {}) { const parts = [`<a id="${anchorFor(step)}"></a>`, `<h2>${escapeXml(step.number)}. ${escapeXml(step.title || 'Untitled step')}</h2>`]; if (step.skipped) parts.push('<p><em>(skipped)</em></p>'); - for (const tb of stepBlocks(step).filter((block) => block.kind === 'text' && block.position === 'before-description')) { + const { before, rest } = stepContentGroups(step); + for (const tb of before) { parts.push(blockMacro(tb, ast)); } @@ -80,8 +81,10 @@ function exportConfluence(ast, outDir, template = {}) { parts.push(`<p><ac:image><ri:attachment ri:filename="${escapeXml(attachment)}" /></ac:image></p>`); } - for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) { - if (block.kind === 'code') { + for (const block of rest) { + if (block.kind === 'text') { + parts.push(blockMacro(block, ast)); + } else if (block.kind === 'code') { const lang = block.language ? `<ac:parameter ac:name="language">${escapeXml(block.language)}</ac:parameter>` : ''; parts.push(`<ac:structured-macro ac:name="code">${lang}<ac:plain-text-body>${cdata(codeBlockText(block))}</ac:plain-text-body></ac:structured-macro>`); } else if (block.kind === 'table') { @@ -97,13 +100,6 @@ function exportConfluence(ast, outDir, template = {}) { } } - for (const tb of stepBlocks(step).filter((block) => block.kind === 'text' && block.position === 'after-description')) { - parts.push(blockMacro(tb, ast)); - } - for (const tb of stepBlocks(step).filter((block) => block.kind === 'text' && block.position === 'after-image')) { - parts.push(blockMacro(tb, ast)); - } - return `<div class="step">${parts.join('\n')}</div>`; }).join('\n'); diff --git a/exporters/docx.js b/exporters/docx.js index ef761ce..d79bf6e 100644 --- a/exporters/docx.js +++ b/exporters/docx.js @@ -5,7 +5,7 @@ const path = require('node:path'); const { zipSync } = require('../core/zip'); const { escapeXml } = require('../core/util'); const { encodePng } = require('../core/png'); -const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common'); +const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common'); /** * DOCX exporter: WordprocessingML built directly (no dependency), one @@ -99,7 +99,8 @@ function exportDocx(ast, outDir, template = {}) { body.push(p(run(`${step.number}. ${step.title || 'Untitled step'}`, { bold: true, size: headSize }), step.forceNewPage ? '<w:pageBreakBefore/>' : '')); - emitTextBlocks(step, 'before-description'); + const { before, rest } = stepContentGroups(step); + for (const tb of before) emitTextBlock(tb); if (step.descriptionText) body.push(p(run(step.descriptionText))); const img = images.get(step.stepId); @@ -111,27 +112,25 @@ function exportDocx(ast, outDir, template = {}) { body.push(p(drawing(relCounter, img.width, img.height, tpl.imageWidthTwips))); } - for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) { - if (block.kind === 'code') { + for (const block of rest) { + if (block.kind === 'text') { + emitTextBlock(block); + } else if (block.kind === 'code') { body.push(p(run(codeBlockText(block), { size: 18, font: 'Courier New', color: '1F2937' }), '<w:shd w:val="clear" w:fill="F3F4F6"/>')); } else if (block.kind === 'table') { if (block.rows && block.rows.length) body.push(table(block.rows), p('')); } } - emitTextBlocks(step, 'after-description'); - emitTextBlocks(step, 'after-image'); } - function emitTextBlocks(step, position) { - for (const tb of stepBlocks(step).filter((b) => b.kind === 'text' && b.position === position)) { - const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`; - const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info; - body.push(p( - run(label, { bold: true, size: 20, color: style.color }) + (tb.descriptionText ? run('\n' + tb.descriptionText, { size: 20 }) : ''), - `<w:shd w:val="clear" w:fill="${style.fill}"/><w:pBdr><w:left w:val="single" w:sz="24" w:space="4" w:color="${style.color}"/></w:pBdr>` - )); - } + function emitTextBlock(tb) { + const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`; + const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info; + body.push(p( + run(label, { bold: true, size: 20, color: style.color }) + (tb.descriptionText ? run('\n' + tb.descriptionText, { size: 20 }) : ''), + `<w:shd w:val="clear" w:fill="${style.fill}"/><w:pBdr><w:left w:val="single" w:sz="24" w:space="4" w:color="${style.color}"/></w:pBdr>` + )); } const documentXml = `<?xml version="1.0" encoding="UTF-8" standalone="yes"?> diff --git a/exporters/html.js b/exporters/html.js index 4318f4f..852973e 100644 --- a/exporters/html.js +++ b/exporters/html.js @@ -4,7 +4,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { escapeHtml } = require('../core/util'); const { encodePng } = require('../core/png'); -const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common'); +const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common'); /** * HTML exporters. Both variants are fully self-contained single files: @@ -38,34 +38,32 @@ function stepLinkRewrite(html, ast) { }); } -function blocksHtml(step, position) { - return stepBlocks(step) - .filter((tb) => tb.position === position) - .map((tb) => `<div class="block block-${tb.level}"><strong>${escapeHtml(LEVEL_LABEL[tb.level] || 'Note')}${tb.title ? `: ${escapeHtml(tb.title)}` : ''}</strong>${tb.descriptionHtml ? `<div>${tb.descriptionHtml}</div>` : ''}</div>`) - .join('\n'); +function blockHtml(tb) { + return `<div class="block block-${tb.level}"><strong>${escapeHtml(LEVEL_LABEL[tb.level] || 'Note')}${tb.title ? `: ${escapeHtml(tb.title)}` : ''}</strong>${tb.descriptionHtml ? `<div>${tb.descriptionHtml}</div>` : ''}</div>`; } function stepBodyHtml(step, ast, images, tpl) { const parts = []; - parts.push(blocksHtml(step, 'before-description')); + const { before, rest } = stepContentGroups(step); + for (const tb of before) parts.push(blockHtml(tb)); if (step.descriptionHtml) parts.push(`<div class="desc">${stepLinkRewrite(step.descriptionHtml, ast)}</div>`); const img = images.get(step.stepId); if (img && tpl.includeImages) { parts.push(`<img class="shot" alt="Step ${escapeHtml(step.number)}" src="${dataUri(img)}" width="${img.width}">`); } - for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) { - if (block.kind === 'code') { + for (const block of rest) { + if (block.kind === 'text') { + parts.push(blockHtml(block)); + } else if (block.kind === 'code') { parts.push(`<pre class="code"><code>${escapeHtml(codeBlockText(block))}</code></pre>`); } else if (block.kind === 'table') { if (!block.rows || !block.rows.length) continue; - const [head, ...rest] = block.rows; + const [head, ...bodyRows] = block.rows; parts.push('<table><thead><tr>' + head.map((c) => `<th>${escapeHtml(c)}</th>`).join('') + '</tr></thead><tbody>' - + rest.map((r) => '<tr>' + r.map((c) => `<td>${escapeHtml(c)}</td>`).join('') + '</tr>').join('') + + bodyRows.map((r) => '<tr>' + r.map((c) => `<td>${escapeHtml(c)}</td>`).join('') + '</tr>').join('') + '</tbody></table>'); } } - parts.push(blocksHtml(step, 'after-description')); - parts.push(blocksHtml(step, 'after-image')); return parts.filter(Boolean).join('\n'); } diff --git a/exporters/markdown.js b/exporters/markdown.js index 5f46027..5d6be64 100644 --- a/exporters/markdown.js +++ b/exporters/markdown.js @@ -2,7 +2,7 @@ const fs = require('node:fs'); const path = require('node:path'); -const { guideSlug, writeStepImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common'); +const { guideSlug, writeStepImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common'); const { htmlToMarkdown } = require('./htmlmd'); /** @@ -45,7 +45,8 @@ function exportMarkdown(ast, outDir, template = {}) { lines.push(`${heading} ${step.number}. ${step.title || 'Untitled step'}`, ''); if (step.skipped) lines.push('*(skipped)*', ''); - emitBlocks(lines, step, 'before-description'); + const { before, rest } = stepContentGroups(step); + for (const tb of before) emitBlock(lines, tb); if (step.descriptionHtml) lines.push(htmlToMarkdown(step.descriptionHtml), ''); @@ -58,8 +59,10 @@ function exportMarkdown(ast, outDir, template = {}) { } } - for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) { - if (block.kind === 'code') { + for (const block of rest) { + if (block.kind === 'text') { + emitBlock(lines, block); + } else if (block.kind === 'code') { lines.push(`\`\`\`${block.language || ''}`, codeBlockText(block), '```', ''); } else if (block.kind === 'table') { if (!block.rows || !block.rows.length) continue; @@ -71,9 +74,6 @@ function exportMarkdown(ast, outDir, template = {}) { lines.push(''); } } - - emitBlocks(lines, step, 'after-description'); - emitBlocks(lines, step, 'after-image'); } const file = path.join(outDir, `${guideSlug(ast)}.md`); @@ -81,18 +81,16 @@ function exportMarkdown(ast, outDir, template = {}) { return { file, imageCount: images.size }; } -function emitBlocks(lines, step, position) { - for (const tb of stepBlocks(step).filter((b) => b.kind === 'text' && b.position === position)) { - const label = LEVEL_LABEL[tb.level] || 'Note'; - // GitHub-Flavored Markdown alert syntax — renders with a colored, - // icon-labeled box on GitHub/Azure DevOps wikis and several other - // viewers; degrades to a plain blockquote elsewhere. - lines.push(`> [!${label.toUpperCase()}]`); - if (tb.title) lines.push(`> **${tb.title}**`); - const body = htmlToMarkdown(tb.descriptionHtml); - if (body) lines.push(`> ${body.replace(/\n/g, '\n> ')}`); - lines.push(''); - } +function emitBlock(lines, tb) { + const label = LEVEL_LABEL[tb.level] || 'Note'; + // GitHub-Flavored Markdown alert syntax — renders with a colored, + // icon-labeled box on GitHub/Azure DevOps wikis and several other + // viewers; degrades to a plain blockquote elsewhere. + lines.push(`> [!${label.toUpperCase()}]`); + if (tb.title) lines.push(`> **${tb.title}**`); + const body = htmlToMarkdown(tb.descriptionHtml); + if (body) lines.push(`> ${body.replace(/\n/g, '\n> ')}`); + lines.push(''); } module.exports = { exportMarkdown, DEFAULT_TEMPLATE, anchorFor }; diff --git a/exporters/pdf.js b/exporters/pdf.js index 1f5eedb..e54d913 100644 --- a/exporters/pdf.js +++ b/exporters/pdf.js @@ -3,7 +3,7 @@ const fs = require('node:fs'); const path = require('node:path'); const { PdfBuilder } = require('../core/pdf'); -const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common'); +const { guideSlug, renderAllImages, LEVEL_LABEL, stepContentGroups, codeBlockText } = require('./common'); const { htmlToText } = require('../core/util'); const { htmlToBlocks } = require('../core/htmlblocks'); @@ -207,7 +207,8 @@ function exportPdf(ast, outDir, template = {}) { pdf.rect(M, y, usableW, 0.8, { fill: [225, 228, 232] }); y += 8; - emitBlocks(step, 'before-description'); + const { before, rest } = stepContentGroups(step); + for (const tb of before) emitBlock(tb); if (step.descriptionHtml) writeDescription(step.descriptionHtml); const img = images.get(step.stepId); @@ -221,8 +222,10 @@ function exportPdf(ast, outDir, template = {}) { y += h + 10; } - for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) { - if (block.kind === 'code') { + for (const block of rest) { + if (block.kind === 'text') { + emitBlock(block); + } else if (block.kind === 'code') { const lines = String(codeBlockText(block) || '').split('\n'); const lineH = 9 * 1.3; ensure(Math.min(lines.length, 4) * lineH + 12); @@ -255,45 +258,41 @@ function exportPdf(ast, outDir, template = {}) { } } - emitBlocks(step, 'after-description'); - emitBlocks(step, 'after-image'); y += 10; } - function emitBlocks(step, position) { - for (const tb of stepBlocks(step).filter((b) => b.kind === 'text' && b.position === position)) { - const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`; - const { items, height: bodyH } = tb.descriptionHtml - ? layoutDescription(pdf, tb.descriptionHtml, usableW - 18, 9.5) - : { items: [], height: 0 }; - const blockH = 16 + bodyH; - const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info; - ensure(blockH + 4); - pdf.rect(M, y, usableW, blockH, { fill: style.tint }); - pdf.rect(M, y, 3, blockH, { fill: style.accent }); - pdf.text(label, M + 10, y + 2, { size: 9.5, font: 'F2', color: style.accent }); - let by = y + 16; - for (const item of items) { - if (item.kind === 'hr') { - pdf.rect(M + 10, by + 5, item.width, 0.8, { fill: [225, 228, 232] }); - by += 12; - continue; - } - 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; + function emitBlock(tb) { + const label = `${LEVEL_LABEL[tb.level] || 'Note'}${tb.title ? `: ${tb.title}` : ''}`; + const { items, height: bodyH } = tb.descriptionHtml + ? layoutDescription(pdf, tb.descriptionHtml, usableW - 18, 9.5) + : { items: [], height: 0 }; + const blockH = 16 + bodyH; + const style = LEVEL_STYLE[tb.level] || LEVEL_STYLE.info; + ensure(blockH + 4); + pdf.rect(M, y, usableW, blockH, { fill: style.tint }); + pdf.rect(M, y, 3, blockH, { fill: style.accent }); + pdf.text(label, M + 10, y + 2, { size: 9.5, font: 'F2', color: style.accent }); + let by = y + 16; + for (const item of items) { + if (item.kind === 'hr') { + pdf.rect(M + 10, by + 5, item.width, 0.8, { fill: [225, 228, 232] }); + by += 12; + continue; } - y += blockH + 6; + item.lines.forEach((line, idx) => { + const textX = M + 10 + item.indent; + if (idx === 0 && item.prefix) pdf.text(item.prefix, textX - LIST_INDENT, by, { size: item.size, font: 'F1' }); + const parts = line.map((word) => ({ + text: word.text, + font: word.font, + color: word.href ? tpl.accentColor : (item.muted ? [100, 100, 100] : [0, 0, 0]), + })); + pdf.textRun(parts, textX, by, item.size); + by += item.lineHeight; + }); + by += 4; } + y += blockH + 6; } fs.mkdirSync(outDir, { recursive: true }); From a3425c36b4b4228a2f27046025a8d16621df6ba3 Mon Sep 17 00:00:00 2001 From: Twest2 <git@twestbrook.com> Date: Mon, 15 Jun 2026 09:38:24 -0500 Subject: [PATCH 19/25] Spell out "Rectangle" in tool labels instead of "Rect" Keeps the internal 'rect' annotation type id (canvas/raster/schema) unchanged; only updates the user-facing toolbar button and shortcuts help text. --- app/renderer/dialogs.js | 2 +- app/renderer/editor.js | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/app/renderer/dialogs.js b/app/renderer/dialogs.js index 2b03c1f..40e3ec6 100644 --- a/app/renderer/dialogs.js +++ b/app/renderer/dialogs.js @@ -649,7 +649,7 @@ const SHORTCUTS = [ ['Ctrl+V', 'Paste annotation, or clipboard image as new step'], ]], ['Canvas tools', [ - ['S R O L A T', 'Select · Rect · Oval · Line · Arrow · Text'], + ['S R O L A T', 'Select · Rectangle · Oval · Line · Arrow · Text'], ['G N B H M U C', 'Tooltip · Number · Blur · Highlight · Magnify · Cursor · Crop'], ['Ctrl+C', 'Copy selected annotation'], ['Delete', 'Delete selected annotation'], diff --git a/app/renderer/editor.js b/app/renderer/editor.js index cae9ee1..22dba8d 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -189,7 +189,7 @@ class GuideEditor { this.root.innerHTML = ''; const toolButtons = [ ['select', 'Select'], - ['rect', 'Rect'], + ['rect', 'Rectangle'], ['oval', 'Oval'], ['line', 'Line'], ['arrow', 'Arrow'], From 6408040623f7d3e98d2b53d490f84eac2960f629 Mon Sep 17 00:00:00 2001 From: Twest2 <git@twestbrook.com> Date: Mon, 15 Jun 2026 09:43:09 -0500 Subject: [PATCH 20/25] Show proper display names in the annotation Type dropdown The annotation editor's "Type" select used the raw lowercase type id (e.g. "rect") as its label. Add an ANNOTATION_TYPE_LABELS map so it shows "Rectangle", "Tooltip", "Number", etc. instead. --- app/renderer/editor.js | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/app/renderer/editor.js b/app/renderer/editor.js index 22dba8d..5cd17da 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -24,6 +24,21 @@ const ANNOTATION_FIELDS = { cursor: [], }; +// Display names for annotation types in the "Type" dropdown. +const ANNOTATION_TYPE_LABELS = { + rect: 'Rectangle', + oval: 'Oval', + line: 'Line', + arrow: 'Arrow', + text: 'Text', + tooltip: 'Tooltip', + number: 'Number', + blur: 'Blur', + highlight: 'Highlight', + magnify: 'Magnify', + cursor: 'Cursor', +}; + function blockText(block) { for (const key of ['code', 'text', 'body', 'value', 'content']) { const value = block && block[key]; @@ -962,7 +977,7 @@ class GuideEditor { const style = selected.style || {}; const typeSelect = makeSelect(selected.type, [ 'rect', 'oval', 'line', 'arrow', 'text', 'tooltip', 'number', 'blur', 'highlight', 'magnify', 'cursor', - ].map((type) => ({ value: type, label: type }))); + ].map((type) => ({ value: type, label: ANNOTATION_TYPE_LABELS[type] || type }))); const textInput = el('input', { type: 'text', value: selected.text || '', placeholder: 'Annotation text' }); const valueInput = el('input', { type: 'number', value: Number.isFinite(selected.value) ? selected.value : '', placeholder: 'Value' }); const strokeInput = el('input', { type: 'color', value: style.stroke || '#E5484D' }); From 2bb17b170e2107e50921a2240ceb372289bbe7c4 Mon Sep 17 00:00:00 2001 From: Twest2 <git@twestbrook.com> Date: Mon, 15 Jun 2026 09:46:31 -0500 Subject: [PATCH 21/25] Use display labels for annotation types in the Annotations list and copy-style text The per-annotation list cards and the "Copy this style to every other ... annotation" text/tooltips were still showing raw type ids (rect, arrow, etc). Use ANNOTATION_TYPE_LABELS for these too. --- app/renderer/editor.js | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/app/renderer/editor.js b/app/renderer/editor.js index 5cd17da..d621edc 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -962,7 +962,7 @@ class GuideEditor { dataset: { annId: ann.id }, style: { cursor: 'pointer', borderColor: selected ? 'var(--accent)' : '' }, }, - el('div.row', {}, el('strong', {}, ann.type), el('span.muted', {}, ann.text || ann.value || '')), + el('div.row', {}, el('strong', {}, ANNOTATION_TYPE_LABELS[ann.type] || ann.type), el('span.muted', {}, ann.text || ann.value || '')), el('div.muted', {}, `${ann.x.toFixed(3)}, ${ann.y.toFixed(3)} · ${ann.w.toFixed(3)} × ${ann.h.toFixed(3)}`))); } } @@ -1013,6 +1013,7 @@ class GuideEditor { const fields = new Set(ANNOTATION_FIELDS[selected.type] || []); const strokeLabel = (selected.type === 'text' || selected.type === 'number') ? 'Color' : 'Stroke'; + const typeLabel = ANNOTATION_TYPE_LABELS[selected.type] || selected.type; const rows = [labeledRow('Type', typeSelect)]; if (fields.has('text')) rows.push(labeledRow('Text', textInput)); @@ -1026,16 +1027,16 @@ class GuideEditor { 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 "${selected.type}" annotation:`), + el('div.muted', {}, `Copy this style to every other "${typeLabel}" annotation:`), el('div.row', {}, el('button', { type: 'button', - title: `Overwrite the style of every "${selected.type}" annotation on this step with the style shown above.`, + title: `Overwrite the style of every "${typeLabel}" annotation on this step with the style shown above.`, onClick: () => this.applyStyleAcross('step'), }, 'This step'), el('button', { type: 'button', - title: `Overwrite the style of every "${selected.type}" annotation across all steps in this guide with the style shown above.`, + title: `Overwrite the style of every "${typeLabel}" annotation across all steps in this guide with the style shown above.`, onClick: () => this.applyStyleAcross('guide'), }, 'Entire guide'), ), From 88d1fc3efb34dfe3892197e07eea971856d394a7 Mon Sep 17 00:00:00 2001 From: Twest2 <git@twestbrook.com> Date: Mon, 15 Jun 2026 09:50:34 -0500 Subject: [PATCH 22/25] Spell out "Highlight" in the annotation toolbar instead of "Hi" --- app/renderer/editor.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/renderer/editor.js b/app/renderer/editor.js index d621edc..e903fb5 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -212,7 +212,7 @@ class GuideEditor { ['tooltip', 'Tip'], ['number', '#'], ['blur', 'Blur'], - ['highlight', 'Hi'], + ['highlight', 'Highlight'], ['magnify', 'Mag'], ['cursor', 'Cursor'], ['crop', 'Crop'], From 58b4182c5409e1b9b5d3095ca6401c9f6dced5c6 Mon Sep 17 00:00:00 2001 From: Twest2 <git@twestbrook.com> Date: Mon, 15 Jun 2026 10:15:15 -0500 Subject: [PATCH 23/25] Add global undo for step deletion; simplify capture session UI - The canvas Undo/Redo buttons now also undo/redo step deletion (single or multi-select), restoring the step's data, images, and position in the order. Backed by a new step:restore IPC call and GuideStore.restoreStep(). - Remove "Finish capture session" from the capture menu. The top-right recording bar in the guide editor is now the only place to start/stop recording, with its toggle relabeled "Start recording" / "Stop recording". --- app/main.js | 9 +++ app/preload.js | 1 + app/renderer/app.js | 2 +- app/renderer/editor.js | 119 ++++++++++++++++++++++++++++++++------- core/store.js | 21 +++++++ tests/unit/store.test.js | 25 ++++++++ 6 files changed, 156 insertions(+), 21 deletions(-) diff --git a/app/main.js b/app/main.js index 251d5bd..b383010 100644 --- a/app/main.js +++ b/app/main.js @@ -419,6 +419,15 @@ function setupIpc() { reindex(guideId); return true; }); + h('step:restore', ({ guideId, step, originalBase64, workingBase64, position }) => { + const images = { + original: originalBase64 ? Buffer.from(originalBase64, 'base64') : null, + working: workingBase64 ? Buffer.from(workingBase64, 'base64') : null, + }; + const restored = store.restoreStep(guideId, step, images, position); + reindex(guideId); + return restored; + }); h('steps:reorder', ({ guideId, order }) => store.reorderSteps(guideId, order)); h('step:imagePath', ({ guideId, stepId, which }) => { const p = store.stepImagePath(guideId, stepId, which || 'working'); diff --git a/app/preload.js b/app/preload.js index 022f089..432e31b 100644 --- a/app/preload.js +++ b/app/preload.js @@ -34,6 +34,7 @@ const api = { add: invoke('step:add'), save: invoke('step:save'), delete: invoke('step:delete'), + restore: invoke('step:restore'), reorder: invoke('steps:reorder'), imagePath: invoke('step:imagePath'), setWorkingImage: invoke('step:setWorkingImage'), diff --git a/app/renderer/app.js b/app/renderer/app.js index 1092d3e..582ca1f 100644 --- a/app/renderer/app.js +++ b/app/renderer/app.js @@ -279,7 +279,7 @@ class StepForgeApp { } send({ action: s.paused ? 'resume' : 'pause' }); }, - }, notStarted ? 'Start recording' : s.paused ? 'Resume' : 'Pause'); + }, s.paused ? 'Start recording' : 'Stop recording'); this.captureStatus.append( el('span', { title: `Capture session — ${trigger}` }, `Rec - ${trigger}`), diff --git a/app/renderer/editor.js b/app/renderer/editor.js index e903fb5..b6ab0e6 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -873,13 +873,21 @@ class GuideEditor { if (!ids.length) return; const ok = await confirmDialog(`Delete ${ids.length} step${ids.length === 1 ? '' : 's'}?`, { danger: true, okLabel: 'Delete' }); if (!ok) return; + if (this.pendingSave) await this.flushStep(); + const order = this.steps.map((s) => s.stepId); + const entries = []; + for (const stepId of ids) { + const step = this.stepMap.get(stepId); + if (step) entries.push(await this.snapshotStepForDeletion(step)); + } for (const stepId of ids) { await api.step.delete({ guideId: this.guideId, stepId }); } + if (entries.length) this.pushCanvasHistory({ type: 'delete-step', steps: entries, order }); this.stepSelectMode = false; this.selectedSteps = new Set(); await this.reload(null); - this.onToast(`${ids.length} step${ids.length === 1 ? '' : 's'} deleted.`); + this.onToast(`${ids.length} step${ids.length === 1 ? '' : 's'} deleted. Press Ctrl+Z to undo.`); } syncStepFields() { @@ -1146,7 +1154,8 @@ class GuideEditor { pushCanvasHistory(recordOrLabel = 'change') { if (!this.currentStep) return; - const record = recordOrLabel && typeof recordOrLabel === 'object' && recordOrLabel.step + const record = recordOrLabel && typeof recordOrLabel === 'object' + && (recordOrLabel.step || recordOrLabel.type === 'delete-step') ? recordOrLabel : { step: clone(this.currentStep) }; this.canvasHistory.push(record); @@ -1187,27 +1196,39 @@ class GuideEditor { } async undo() { - if (!this.currentStep) return; if (!this.canvasHistory.length) { this.onToast('Nothing to undo.'); return; } + const previous = this.canvasHistory.pop(); + if (previous.type === 'delete-step') { + await this.restoreDeletedSteps(previous); + this.canvasFuture.push(previous); + this.renderAll(); + return; + } + if (!this.currentStep) return; const current = await this.snapshotCurrentStep(true); if (current) this.canvasFuture.push(current); - const previous = this.canvasHistory.pop(); await this.restoreHistoryRecord(previous); this.renderAll(); } async redo() { - if (!this.currentStep) return; if (!this.canvasFuture.length) { this.onToast('Nothing to redo.'); return; } + const next = this.canvasFuture.pop(); + if (next.type === 'delete-step') { + await this.deleteStepsAgain(next); + this.canvasHistory.push(next); + this.renderAll(); + return; + } + if (!this.currentStep) return; const current = await this.snapshotCurrentStep(true); if (current) this.canvasHistory.push(current); - const next = this.canvasFuture.pop(); await this.restoreHistoryRecord(next); this.renderAll(); } @@ -1434,12 +1455,16 @@ class GuideEditor { if (!step) return; const ok = await confirmDialog(`Delete “${step.title || 'Untitled step'}”?`, { danger: true, okLabel: 'Delete' }); if (!ok) return; + if (this.pendingSave) await this.flushStep(); + const order = this.steps.map((s) => s.stepId); + const entry = await this.snapshotStepForDeletion(step); await api.step.delete({ guideId: this.guideId, stepId: step.stepId }); + this.pushCanvasHistory({ type: 'delete-step', steps: [entry], order }); const next = this.steps[this.steps.findIndex((s) => s.stepId === step.stepId) + 1] || this.steps[this.steps.findIndex((s) => s.stepId === step.stepId) - 1] || null; await this.reload(next && next.stepId); - this.onToast('Step deleted.'); + this.onToast('Step deleted. Press Ctrl+Z to undo.'); } async moveSelectedStep(delta) { @@ -1479,18 +1504,18 @@ class GuideEditor { async openCaptureMenu(event) { const rect = event.target.getBoundingClientRect(); const session = (await api.capture.state())?.active; - contextMenu(rect.left, rect.bottom + 4, [ + const items = [ { label: 'Capture full screen', action: () => this.captureStep('fullscreen') }, { label: 'Capture window', action: () => this.captureStep('window') }, { label: 'Capture region…', action: () => this.captureStep('region') }, 'sep', { label: 'Paste image as step', action: () => this.pasteClipboardStep() }, { label: 'Import images…', action: () => this.importImageSteps() }, - 'sep', - session - ? { label: 'Finish capture session', action: () => this.finishCaptureSession() } - : { label: 'Start capture session (hotkey)', action: () => this.startCaptureSession() }, - ]); + ]; + if (!session) { + items.push('sep', { label: 'Start capture session (hotkey)', action: () => this.startCaptureSession() }); + } + contextMenu(rect.left, rect.bottom + 4, items); } async pasteClipboardStep() { @@ -1598,12 +1623,6 @@ class GuideEditor { this.emitMeta(); } - async finishCaptureSession() { - await api.capture.session({ action: 'finish', guideId: this.guideId }); - this.onToast('Capture session finished.'); - this.emitMeta(); - } - async openSettings() { const settings = await api.settings.all(); const placeholders = await api.settings.globalPlaceholders(); @@ -1750,8 +1769,12 @@ class GuideEditor { } async currentStepImageToBase64(step = this.currentStep) { + return this.stepImageToBase64(step, 'working'); + } + + async stepImageToBase64(step, which = 'working') { if (!step || !step.image) return null; - const file = await api.step.imagePath({ guideId: this.guideId, stepId: step.stepId, which: 'working' }); + const file = await api.step.imagePath({ guideId: this.guideId, stepId: step.stepId, which }); if (!file) return null; return new Promise((resolve) => { const img = new Image(); @@ -1769,6 +1792,62 @@ class GuideEditor { }); } + /** Snapshot a step (and its images, if any) before deletion so it can be undone. */ + async snapshotStepForDeletion(step) { + const position = this.steps.findIndex((s) => s.stepId === step.stepId); + const childIds = this.steps.filter((s) => s.parentStepId === step.stepId).map((s) => s.stepId); + let images = null; + if (step.image) { + const [original, working] = await Promise.all([ + this.stepImageToBase64(step, 'original'), + this.stepImageToBase64(step, 'working'), + ]); + images = { original, working }; + } + return { step: clone(step), position, childIds, images }; + } + + /** Undo a 'delete-step' history record: recreate the deleted steps and their order. */ + async restoreDeletedSteps(record) { + for (const entry of record.steps) { + await api.step.restore({ + guideId: this.guideId, + step: entry.step, + originalBase64: entry.images?.original?.base64 || null, + workingBase64: entry.images?.working?.base64 || null, + position: entry.position, + }); + } + await api.step.reorder({ guideId: this.guideId, order: record.order }); + this.saveStepDebounced.cancel(); + this.pendingSave = false; + await this.reload(record.steps[0].step.stepId); + for (const entry of record.steps) { + for (const childId of entry.childIds) { + const child = this.stepMap.get(childId); + if (child && child.parentStepId !== entry.step.stepId) { + child.parentStepId = entry.step.stepId; + await api.step.save({ guideId: this.guideId, step: child }); + } + } + } + if (record.steps.some((entry) => entry.childIds.length)) await this.reload(record.steps[0].step.stepId); + this.onToast(`Restored ${record.steps.length} step${record.steps.length === 1 ? '' : 's'}.`); + } + + /** Redo of an undone 'delete-step' record: delete those steps again. */ + async deleteStepsAgain(record) { + for (const entry of record.steps) { + await api.step.delete({ guideId: this.guideId, stepId: entry.step.stepId }); + } + const deletedIds = new Set(record.steps.map((entry) => entry.step.stepId)); + const remaining = record.order.filter((id) => !deletedIds.has(id)); + this.saveStepDebounced.cancel(); + this.pendingSave = false; + await this.reload(remaining[0] || null); + this.onToast(`Deleted ${record.steps.length} step${record.steps.length === 1 ? '' : 's'}.`); + } + async onCanvasChange(annotations) { const step = this.currentStep; if (!step) return; diff --git a/core/store.js b/core/store.js index c66e7da..8cf1eb3 100644 --- a/core/store.js +++ b/core/store.js @@ -244,6 +244,27 @@ class GuideStore { this.saveGuide(guide); } + /** + * Recreate a previously-deleted step, preserving its stepId, and splice it + * back into the guide's order. Used to undo step deletion. + */ + restoreStep(guideId, step, images, position) { + const guide = this.getGuide(guideId); + const restored = normalizeStep(deepClone(step)); + validateStep(restored); + const dir = this.stepDir(guideId, restored.stepId); + fs.mkdirSync(dir, { recursive: true }); + if (restored.image && images) { + if (images.original) atomicWriteFileSync(path.join(dir, restored.image.originalPath), images.original); + if (images.working) atomicWriteFileSync(path.join(dir, restored.image.workingPath), images.working); + } + writeJsonSync(path.join(dir, 'step.json'), restored); + const at = Number.isInteger(position) ? Math.min(position, guide.stepsOrder.length) : guide.stepsOrder.length; + guide.stepsOrder.splice(at, 0, restored.stepId); + this.saveGuide(guide); + return restored; + } + reorderSteps(guideId, newOrder) { const guide = this.getGuide(guideId); const current = new Set(guide.stepsOrder); diff --git a/tests/unit/store.test.js b/tests/unit/store.test.js index 5dccd01..aa9e672 100644 --- a/tests/unit/store.test.js +++ b/tests/unit/store.test.js @@ -69,6 +69,31 @@ test('step reorder, delete with substep reparenting, and order integrity', (t) = assert.equal(store.getStep(guide.guideId, c.stepId).parentStepId, null); }); +test('restoreStep recreates a deleted step with its original id, data, and images', (t) => { + const root = makeTmpDir('restore'); + t.after(() => rmrf(root)); + const store = new GuideStore(root); + const guide = store.createGuide({ title: 'Restore test' }); + const a = store.addStep(guide.guideId, { kind: 'empty', title: 'A' }); + const b = store.addStep(guide.guideId, { title: 'B', annotations: [{ id: 'ann1', type: 'arrow', x: 1, y: 2 }] }, TINY_PNG, { width: 1, height: 1 }); + const c = store.addStep(guide.guideId, { kind: 'empty', title: 'C' }); + + const deleted = store.getStep(guide.guideId, b.stepId); + store.deleteStep(guide.guideId, b.stepId); + assert.deepEqual(store.getGuide(guide.guideId).stepsOrder, [a.stepId, c.stepId]); + + const restored = store.restoreStep(guide.guideId, deleted, { original: TINY_PNG, working: TINY_PNG }, 1); + assert.equal(restored.stepId, b.stepId); + assert.equal(restored.title, 'B'); + assert.equal(restored.annotations[0].type, 'arrow'); + + const after = store.getGuide(guide.guideId); + assert.deepEqual(after.stepsOrder, [a.stepId, b.stepId, c.stepId]); + assert.deepEqual(store.getStep(guide.guideId, b.stepId).annotations, deleted.annotations); + assert.deepEqual(fs.readFileSync(store.stepImagePath(guide.guideId, b.stepId, 'original')), TINY_PNG); + assert.deepEqual(fs.readFileSync(store.stepImagePath(guide.guideId, b.stepId, 'working')), TINY_PNG); +}); + test('duplicate guide produces independent deep copy with fresh ids', (t) => { const root = makeTmpDir('dup'); t.after(() => rmrf(root)); From db2382294eca92517fa6c50b24566aa52eba0e90 Mon Sep 17 00:00:00 2001 From: Twest2 <git@twestbrook.com> Date: Mon, 15 Jun 2026 10:26:56 -0500 Subject: [PATCH 24/25] Document the Undo/Redo shortcut in the keyboard shortcuts help Now that undo also restores deleted steps, it's worth surfacing the existing Ctrl+Z / Ctrl+Shift+Z shortcut to users. --- app/renderer/dialogs.js | 1 + 1 file changed, 1 insertion(+) diff --git a/app/renderer/dialogs.js b/app/renderer/dialogs.js index 40e3ec6..532a64e 100644 --- a/app/renderer/dialogs.js +++ b/app/renderer/dialogs.js @@ -646,6 +646,7 @@ const SHORTCUTS = [ ['PageUp / PageDown', 'Previous / next step'], ['Alt+↑ / Alt+↓', 'Move step up / down'], ['Ctrl+Delete', 'Delete current step'], + ['Ctrl+Z / Ctrl+Shift+Z', 'Undo / redo (including step deletion)'], ['Ctrl+V', 'Paste annotation, or clipboard image as new step'], ]], ['Canvas tools', [ From 4732c5daf3c5eded07cfe2c1f5a20a9d62d055a7 Mon Sep 17 00:00:00 2001 From: Twest2 <git@twestbrook.com> Date: Mon, 15 Jun 2026 10:29:26 -0500 Subject: [PATCH 25/25] Limit CI test run to main and manual triggers Pushes to main (direct commits and merged PRs) and manual workflow_dispatch now trigger the test job; feature-branch pushes and PR open/sync no longer do. --- .gitea/workflows/tests.yaml | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/tests.yaml b/.gitea/workflows/tests.yaml index 39efead..1bc5c22 100644 --- a/.gitea/workflows/tests.yaml +++ b/.gitea/workflows/tests.yaml @@ -2,11 +2,9 @@ name: Template tests on: push: - pull_request: - types: - - opened - - synchronize - - reopened + branches: + - main + workflow_dispatch: jobs: tests: