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(`
`);
}
- 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) => `| ${escapeHtml(c)} | `).join('') + '
'
- + rest.map((r) => '' + r.map((c) => `| ${escapeHtml(c)} | `).join('') + '
').join('')
+ + bodyRows.map((r) => '' + r.map((c) => `| ${escapeHtml(c)} | `).join('') + '
').join('')
+ '
');
}
}
- 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 });