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 });