Fix guide editor issues 4-10
This commit is contained in:
+11
-6
@@ -184,6 +184,7 @@ class CaptureService {
|
||||
// the user explicitly presses "Start recording" in the capture bar, so
|
||||
// New Capture never makes the window vanish out from under them.
|
||||
this.session = { guideId, paused: true, count: 0, intervalSec: interval };
|
||||
this.sessionNotificationShown = false;
|
||||
if (this.settings.get('capture.captureOutsideClicks') !== false) this.startClickWatcher();
|
||||
this.applyInterval();
|
||||
this.notify('capture:state', this.state());
|
||||
@@ -196,12 +197,6 @@ class CaptureService {
|
||||
// up — that's what `togglePause` uses to decide whether to tuck the
|
||||
// app away once the user actually starts recording.
|
||||
this.hiddenForSession = Boolean(win && !win.isDestroyed() && win.isVisible());
|
||||
try {
|
||||
new Notification({
|
||||
title: 'StepForge is ready to capture',
|
||||
body: 'Click "Start recording" in the red capture bar when you’re ready. The window tucks away and the red tray icon takes over.',
|
||||
}).show();
|
||||
} catch { /* notifications unavailable on this desktop */ }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,6 +382,15 @@ class CaptureService {
|
||||
await new Promise((r) => setTimeout(r, Number.isFinite(settleMs) ? settleMs : 150));
|
||||
}
|
||||
// Window hidden and buffer primed — clicks now count.
|
||||
if (!process.env.STEPFORGE_SCREENSHOT && !this.sessionNotificationShown) {
|
||||
try {
|
||||
new Notification({
|
||||
title: 'StepForge is recording',
|
||||
body: 'Use the red tray icon to pause or finish capture.',
|
||||
}).show();
|
||||
this.sessionNotificationShown = true;
|
||||
} catch { /* notifications unavailable on this desktop */ }
|
||||
}
|
||||
this.warmingUp = false;
|
||||
};
|
||||
run().catch(() => { this.warmingUp = false; });
|
||||
@@ -403,6 +407,7 @@ class CaptureService {
|
||||
this.stopClickFrameBackend();
|
||||
this.destroySessionTray();
|
||||
this.session = null;
|
||||
this.sessionNotificationShown = false;
|
||||
if (this.hiddenForSession) {
|
||||
this.hiddenForSession = false;
|
||||
this.showWindow();
|
||||
|
||||
+3
-2
@@ -424,8 +424,8 @@ function setupIpc() {
|
||||
const p = store.stepImagePath(guideId, stepId, which || 'working');
|
||||
return p && fs.existsSync(p) ? `file://${p}?v=${fs.statSync(p).mtimeMs}` : null;
|
||||
});
|
||||
h('step:setWorkingImage', ({ guideId, stepId, pngBase64, size }) =>
|
||||
store.setWorkingImage(guideId, stepId, Buffer.from(pngBase64, 'base64'), size));
|
||||
h('step:setWorkingImage', ({ guideId, stepId, pngBase64, size, step }) =>
|
||||
store.setWorkingImage(guideId, stepId, Buffer.from(pngBase64, 'base64'), size, step || null));
|
||||
h('step:resetWorkingImage', ({ guideId, stepId }) => {
|
||||
const p = store.stepImagePath(guideId, stepId, 'original');
|
||||
const img = nativeImage.createFromPath(p);
|
||||
@@ -579,6 +579,7 @@ function setupIpc() {
|
||||
markdown: '../exporters/markdown',
|
||||
'html-simple': '../exporters/html',
|
||||
'html-rich': '../exporters/html',
|
||||
confluence: '../exporters/confluence',
|
||||
pdf: '../exporters/pdf',
|
||||
gif: '../exporters/gif',
|
||||
'image-bundle': '../exporters/image-bundle',
|
||||
|
||||
+9
-5
@@ -611,6 +611,7 @@ class StepForgeApp {
|
||||
const moveItems = folderItems.length ? ['sep', ...folderItems] : [];
|
||||
contextMenu(event.clientX, event.clientY, [
|
||||
{ label: 'Open guide', action: () => this.openGuideAndArmCapture(guide.guideId) },
|
||||
{ label: 'Rename guide…', action: () => this.renameGuide(guide) },
|
||||
{ label: guide.favorite ? 'Unfavorite' : 'Favorite', action: () => this.toggleFavorite(guide) },
|
||||
{ label: 'Duplicate guide', action: () => this.duplicateGuide(guide.guideId) },
|
||||
{ label: 'Export', action: () => this.openGuideExport(guide.guideId) },
|
||||
@@ -843,15 +844,18 @@ class StepForgeApp {
|
||||
}
|
||||
}
|
||||
|
||||
async renameGuide() {
|
||||
const guide = this.editorMeta?.guide;
|
||||
async renameGuide(guide = this.editorMeta?.guide) {
|
||||
if (!guide) return;
|
||||
const title = await dialogs.promptText({ title: 'Rename guide', label: 'Title', value: guide.title });
|
||||
if (title == null || !title.trim()) return;
|
||||
guide.title = title.trim();
|
||||
await api.guide.save({ guide });
|
||||
await this.editor.reload(this.editor.selectedStepId);
|
||||
const fullGuide = (await api.guide.get({ guideId: guide.guideId })).guide;
|
||||
fullGuide.title = title.trim();
|
||||
await api.guide.save({ guide: fullGuide });
|
||||
if (this.state.view === 'editor' && this.editor.guideId === fullGuide.guideId) {
|
||||
await this.editor.reload(this.editor.selectedStepId);
|
||||
}
|
||||
await this.refreshLibrary();
|
||||
if (this.state.view === 'editor') this.renderTopbar();
|
||||
}
|
||||
|
||||
async importArchive(mode = 'copy') {
|
||||
|
||||
+187
-80
@@ -6,6 +6,35 @@ const api = window.stepforge;
|
||||
const dialogs = window.StepForgeDialogs || {};
|
||||
|
||||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||||
const BLOCK_KIND_ORDER = { text: 0, code: 1, table: 2 };
|
||||
|
||||
function blockText(block) {
|
||||
for (const key of ['code', 'text', 'body', 'value', 'content']) {
|
||||
const value = block && block[key];
|
||||
if (value != null && value !== '') return String(value);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function orderedStepBlocks(step) {
|
||||
const blocks = [];
|
||||
for (const tb of step.textBlocks || []) blocks.push({ kind: 'text', block: tb });
|
||||
for (const cb of step.codeBlocks || []) blocks.push({ kind: 'code', block: cb });
|
||||
for (const tbl of step.tableBlocks || []) blocks.push({ kind: 'table', block: tbl });
|
||||
return blocks.sort((a, b) => (
|
||||
(Number.isFinite(a.block.order) ? a.block.order : 0) - (Number.isFinite(b.block.order) ? b.block.order : 0)
|
||||
|| BLOCK_KIND_ORDER[a.kind] - BLOCK_KIND_ORDER[b.kind]
|
||||
|| String(a.block.id || '').localeCompare(String(b.block.id || ''))
|
||||
));
|
||||
}
|
||||
|
||||
function nextBlockOrder(step) {
|
||||
return orderedStepBlocks(step).reduce((max, entry) => Math.max(max, Number.isFinite(entry.block.order) ? entry.block.order : 0), 0) + 1;
|
||||
}
|
||||
|
||||
function blockLabel(kind) {
|
||||
return kind === 'text' ? 'Text block' : kind === 'code' ? 'Code block' : 'Table';
|
||||
}
|
||||
|
||||
function stepNumberMap(steps) {
|
||||
const numbers = new Map();
|
||||
@@ -57,6 +86,7 @@ class GuideEditor {
|
||||
this.canvasHistory = [];
|
||||
this.canvasFuture = [];
|
||||
this.beforeCanvasSnapshot = null;
|
||||
this.draggedBlock = null;
|
||||
this.stepLoadToken = 0;
|
||||
this.imageLoadToken = 0;
|
||||
this.shellMounted = false;
|
||||
@@ -265,7 +295,7 @@ class GuideEditor {
|
||||
// canvas interactions need to snapshot the current step before the drag
|
||||
// mutates it, so undo can restore the pre-edit annotations.
|
||||
this.dom.canvas.addEventListener('pointerdown', () => {
|
||||
if (this.currentStep) this.beforeCanvasSnapshot = clone(this.currentStep);
|
||||
if (this.currentStep) this.beforeCanvasSnapshot = { step: clone(this.currentStep) };
|
||||
}, true);
|
||||
|
||||
this.canvas = new AnnotationCanvas(this.dom.canvas, {
|
||||
@@ -434,13 +464,13 @@ class GuideEditor {
|
||||
const id = `blk-${Date.now().toString(36)}`;
|
||||
if (kind === 'text') {
|
||||
step.textBlocks = step.textBlocks || [];
|
||||
step.textBlocks.push({ id, position: 'after-description', level: 'info', title: '', descriptionHtml: '' });
|
||||
step.textBlocks.push({ id, order: nextBlockOrder(step), position: 'after-description', level: 'info', title: '', descriptionHtml: '' });
|
||||
} else if (kind === 'code') {
|
||||
step.codeBlocks = step.codeBlocks || [];
|
||||
step.codeBlocks.push({ id, language: '', code: '' });
|
||||
step.codeBlocks.push({ id, order: nextBlockOrder(step), language: '', code: '' });
|
||||
} else if (kind === 'table') {
|
||||
step.tableBlocks = step.tableBlocks || [];
|
||||
step.tableBlocks.push({ id, rows: [['Column A', 'Column B'], ['', '']] });
|
||||
step.tableBlocks.push({ id, order: nextBlockOrder(step), rows: [['Column A', 'Column B'], ['', '']] });
|
||||
}
|
||||
this.pendingSave = true;
|
||||
this.saveStepDebounced();
|
||||
@@ -458,73 +488,114 @@ class GuideEditor {
|
||||
this.pendingSave = true;
|
||||
this.saveStepDebounced();
|
||||
};
|
||||
const moveBlock = (source, target) => {
|
||||
if (!source || !target || source.kind === target.kind && source.block.id === target.block.id) return;
|
||||
const swap = source.block.order;
|
||||
source.block.order = target.block.order;
|
||||
target.block.order = swap;
|
||||
save();
|
||||
this.renderBlocksPanel();
|
||||
};
|
||||
const removeBtn = (onRemove) => el('button.icon.danger', {
|
||||
type: 'button', title: 'Remove block',
|
||||
onClick: () => { onRemove(); save(); this.renderBlocksPanel(); },
|
||||
}, '✕');
|
||||
|
||||
for (const tb of step.textBlocks || []) {
|
||||
const position = makeSelect(tb.position, [
|
||||
{ value: 'before-title', label: 'Before title' },
|
||||
{ value: 'after-title', label: 'After title' },
|
||||
{ value: 'before-image', label: 'Before image' },
|
||||
{ value: 'after-image', label: 'After image' },
|
||||
{ value: 'before-description', label: 'Before description' },
|
||||
{ value: 'after-description', label: 'After description' },
|
||||
]);
|
||||
const level = makeSelect(tb.level, [
|
||||
{ value: 'info', label: 'Note' },
|
||||
{ value: 'warn', label: 'Warning' },
|
||||
{ value: 'error', label: 'Important' },
|
||||
{ value: 'success', label: 'Tip' },
|
||||
]);
|
||||
const title = el('input', { type: 'text', value: tb.title || '', placeholder: 'Block title' });
|
||||
const body = el('textarea', { rows: 2, placeholder: 'Block text' });
|
||||
body.value = (tb.descriptionHtml || '').replace(/<[^>]+>/g, '');
|
||||
position.addEventListener('change', () => { tb.position = position.value; save(); });
|
||||
level.addEventListener('change', () => { tb.level = level.value; save(); });
|
||||
title.addEventListener('input', () => { tb.title = title.value; save(); });
|
||||
body.addEventListener('input', () => { tb.descriptionHtml = `<p>${escapeHtml(body.value)}</p>`; save(); });
|
||||
this.dom.blocksList.append(el('div.block-card', {},
|
||||
el('div.row', {}, el('strong', {}, 'Text block'), el('span.spacer'),
|
||||
removeBtn(() => { step.textBlocks = step.textBlocks.filter((b) => b !== tb); })),
|
||||
el('div.row', {}, level, position),
|
||||
title, body,
|
||||
));
|
||||
const blocks = orderedStepBlocks(step);
|
||||
for (const [index, entry] of blocks.entries()) {
|
||||
const { kind, block } = entry;
|
||||
const canMoveUp = index > 0;
|
||||
const canMoveDown = index < blocks.length - 1;
|
||||
const moveUp = () => moveBlock(entry, blocks[index - 1]);
|
||||
const moveDown = () => moveBlock(entry, blocks[index + 1]);
|
||||
|
||||
const header = el('div.row', {},
|
||||
el('strong', {}, blockLabel(kind)),
|
||||
el('span.muted', {}, `#${Number.isFinite(block.order) ? block.order : index + 1}`),
|
||||
el('span.spacer'),
|
||||
el('button.icon', { type: 'button', title: 'Move block up', disabled: !canMoveUp, onClick: moveUp }, '↑'),
|
||||
el('button.icon', { type: 'button', title: 'Move block down', disabled: !canMoveDown, onClick: moveDown }, '↓'),
|
||||
removeBtn(() => {
|
||||
if (kind === 'text') step.textBlocks = (step.textBlocks || []).filter((b) => b !== block);
|
||||
else if (kind === 'code') step.codeBlocks = (step.codeBlocks || []).filter((b) => b !== block);
|
||||
else step.tableBlocks = (step.tableBlocks || []).filter((b) => b !== block);
|
||||
}),
|
||||
);
|
||||
|
||||
const card = el('div.block-card', {
|
||||
draggable: true,
|
||||
onDragStart: (e) => {
|
||||
this.draggedBlock = entry;
|
||||
if (e.dataTransfer) e.dataTransfer.effectAllowed = 'move';
|
||||
},
|
||||
onDragOver: (e) => {
|
||||
if (this.draggedBlock) e.preventDefault();
|
||||
},
|
||||
onDrop: (e) => {
|
||||
e.preventDefault();
|
||||
if (!this.draggedBlock) return;
|
||||
moveBlock(this.draggedBlock, entry);
|
||||
this.draggedBlock = null;
|
||||
},
|
||||
onDragEnd: () => {
|
||||
this.draggedBlock = null;
|
||||
},
|
||||
}, header);
|
||||
|
||||
if (kind === 'text') {
|
||||
const position = makeSelect(block.position, [
|
||||
{ value: 'before-title', label: 'Before title' },
|
||||
{ value: 'after-title', label: 'After title' },
|
||||
{ value: 'before-image', label: 'Before image' },
|
||||
{ value: 'after-image', label: 'After image' },
|
||||
{ value: 'before-description', label: 'Before description' },
|
||||
{ value: 'after-description', label: 'After description' },
|
||||
]);
|
||||
const level = makeSelect(block.level, [
|
||||
{ value: 'info', label: 'Note' },
|
||||
{ value: 'warn', label: 'Warning' },
|
||||
{ value: 'error', label: 'Important' },
|
||||
{ value: 'success', label: 'Tip' },
|
||||
]);
|
||||
const title = el('input', { type: 'text', value: block.title || '', placeholder: 'Block title' });
|
||||
const body = el('textarea', { rows: 2, placeholder: 'Block text' });
|
||||
body.value = (block.descriptionHtml || '').replace(/<[^>]+>/g, '');
|
||||
position.addEventListener('change', () => { block.position = position.value; save(); });
|
||||
level.addEventListener('change', () => { block.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(
|
||||
el('div.row', {}, level, position),
|
||||
title,
|
||||
body,
|
||||
);
|
||||
} else if (kind === 'code') {
|
||||
const lang = el('input', { type: 'text', value: block.language || '', placeholder: 'Language (e.g. bash)' });
|
||||
const code = el('textarea', { rows: 3, placeholder: 'Code', spellcheck: false });
|
||||
code.value = blockText(block);
|
||||
code.style.fontFamily = 'monospace';
|
||||
lang.addEventListener('input', () => { block.language = lang.value; save(); });
|
||||
code.addEventListener('input', () => { block.code = code.value; save(); });
|
||||
card.append(lang, code);
|
||||
} else if (kind === 'table') {
|
||||
const grid = el('textarea', { rows: 3, placeholder: 'One row per line, cells separated by |', spellcheck: false });
|
||||
grid.value = (block.rows || []).map((r) => r.join(' | ')).join('\n');
|
||||
grid.addEventListener('input', () => {
|
||||
block.rows = grid.value.split('\n').filter((l) => l.trim() !== '')
|
||||
.map((line) => line.split('|').map((c) => c.trim()));
|
||||
save();
|
||||
});
|
||||
card.append(
|
||||
el('div.muted', {}, 'First line is the header row.'),
|
||||
grid,
|
||||
);
|
||||
}
|
||||
|
||||
this.dom.blocksList.append(card);
|
||||
}
|
||||
|
||||
for (const cb of step.codeBlocks || []) {
|
||||
const lang = el('input', { type: 'text', value: cb.language || '', placeholder: 'Language (e.g. bash)' });
|
||||
const code = el('textarea', { rows: 3, placeholder: 'Code', spellcheck: false });
|
||||
code.value = cb.code || '';
|
||||
code.style.fontFamily = 'monospace';
|
||||
lang.addEventListener('input', () => { cb.language = lang.value; save(); });
|
||||
code.addEventListener('input', () => { cb.code = code.value; save(); });
|
||||
this.dom.blocksList.append(el('div.block-card', {},
|
||||
el('div.row', {}, el('strong', {}, 'Code block'), el('span.spacer'),
|
||||
removeBtn(() => { step.codeBlocks = step.codeBlocks.filter((b) => b !== cb); })),
|
||||
lang, code,
|
||||
));
|
||||
}
|
||||
|
||||
for (const tbl of step.tableBlocks || []) {
|
||||
const grid = el('textarea', { rows: 3, placeholder: 'One row per line, cells separated by |', spellcheck: false });
|
||||
grid.value = (tbl.rows || []).map((r) => r.join(' | ')).join('\n');
|
||||
grid.addEventListener('input', () => {
|
||||
tbl.rows = grid.value.split('\n').filter((l) => l.trim() !== '')
|
||||
.map((line) => line.split('|').map((c) => c.trim()));
|
||||
save();
|
||||
});
|
||||
this.dom.blocksList.append(el('div.block-card', {},
|
||||
el('div.row', {}, el('strong', {}, 'Table'), el('span.spacer'),
|
||||
removeBtn(() => { step.tableBlocks = step.tableBlocks.filter((b) => b !== tbl); })),
|
||||
el('div.muted', {}, 'First line is the header row.'),
|
||||
grid,
|
||||
));
|
||||
}
|
||||
|
||||
if (!(step.textBlocks || []).length && !(step.codeBlocks || []).length && !(step.tableBlocks || []).length) {
|
||||
this.dom.blocksList.append(el('div.muted', {}, 'Informational text, code, and table blocks render in every export.'));
|
||||
if (!blocks.length) {
|
||||
this.dom.blocksList.append(el('div.muted', {}, 'Informational text, code, and table blocks can be reordered with drag handles or arrows.'));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -906,27 +977,60 @@ class GuideEditor {
|
||||
if (mode === 1.5) this.dom.zoom150Btn.classList.add('active');
|
||||
}
|
||||
|
||||
pushCanvasHistory(label = 'change') {
|
||||
pushCanvasHistory(recordOrLabel = 'change') {
|
||||
if (!this.currentStep) return;
|
||||
this.canvasHistory.push(clone(this.currentStep));
|
||||
const record = recordOrLabel && typeof recordOrLabel === 'object' && recordOrLabel.step
|
||||
? recordOrLabel
|
||||
: { step: clone(this.currentStep) };
|
||||
this.canvasHistory.push(record);
|
||||
if (this.canvasHistory.length > 40) this.canvasHistory.shift();
|
||||
this.canvasFuture.length = 0;
|
||||
this.beforeCanvasSnapshot = null;
|
||||
}
|
||||
|
||||
async snapshotCurrentStep(includeImage = false) {
|
||||
if (!this.currentStep) return null;
|
||||
const record = { step: clone(this.currentStep) };
|
||||
if (includeImage && this.currentStep.image) {
|
||||
const image = await this.currentStepImageToBase64(this.currentStep);
|
||||
if (image) record.image = image;
|
||||
}
|
||||
return record;
|
||||
}
|
||||
|
||||
async restoreHistoryRecord(record) {
|
||||
if (!record || !record.step) return;
|
||||
const step = clone(record.step);
|
||||
this.selectedStepId = step.stepId;
|
||||
this.beforeCanvasSnapshot = null;
|
||||
this.saveStepDebounced.cancel();
|
||||
this.pendingSave = false;
|
||||
if (record.image && step.image) {
|
||||
const saved = await api.step.setWorkingImage({
|
||||
guideId: this.guideId,
|
||||
stepId: step.stepId,
|
||||
pngBase64: record.image.base64,
|
||||
size: record.image.size,
|
||||
step,
|
||||
});
|
||||
this.stepMap.set(saved.stepId, saved);
|
||||
const idx = this.steps.findIndex((s) => s.stepId === saved.stepId);
|
||||
if (idx >= 0) this.steps[idx] = saved;
|
||||
} else {
|
||||
await this.flushStep(step);
|
||||
}
|
||||
}
|
||||
|
||||
async undo() {
|
||||
if (!this.currentStep) return;
|
||||
if (!this.canvasHistory.length) {
|
||||
this.onToast('Nothing to undo.');
|
||||
return;
|
||||
}
|
||||
this.canvasFuture.push(clone(this.currentStep));
|
||||
const current = await this.snapshotCurrentStep(true);
|
||||
if (current) this.canvasFuture.push(current);
|
||||
const previous = this.canvasHistory.pop();
|
||||
this.stepMap.set(previous.stepId, previous);
|
||||
const prevIdx = this.steps.findIndex((s) => s.stepId === previous.stepId);
|
||||
if (prevIdx >= 0) this.steps[prevIdx] = previous;
|
||||
this.selectedStepId = previous.stepId;
|
||||
await this.flushStep(previous);
|
||||
await this.restoreHistoryRecord(previous);
|
||||
this.renderAll();
|
||||
}
|
||||
|
||||
@@ -936,13 +1040,10 @@ class GuideEditor {
|
||||
this.onToast('Nothing to redo.');
|
||||
return;
|
||||
}
|
||||
this.canvasHistory.push(clone(this.currentStep));
|
||||
const current = await this.snapshotCurrentStep(true);
|
||||
if (current) this.canvasHistory.push(current);
|
||||
const next = this.canvasFuture.pop();
|
||||
this.stepMap.set(next.stepId, next);
|
||||
const nextIdx = this.steps.findIndex((s) => s.stepId === next.stepId);
|
||||
if (nextIdx >= 0) this.steps[nextIdx] = next;
|
||||
this.selectedStepId = next.stepId;
|
||||
await this.flushStep(next);
|
||||
await this.restoreHistoryRecord(next);
|
||||
this.renderAll();
|
||||
}
|
||||
|
||||
@@ -1388,8 +1489,7 @@ class GuideEditor {
|
||||
}
|
||||
}
|
||||
|
||||
async currentStepImageToBase64() {
|
||||
const step = this.currentStep;
|
||||
async currentStepImageToBase64(step = this.currentStep) {
|
||||
if (!step || !step.image) return null;
|
||||
const file = await api.step.imagePath({ guideId: this.guideId, stepId: step.stepId, which: 'working' });
|
||||
if (!file) return null;
|
||||
@@ -1437,6 +1537,13 @@ class GuideEditor {
|
||||
if (!step || !step.image) return;
|
||||
const ok = await confirmDialog('Crop the working image to the selected area?');
|
||||
if (!ok) return;
|
||||
this.saveStepDebounced.cancel();
|
||||
const snapshot = this.beforeCanvasSnapshot || await this.snapshotCurrentStep(true);
|
||||
if (snapshot) {
|
||||
if (!snapshot.image) snapshot.image = await this.currentStepImageToBase64(step);
|
||||
this.pushCanvasHistory(snapshot);
|
||||
}
|
||||
this.beforeCanvasSnapshot = null;
|
||||
const src = await api.step.imagePath({ guideId: this.guideId, stepId: step.stepId, which: 'working' });
|
||||
if (!src) return;
|
||||
const img = await loadImage(src);
|
||||
|
||||
@@ -430,7 +430,18 @@ kbd {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.pane-props section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-bottom: 14px;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
|
||||
}
|
||||
.pane-props section + section { margin-top: 16px; }
|
||||
.pane-props section:last-child {
|
||||
padding-bottom: 0;
|
||||
border-bottom: 0;
|
||||
}
|
||||
.pane-props input[type="text"],
|
||||
.pane-props input[type="number"],
|
||||
.pane-props input[type="color"],
|
||||
@@ -511,6 +522,8 @@ kbd {
|
||||
padding: 10px;
|
||||
background: var(--panel-solid);
|
||||
}
|
||||
.block-card[draggable="true"] { cursor: grab; }
|
||||
.block-card[draggable="true"]:active { cursor: grabbing; }
|
||||
.annotation-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
'use strict';
|
||||
|
||||
const BLOCK_KIND_ORDER = { text: 0, code: 1, table: 2 };
|
||||
|
||||
function blockText(block) {
|
||||
if (!block || typeof block !== 'object') return '';
|
||||
for (const key of ['code', 'text', 'body', 'value', 'content']) {
|
||||
const value = block[key];
|
||||
if (value != null && value !== '') return String(value);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function orderedBlocks(step) {
|
||||
const blocks = [];
|
||||
for (const tb of step.textBlocks || []) {
|
||||
blocks.push({ kind: 'text', ...tb });
|
||||
}
|
||||
for (const cb of step.codeBlocks || []) {
|
||||
blocks.push({ kind: 'code', ...cb, code: blockText(cb) });
|
||||
}
|
||||
for (const tbl of step.tableBlocks || []) {
|
||||
blocks.push({ kind: 'table', ...tbl });
|
||||
}
|
||||
return blocks.sort((a, b) => (
|
||||
(Number.isFinite(a.order) ? a.order : 0) - (Number.isFinite(b.order) ? b.order : 0)
|
||||
|| (BLOCK_KIND_ORDER[a.kind] - BLOCK_KIND_ORDER[b.kind])
|
||||
|| String(a.id || '').localeCompare(String(b.id || ''))
|
||||
));
|
||||
}
|
||||
|
||||
function nextBlockOrder(step) {
|
||||
return orderedBlocks(step).reduce((max, block) => Math.max(max, Number.isFinite(block.order) ? block.order : 0), 0) + 1;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
BLOCK_KIND_ORDER,
|
||||
blockText,
|
||||
orderedBlocks,
|
||||
nextBlockOrder,
|
||||
};
|
||||
+19
-2
@@ -7,6 +7,7 @@ const { htmlToText, deepClone } = require('./util');
|
||||
const { systemPlaceholders, resolveScopes, expandPlaceholders } = require('./placeholders');
|
||||
const { decodePng } = require('./png');
|
||||
const { renderAnnotations, applyFocusedView } = require('./raster');
|
||||
const { orderedBlocks, blockText } = require('./blocks');
|
||||
|
||||
/**
|
||||
* The Render AST is the single normalized document model every exporter
|
||||
@@ -75,8 +76,24 @@ function buildRenderAst(store, guideId, { globals = {}, now = new Date(), maxSte
|
||||
descriptionHtml: sanitizeHtml(expand(tb.descriptionHtml || '')),
|
||||
descriptionText: htmlToText(expand(tb.descriptionHtml || '')),
|
||||
})),
|
||||
codeBlocks: step.codeBlocks || [],
|
||||
tableBlocks: step.tableBlocks || [],
|
||||
codeBlocks: (step.codeBlocks || []).map((cb) => ({ ...cb, code: blockText(cb) })),
|
||||
tableBlocks: (step.tableBlocks || []).map((tb) => ({
|
||||
...tb,
|
||||
rows: Array.isArray(tb.rows) ? tb.rows.map((row) => [...row]) : [],
|
||||
})),
|
||||
blocks: orderedBlocks(step).map((block) => {
|
||||
if (block.kind === 'text') {
|
||||
return {
|
||||
...block,
|
||||
title: expand(block.title || ''),
|
||||
descriptionHtml: sanitizeHtml(expand(block.descriptionHtml || '')),
|
||||
descriptionText: htmlToText(expand(block.descriptionHtml || '')),
|
||||
};
|
||||
}
|
||||
if (block.kind === 'code') return { ...block };
|
||||
if (block.kind === 'table') return { ...block };
|
||||
return { ...block };
|
||||
}),
|
||||
links: step.links || [],
|
||||
image: null,
|
||||
};
|
||||
|
||||
+34
-5
@@ -1,7 +1,8 @@
|
||||
'use strict';
|
||||
|
||||
const { newId, nowIso, deepClone } = require('./util');
|
||||
const { newId, nowIso } = require('./util');
|
||||
const { sanitizeHtml } = require('./sanitize');
|
||||
const { blockText } = require('./blocks');
|
||||
|
||||
const SCHEMA_VERSION = 1;
|
||||
|
||||
@@ -49,6 +50,12 @@ function createGuide(fields = {}) {
|
||||
}
|
||||
|
||||
function createStep(fields = {}) {
|
||||
let nextOrder = 1;
|
||||
const takeOrder = (block) => {
|
||||
const order = Number.isFinite(block && block.order) ? block.order : nextOrder;
|
||||
nextOrder = Math.max(nextOrder, order + 1);
|
||||
return order;
|
||||
};
|
||||
return {
|
||||
stepId: fields.stepId || newId('step'),
|
||||
parentStepId: fields.parentStepId || null,
|
||||
@@ -69,9 +76,9 @@ function createStep(fields = {}) {
|
||||
image: fields.image || null, // { originalPath, workingPath, size:{width,height} }
|
||||
extraImages: fields.extraImages || [], // multi-image steps
|
||||
annotations: (fields.annotations || []).map(normalizeAnnotation),
|
||||
textBlocks: (fields.textBlocks || []).map(normalizeTextBlock),
|
||||
codeBlocks: fields.codeBlocks || [], // { id, language, code }
|
||||
tableBlocks: fields.tableBlocks || [], // { id, rows: [[cellText,..],..], headerRow }
|
||||
textBlocks: (fields.textBlocks || []).map((tb) => normalizeTextBlock(tb, takeOrder(tb))),
|
||||
codeBlocks: (fields.codeBlocks || []).map((cb) => normalizeCodeBlock(cb, takeOrder(cb))),
|
||||
tableBlocks: (fields.tableBlocks || []).map((tb) => normalizeTableBlock(tb, takeOrder(tb))),
|
||||
links: fields.links || [], // { id, label, targetStepId }
|
||||
};
|
||||
}
|
||||
@@ -94,16 +101,36 @@ function normalizeAnnotation(a) {
|
||||
return ann;
|
||||
}
|
||||
|
||||
function normalizeTextBlock(tb) {
|
||||
function normalizeTextBlock(tb, order = null) {
|
||||
return {
|
||||
id: tb.id || newId('tb'),
|
||||
position: TEXTBLOCK_POSITIONS.includes(tb.position) ? tb.position : 'after-description',
|
||||
level: TEXTBLOCK_LEVELS.includes(tb.level) ? tb.level : 'info',
|
||||
order: Number.isFinite(tb.order) ? tb.order : order,
|
||||
title: tb.title || '',
|
||||
descriptionHtml: sanitizeHtml(tb.descriptionHtml || ''),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCodeBlock(cb, order = null) {
|
||||
return {
|
||||
id: cb.id || newId('cb'),
|
||||
order: Number.isFinite(cb.order) ? cb.order : order,
|
||||
language: typeof cb.language === 'string' ? cb.language : '',
|
||||
code: blockText(cb),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTableBlock(tb, order = null) {
|
||||
return {
|
||||
id: tb.id || newId('tbl'),
|
||||
order: Number.isFinite(tb.order) ? tb.order : order,
|
||||
rows: Array.isArray(tb.rows)
|
||||
? tb.rows.map((row) => (Array.isArray(row) ? row.map((cell) => String(cell ?? '')) : []))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function num(v, fallback) {
|
||||
return Number.isFinite(v) ? v : fallback;
|
||||
}
|
||||
@@ -173,6 +200,8 @@ module.exports = {
|
||||
createStep,
|
||||
normalizeAnnotation,
|
||||
normalizeTextBlock,
|
||||
normalizeCodeBlock,
|
||||
normalizeTableBlock,
|
||||
validateGuide,
|
||||
validateStep,
|
||||
normalizeGuide,
|
||||
|
||||
+2
-1
@@ -2,6 +2,7 @@
|
||||
|
||||
const path = require('node:path');
|
||||
const { writeJsonSync, readJsonIfExists, htmlToText } = require('./util');
|
||||
const { blockText } = require('./blocks');
|
||||
|
||||
/**
|
||||
* Local full-text search over guide titles, descriptions, step titles/
|
||||
@@ -57,7 +58,7 @@ class SearchIndex {
|
||||
const parts = [
|
||||
htmlToText(step.descriptionHtml),
|
||||
...(step.textBlocks || []).map((tb) => `${tb.title} ${htmlToText(tb.descriptionHtml)}`),
|
||||
...(step.codeBlocks || []).map((cb) => cb.code || ''),
|
||||
...(step.codeBlocks || []).map((cb) => blockText(cb)),
|
||||
...(step.annotations || []).map((a) => a.text || ''),
|
||||
];
|
||||
this.docs[`s:${guide.guideId}:${step.stepId}`] = {
|
||||
|
||||
+3
-4
@@ -221,9 +221,8 @@ class GuideStore {
|
||||
}
|
||||
|
||||
saveStep(guideId, step) {
|
||||
const stored = deepClone(step);
|
||||
const stored = normalizeStep(deepClone(step));
|
||||
stored.descriptionHtml = sanitizeHtml(stored.descriptionHtml);
|
||||
for (const tb of stored.textBlocks || []) tb.descriptionHtml = sanitizeHtml(tb.descriptionHtml);
|
||||
validateStep(stored);
|
||||
writeJsonSync(path.join(this.stepDir(guideId, step.stepId), 'step.json'), stored);
|
||||
const guide = this.getGuide(guideId);
|
||||
@@ -263,8 +262,8 @@ class GuideStore {
|
||||
}
|
||||
|
||||
/** Replace the working image (crop result). The original is never touched. */
|
||||
setWorkingImage(guideId, stepId, pngBuffer, size) {
|
||||
const step = this.getStep(guideId, stepId);
|
||||
setWorkingImage(guideId, stepId, pngBuffer, size, stepPatch = null) {
|
||||
const step = stepPatch ? deepClone(stepPatch) : this.getStep(guideId, stepId);
|
||||
if (!step.image) throw new Error('step has no image');
|
||||
atomicWriteFileSync(path.join(this.stepDir(guideId, stepId), step.image.workingPath), pngBuffer);
|
||||
step.image.size = size;
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ const { writeJsonSync, readJsonSync, atomicWriteFileSync, nowIso } = require('./
|
||||
* defaults, shareable as .sfglt zip files.
|
||||
*/
|
||||
|
||||
const FORMATS = ['json', 'markdown', 'html-simple', 'html-rich', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx'];
|
||||
const FORMATS = ['json', 'markdown', 'html-simple', 'html-rich', 'confluence', 'pdf', 'gif', 'image-bundle', 'docx', 'pptx'];
|
||||
|
||||
class TemplateManager {
|
||||
constructor(templatesDir) {
|
||||
|
||||
+18
-1
@@ -5,6 +5,7 @@ const path = require('node:path');
|
||||
const { slugify } = require('../core/util');
|
||||
const { encodePng } = require('../core/png');
|
||||
const { renderStepImage } = require('../core/renderast');
|
||||
const { orderedBlocks, blockText } = require('../core/blocks');
|
||||
|
||||
/**
|
||||
* Shared exporter helpers: every image-bearing exporter renders annotated
|
||||
@@ -50,6 +51,22 @@ function renderAllImages(ast) {
|
||||
return result;
|
||||
}
|
||||
|
||||
function stepBlocks(step) {
|
||||
return step.blocks || orderedBlocks(step);
|
||||
}
|
||||
|
||||
function codeBlockText(block) {
|
||||
return blockText(block);
|
||||
}
|
||||
|
||||
const LEVEL_LABEL = { info: 'Note', warn: 'Warning', error: 'Important', success: 'Tip' };
|
||||
|
||||
module.exports = { guideSlug, imagesDirName, writeStepImages, renderAllImages, LEVEL_LABEL };
|
||||
module.exports = {
|
||||
guideSlug,
|
||||
imagesDirName,
|
||||
writeStepImages,
|
||||
renderAllImages,
|
||||
stepBlocks,
|
||||
codeBlockText,
|
||||
LEVEL_LABEL,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
'use strict';
|
||||
|
||||
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');
|
||||
|
||||
/**
|
||||
* Confluence storage-format export. Writes a single XHTML document plus a
|
||||
* sidecar attachments folder containing the rendered screenshots referenced
|
||||
* by the page.
|
||||
*/
|
||||
|
||||
const DEFAULT_TEMPLATE = {
|
||||
includeImages: true,
|
||||
};
|
||||
|
||||
const MACRO_FOR_LEVEL = {
|
||||
info: 'info',
|
||||
warn: 'warning',
|
||||
error: 'note',
|
||||
success: 'tip',
|
||||
};
|
||||
|
||||
function anchorFor(step) {
|
||||
return `step-${step.number.replace(/\./g, '-')}`;
|
||||
}
|
||||
|
||||
function stepLinkRewrite(html, ast) {
|
||||
return String(html || '').replace(/href="step:([^"]+)"/g, (m, id) => {
|
||||
const target = ast.steps.find((s) => s.stepId === id);
|
||||
return target ? `href="#${anchorFor(target)}"` : 'data-missing-step-link="true"';
|
||||
});
|
||||
}
|
||||
|
||||
function cdata(text) {
|
||||
return `<![CDATA[${String(text || '').replace(/]]>/g, ']]]]><![CDATA[>')}]]>`;
|
||||
}
|
||||
|
||||
function blockMacro(tb, ast) {
|
||||
const macro = MACRO_FOR_LEVEL[tb.level] || 'note';
|
||||
const title = tb.title ? `<ac:parameter ac:name="title">${escapeXml(tb.title)}</ac:parameter>` : '';
|
||||
const body = tb.descriptionHtml ? `<div>${stepLinkRewrite(tb.descriptionHtml, ast)}</div>` : '<p />';
|
||||
return `<ac:structured-macro ac:name="${macro}">${title}<ac:rich-text-body>${body}</ac:rich-text-body></ac:structured-macro>`;
|
||||
}
|
||||
|
||||
function exportConfluence(ast, outDir, template = {}) {
|
||||
const tpl = { ...DEFAULT_TEMPLATE, ...template };
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
const images = tpl.includeImages ? renderAllImages(ast) : new Map();
|
||||
const attachmentDir = path.join(outDir, `${guideSlug(ast)}-attachments`);
|
||||
fs.mkdirSync(attachmentDir, { recursive: true });
|
||||
|
||||
let attachmentCount = 0;
|
||||
const attachmentNames = new Map();
|
||||
for (const step of ast.steps) {
|
||||
const img = images.get(step.stepId);
|
||||
if (!img) continue;
|
||||
attachmentCount += 1;
|
||||
const fileName = `${String(attachmentCount).padStart(3, '0')}-${slugify(step.title || step.stepId, step.stepId)}.png`;
|
||||
fs.writeFileSync(path.join(attachmentDir, fileName), encodePng(img));
|
||||
attachmentNames.set(step.stepId, fileName);
|
||||
}
|
||||
|
||||
const stepXml = ast.steps.map((step) => {
|
||||
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')) {
|
||||
parts.push(blockMacro(tb, ast));
|
||||
}
|
||||
|
||||
if (step.descriptionHtml) {
|
||||
parts.push(`<div>${stepLinkRewrite(step.descriptionHtml, ast)}</div>`);
|
||||
}
|
||||
|
||||
const attachment = attachmentNames.get(step.stepId);
|
||||
if (attachment) {
|
||||
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') {
|
||||
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') {
|
||||
if (!block.rows || !block.rows.length) continue;
|
||||
const width = Math.max(...block.rows.map((row) => row.length));
|
||||
const rows = block.rows.map((row, rowIndex) => (
|
||||
`<tr>${Array.from({ length: width }, (_, i) => {
|
||||
const cell = escapeXml(row[i] ?? '');
|
||||
return rowIndex === 0 ? `<th>${cell}</th>` : `<td>${cell}</td>`;
|
||||
}).join('')}</tr>`
|
||||
));
|
||||
parts.push(`<table><tbody>${rows.join('')}</tbody></table>`);
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
|
||||
const html = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml"
|
||||
xmlns:ac="http://atlassian.com/content"
|
||||
xmlns:ri="http://atlassian.com/resource/identifier">
|
||||
<head>
|
||||
<title>${escapeXml(ast.guide.title)}</title>
|
||||
</head>
|
||||
<body>
|
||||
<h1>${escapeXml(ast.guide.title)}</h1>
|
||||
${ast.guide.descriptionHtml ? `<div>${stepLinkRewrite(ast.guide.descriptionHtml, ast)}</div>` : ''}
|
||||
${stepXml}
|
||||
</body>
|
||||
</html>
|
||||
`;
|
||||
|
||||
const file = path.join(outDir, `${guideSlug(ast)}.confluence.xml`);
|
||||
fs.writeFileSync(file, html);
|
||||
return { file, attachmentCount: images.size };
|
||||
}
|
||||
|
||||
module.exports = { exportConfluence, DEFAULT_TEMPLATE };
|
||||
+9
-8
@@ -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 } = require('./common');
|
||||
const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common');
|
||||
|
||||
/**
|
||||
* DOCX exporter: WordprocessingML built directly (no dependency), one
|
||||
@@ -102,19 +102,20 @@ function exportDocx(ast, outDir, template = {}) {
|
||||
body.push(p(drawing(relCounter, img.width, img.height, tpl.imageWidthTwips)));
|
||||
}
|
||||
|
||||
for (const cb of step.codeBlocks) {
|
||||
body.push(p(run(cb.code || '', { size: 18, font: 'Courier New', color: '1F2937' }),
|
||||
'<w:shd w:val="clear" w:fill="F3F4F6"/>'));
|
||||
}
|
||||
for (const tb of step.tableBlocks || []) {
|
||||
if (tb.rows && tb.rows.length) body.push(table(tb.rows), p(''));
|
||||
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) {
|
||||
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 step.textBlocks.filter((b) => b.position === 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}` : ''}`;
|
||||
body.push(p(
|
||||
run(label, { bold: true, size: 20 }) + (tb.descriptionText ? run('\n' + tb.descriptionText, { size: 20 }) : ''),
|
||||
|
||||
+12
-11
@@ -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 } = require('./common');
|
||||
const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common');
|
||||
|
||||
/**
|
||||
* HTML exporters. Both variants are fully self-contained single files:
|
||||
@@ -39,7 +39,7 @@ function stepLinkRewrite(html, ast) {
|
||||
}
|
||||
|
||||
function blocksHtml(step, position) {
|
||||
return step.textBlocks
|
||||
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');
|
||||
@@ -53,15 +53,16 @@ function stepBodyHtml(step, ast, images, tpl) {
|
||||
if (img && tpl.includeImages) {
|
||||
parts.push(`<img class="shot" alt="Step ${escapeHtml(step.number)}" src="${dataUri(img)}" width="${img.width}">`);
|
||||
}
|
||||
for (const cb of step.codeBlocks) {
|
||||
parts.push(`<pre class="code"><code>${escapeHtml(cb.code || '')}</code></pre>`);
|
||||
}
|
||||
for (const tb of step.tableBlocks || []) {
|
||||
if (!tb.rows || !tb.rows.length) continue;
|
||||
const [head, ...rest] = tb.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('')
|
||||
+ '</tbody></table>');
|
||||
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) {
|
||||
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;
|
||||
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('')
|
||||
+ '</tbody></table>');
|
||||
}
|
||||
}
|
||||
parts.push(blocksHtml(step, 'after-description'));
|
||||
parts.push(blocksHtml(step, 'after-image'));
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
const { exportJson } = require('./json');
|
||||
const { exportMarkdown } = require('./markdown');
|
||||
const { exportHtmlSimple, exportHtmlRich } = require('./html');
|
||||
const { exportConfluence } = require('./confluence');
|
||||
const { exportPdf } = require('./pdf');
|
||||
const { exportGifGuide } = require('./gif');
|
||||
const { exportImageBundle } = require('./image-bundle');
|
||||
@@ -15,6 +16,7 @@ const EXPORTERS = {
|
||||
markdown: exportMarkdown,
|
||||
'html-simple': exportHtmlSimple,
|
||||
'html-rich': exportHtmlRich,
|
||||
confluence: exportConfluence,
|
||||
pdf: exportPdf,
|
||||
gif: exportGifGuide,
|
||||
'image-bundle': exportImageBundle,
|
||||
|
||||
+9
-2
@@ -2,7 +2,7 @@
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { guideSlug, writeStepImages } = require('./common');
|
||||
const { guideSlug, writeStepImages, stepBlocks, codeBlockText } = require('./common');
|
||||
|
||||
/**
|
||||
* JSON exporter: structured guide + steps, annotated screenshots written to
|
||||
@@ -42,8 +42,15 @@ function exportJson(ast, outDir, template = {}) {
|
||||
textBlocks: step.textBlocks.map((tb) => ({
|
||||
position: tb.position, level: tb.level, title: tb.title, descriptionHtml: tb.descriptionHtml,
|
||||
})),
|
||||
codeBlocks: step.codeBlocks,
|
||||
codeBlocks: step.codeBlocks.map((cb) => ({ ...cb, code: codeBlockText(cb) })),
|
||||
tableBlocks: step.tableBlocks,
|
||||
blocks: stepBlocks(step).map((block) => (
|
||||
block.kind === 'text'
|
||||
? { ...block }
|
||||
: block.kind === 'code'
|
||||
? { ...block, code: codeBlockText(block) }
|
||||
: { ...block }
|
||||
)),
|
||||
links: step.links,
|
||||
})),
|
||||
};
|
||||
|
||||
+14
-13
@@ -2,7 +2,7 @@
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { guideSlug, writeStepImages, LEVEL_LABEL } = require('./common');
|
||||
const { guideSlug, writeStepImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common');
|
||||
const { htmlToMarkdown } = require('./htmlmd');
|
||||
|
||||
/**
|
||||
@@ -58,17 +58,18 @@ function exportMarkdown(ast, outDir, template = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
for (const cb of step.codeBlocks) {
|
||||
lines.push(`\`\`\`${cb.language || ''}`, cb.code || '', '```', '');
|
||||
}
|
||||
for (const tb of step.tableBlocks || []) {
|
||||
if (!tb.rows || !tb.rows.length) continue;
|
||||
const width = Math.max(...tb.rows.map((r) => r.length));
|
||||
const pad = (r) => { const c = [...r]; while (c.length < width) c.push(''); return c; };
|
||||
lines.push(`| ${pad(tb.rows[0]).join(' | ')} |`);
|
||||
lines.push(`|${' --- |'.repeat(width)}`);
|
||||
for (const row of tb.rows.slice(1)) lines.push(`| ${pad(row).join(' | ')} |`);
|
||||
lines.push('');
|
||||
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) {
|
||||
if (block.kind === 'code') {
|
||||
lines.push(`\`\`\`${block.language || ''}`, codeBlockText(block), '```', '');
|
||||
} else if (block.kind === 'table') {
|
||||
if (!block.rows || !block.rows.length) continue;
|
||||
const width = Math.max(...block.rows.map((r) => r.length));
|
||||
const pad = (r) => { const c = [...r]; while (c.length < width) c.push(''); return c; };
|
||||
lines.push(`| ${pad(block.rows[0]).join(' | ')} |`);
|
||||
lines.push(`|${' --- |'.repeat(width)}`);
|
||||
for (const row of block.rows.slice(1)) lines.push(`| ${pad(row).join(' | ')} |`);
|
||||
lines.push('');
|
||||
}
|
||||
}
|
||||
|
||||
emitBlocks(lines, step, 'after-description');
|
||||
@@ -81,7 +82,7 @@ function exportMarkdown(ast, outDir, template = {}) {
|
||||
}
|
||||
|
||||
function emitBlocks(lines, step, position) {
|
||||
for (const tb of step.textBlocks.filter((b) => b.position === 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}` : ''}**`);
|
||||
const body = htmlToMarkdown(tb.descriptionHtml);
|
||||
|
||||
+32
-32
@@ -3,7 +3,7 @@
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { PdfBuilder } = require('../core/pdf');
|
||||
const { guideSlug, renderAllImages, LEVEL_LABEL } = require('./common');
|
||||
const { guideSlug, renderAllImages, LEVEL_LABEL, stepBlocks, codeBlockText } = require('./common');
|
||||
const { htmlToText } = require('../core/util');
|
||||
|
||||
/**
|
||||
@@ -104,38 +104,38 @@ function exportPdf(ast, outDir, template = {}) {
|
||||
y += h + 10;
|
||||
}
|
||||
|
||||
for (const cb of step.codeBlocks) {
|
||||
const lines = String(cb.code || '').split('\n');
|
||||
const lineH = 9 * 1.3;
|
||||
ensure(Math.min(lines.length, 4) * lineH + 12);
|
||||
const boxH = lines.length * lineH + 10;
|
||||
pdf.rect(M, y, usableW, Math.min(boxH, size.height - M - y), { fill: [243, 244, 246] });
|
||||
y += 6;
|
||||
for (const line of lines) {
|
||||
ensure(lineH);
|
||||
pdf.text(line.slice(0, 95), M + 8, y, { size: 9, font: 'F3', color: [31, 41, 55] });
|
||||
y += lineH;
|
||||
}
|
||||
y += 10;
|
||||
}
|
||||
|
||||
for (const tb of step.tableBlocks || []) {
|
||||
if (!tb.rows || !tb.rows.length) continue;
|
||||
const cols = Math.max(...tb.rows.map((r) => r.length));
|
||||
const colW = usableW / cols;
|
||||
for (let r = 0; r < tb.rows.length; r++) {
|
||||
const rowH = 16;
|
||||
ensure(rowH + 2);
|
||||
if (r === 0) pdf.rect(M, y, usableW, rowH, { fill: [238, 240, 244] });
|
||||
pdf.rect(M, y, usableW, rowH, { stroke: [200, 204, 210], lineWidth: 0.6 });
|
||||
for (let c = 0; c < cols; c++) {
|
||||
pdf.text(String(tb.rows[r][c] ?? '').slice(0, Math.floor(colW / 5)), M + c * colW + 4, y + 3, {
|
||||
size: 9, font: r === 0 ? 'F2' : 'F1',
|
||||
});
|
||||
for (const block of stepBlocks(step).filter((item) => item.kind !== 'text')) {
|
||||
if (block.kind === 'code') {
|
||||
const lines = String(codeBlockText(block) || '').split('\n');
|
||||
const lineH = 9 * 1.3;
|
||||
ensure(Math.min(lines.length, 4) * lineH + 12);
|
||||
const boxH = lines.length * lineH + 10;
|
||||
pdf.rect(M, y, usableW, Math.min(boxH, size.height - M - y), { fill: [243, 244, 246] });
|
||||
y += 6;
|
||||
for (const line of lines) {
|
||||
ensure(lineH);
|
||||
pdf.text(line.slice(0, 95), M + 8, y, { size: 9, font: 'F3', color: [31, 41, 55] });
|
||||
y += lineH;
|
||||
}
|
||||
y += rowH;
|
||||
y += 10;
|
||||
} else if (block.kind === 'table') {
|
||||
if (!block.rows || !block.rows.length) continue;
|
||||
const cols = Math.max(...block.rows.map((r) => r.length));
|
||||
const colW = usableW / cols;
|
||||
for (let r = 0; r < block.rows.length; r++) {
|
||||
const rowH = 16;
|
||||
ensure(rowH + 2);
|
||||
if (r === 0) pdf.rect(M, y, usableW, rowH, { fill: [238, 240, 244] });
|
||||
pdf.rect(M, y, usableW, rowH, { stroke: [200, 204, 210], lineWidth: 0.6 });
|
||||
for (let c = 0; c < cols; c++) {
|
||||
pdf.text(String(block.rows[r][c] ?? '').slice(0, Math.floor(colW / 5)), M + c * colW + 4, y + 3, {
|
||||
size: 9, font: r === 0 ? 'F2' : 'F1',
|
||||
});
|
||||
}
|
||||
y += rowH;
|
||||
}
|
||||
y += 8;
|
||||
}
|
||||
y += 8;
|
||||
}
|
||||
|
||||
emitBlocks(step, 'after-description');
|
||||
@@ -144,7 +144,7 @@ function exportPdf(ast, outDir, template = {}) {
|
||||
}
|
||||
|
||||
function emitBlocks(step, position) {
|
||||
for (const tb of step.textBlocks.filter((b) => b.position === 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;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { normalizeStep } = require('../../core/schema');
|
||||
const { orderedBlocks, blockText } = require('../../core/blocks');
|
||||
|
||||
test('block normalization recovers legacy code fields and preserves order', () => {
|
||||
const step = normalizeStep({
|
||||
stepId: 'step-1',
|
||||
kind: 'content',
|
||||
title: 'Block test',
|
||||
textBlocks: [{ id: 'tb1', order: 2, position: 'after-description', level: 'info', title: 'Note', descriptionHtml: '<p>Text</p>' }],
|
||||
codeBlocks: [{ id: 'cb1', order: 1, language: 'bash', text: 'echo hi' }],
|
||||
tableBlocks: [{ id: 'tbl1', order: 3, rows: [['A', 'B'], ['1', '2']] }],
|
||||
});
|
||||
|
||||
assert.equal(step.codeBlocks[0].code, 'echo hi');
|
||||
assert.equal(blockText(step.codeBlocks[0]), 'echo hi');
|
||||
assert.deepEqual(orderedBlocks(step).map((block) => block.kind), ['code', 'text', 'table']);
|
||||
});
|
||||
@@ -237,6 +237,6 @@ test('a saved template changes exporter behavior through runExport', (t) => {
|
||||
const withTemplate = runExport('pdf', ast, path.join(root, 'out2'), tm.load('pdf', 'no-cover'));
|
||||
assert.ok(withTemplate.pageCount < withDefaults.pageCount, 'dropping cover+toc reduces pages');
|
||||
|
||||
assert.equal(Object.keys(EXPORTERS).length, 9, 'all nine formats wired');
|
||||
assert.equal(Object.keys(EXPORTERS).length, 10, 'all ten formats wired');
|
||||
assert.throws(() => runExport('exe', ast, path.join(root, 'out3')));
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ const { buildRenderAst, renderStepImage } = require('../../core/renderast');
|
||||
const { exportJson } = require('../../exporters/json');
|
||||
const { exportMarkdown } = require('../../exporters/markdown');
|
||||
const { exportHtmlSimple, exportHtmlRich } = require('../../exporters/html');
|
||||
const { exportConfluence } = require('../../exporters/confluence');
|
||||
const { htmlToMarkdown } = require('../../exporters/htmlmd');
|
||||
const { decodePng } = require('../../core/png');
|
||||
const { buildFixtureGuide } = require('./fixture-guide');
|
||||
@@ -114,6 +115,31 @@ test('Markdown export: TOC anchors resolve, images exist, blocks rendered', (t)
|
||||
assert.equal(lines[warnIdx + 1], '> Admins only.');
|
||||
});
|
||||
|
||||
test('Confluence export writes storage-format XML and image attachments', (t) => {
|
||||
const root = makeTmpDir('expconf');
|
||||
t.after(() => rmrf(root));
|
||||
const { store, guide } = buildFixtureGuide(path.join(root, 'data'));
|
||||
const out = path.join(root, 'out');
|
||||
|
||||
const ast = buildRenderAst(store, guide.guideId);
|
||||
const { file, attachmentCount } = exportConfluence(ast, out);
|
||||
const xml = fs.readFileSync(file, 'utf8');
|
||||
|
||||
assert.equal(attachmentCount, 2);
|
||||
assert.ok(xml.includes('<ac:structured-macro ac:name="code">'));
|
||||
assert.ok(xml.includes('ri:attachment ri:filename='));
|
||||
assert.ok(xml.includes('0 2 * * * /usr/local/bin/acmesync --backup'));
|
||||
|
||||
const attachmentsDir = path.join(out, 'configure-acmesync-backups-attachments');
|
||||
const files = fs.readdirSync(attachmentsDir);
|
||||
assert.equal(files.length, 2);
|
||||
for (const name of files) {
|
||||
const img = decodePng(fs.readFileSync(path.join(attachmentsDir, name)));
|
||||
assert.equal(img.width, 320);
|
||||
assert.equal(img.height, 200);
|
||||
}
|
||||
});
|
||||
|
||||
test('Simple HTML export is self-contained with valid embedded images', (t) => {
|
||||
const root = makeTmpDir('exphtml');
|
||||
t.after(() => rmrf(root));
|
||||
|
||||
Reference in New Issue
Block a user