Fix export block ordering, stale per-step UI, and cross-step block loss
Template tests / tests (push) Successful in 1m42s
Template tests / tests (pull_request) Successful in 1m41s

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.
This commit is contained in:
2026-06-14 09:11:00 -05:00
parent 06fba24645
commit 5d4925dee4
7 changed files with 107 additions and 96 deletions
+6
View File
@@ -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();
}
+15
View File
@@ -55,6 +55,20 @@ function stepBlocks(step) {
return step.blocks || orderedBlocks(step);
}
/**
* Split a step's blocks into the groups exporters lay out around the
* description/image: text blocks pinned to 'before-description', and
* everything else (code/table blocks plus 'after-description' and
* 'after-image' text blocks) in the same relative order they appear in the
* editor's Blocks list.
*/
function stepContentGroups(step) {
const all = stepBlocks(step);
const before = all.filter((b) => b.kind === 'text' && b.position === 'before-description');
const rest = all.filter((b) => !(b.kind === 'text' && b.position === 'before-description'));
return { before, rest };
}
function codeBlockText(block) {
return blockText(block);
}
@@ -67,6 +81,7 @@ module.exports = {
writeStepImages,
renderAllImages,
stepBlocks,
stepContentGroups,
codeBlockText,
LEVEL_LABEL,
};
+7 -11
View File
@@ -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');
+8 -9
View File
@@ -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,20 +112,19 @@ 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)) {
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(
@@ -132,7 +132,6 @@ function exportDocx(ast, outDir, template = {}) {
`<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"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
+11 -13
View File
@@ -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');
}
+8 -10
View File
@@ -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,8 +81,7 @@ 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)) {
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
@@ -92,7 +91,6 @@ function emitBlocks(lines, step, position) {
const body = htmlToMarkdown(tb.descriptionHtml);
if (body) lines.push(`> ${body.replace(/\n/g, '\n> ')}`);
lines.push('');
}
}
module.exports = { exportMarkdown, DEFAULT_TEMPLATE, anchorFor };
+8 -9
View File
@@ -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,13 +258,10 @@ 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)) {
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)
@@ -294,7 +294,6 @@ function exportPdf(ast, outDir, template = {}) {
}
y += blockH + 6;
}
}
fs.mkdirSync(outDir, { recursive: true });
const file = path.join(outDir, `${guideSlug(ast)}.pdf`);