From 5d4925dee486f55e345f3b6dd1e27f4b352f0e70 Mon Sep 17 00:00:00 2001 From: Twest2 Date: Sun, 14 Jun 2026 09:11:00 -0500 Subject: [PATCH] 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 = [``, `

${escapeXml(step.number)}. ${escapeXml(step.title || 'Untitled step')}

`]; if (step.skipped) parts.push('

(skipped)

'); - 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(`

`); } - 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 ? `${escapeXml(block.language)}` : ''; parts.push(`${lang}${cdata(codeBlockText(block))}`); } 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 `
${parts.join('\n')}
`; }).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 ? '' : '')); - 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' }), '')); } 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 }) : ''), - `` - )); - } + 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 }) : ''), + `` + )); } const documentXml = ` 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) => `
${escapeHtml(LEVEL_LABEL[tb.level] || 'Note')}${tb.title ? `: ${escapeHtml(tb.title)}` : ''}${tb.descriptionHtml ? `
${tb.descriptionHtml}
` : ''}
`) - .join('\n'); +function blockHtml(tb) { + return `
${escapeHtml(LEVEL_LABEL[tb.level] || 'Note')}${tb.title ? `: ${escapeHtml(tb.title)}` : ''}${tb.descriptionHtml ? `
${tb.descriptionHtml}
` : ''}
`; } 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(`
${stepLinkRewrite(step.descriptionHtml, ast)}
`); const img = images.get(step.stepId); if (img && tpl.includeImages) { parts.push(`Step ${escapeHtml(step.number)}`); } - 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(`
${escapeHtml(codeBlockText(block))}
`); } else if (block.kind === 'table') { if (!block.rows || !block.rows.length) continue; - const [head, ...rest] = block.rows; + const [head, ...bodyRows] = block.rows; parts.push('' + head.map((c) => ``).join('') + '' - + rest.map((r) => '' + r.map((c) => ``).join('') + '').join('') + + bodyRows.map((r) => '' + r.map((c) => ``).join('') + '').join('') + '
${escapeHtml(c)}
${escapeHtml(c)}
${escapeHtml(c)}
'); } } - 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 });