Add guide metadata, redesign PDF cover, paginate steps per-page, and run exports in a helper process
Lets users record author/co-authors/organization for a guide via a new "Guide information…" dialog; this metadata renders below the title on the PDF cover (title now sits above the accent rule). PDF export also paginates so each step fits its own page where possible, keeps a step's title/image/lead-in together, and forces the next step onto a fresh page after an oversized step overflows. Exports now run in a forked helper process so large guides no longer freeze the UI. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
This commit is contained in:
@@ -0,0 +1,42 @@
|
||||
'use strict';
|
||||
|
||||
const path = require('node:path');
|
||||
const { fork } = require('node:child_process');
|
||||
|
||||
const WORKER_PATH = path.join(__dirname, '..', 'core', 'export-worker.js');
|
||||
|
||||
/**
|
||||
* Run an export job in a forked helper process, so building the render AST
|
||||
* and rendering the output (e.g. a multi-step PDF) never blocks the main
|
||||
* process — and therefore never freezes the editor window — no matter how
|
||||
* large the guide is.
|
||||
*/
|
||||
function runExportInWorker({ dataDir, guideId, format, options, outDir, globals, maxSteps }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const worker = fork(WORKER_PATH, [], {
|
||||
env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' },
|
||||
});
|
||||
|
||||
let settled = false;
|
||||
const finish = (fn, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
worker.removeAllListeners();
|
||||
worker.kill();
|
||||
fn(value);
|
||||
};
|
||||
|
||||
worker.once('message', (msg) => {
|
||||
if (msg && msg.ok) finish(resolve, msg.result);
|
||||
else finish(reject, new Error((msg && msg.error) || 'Export worker failed.'));
|
||||
});
|
||||
worker.once('error', (err) => finish(reject, err));
|
||||
worker.once('exit', (code) => {
|
||||
finish(reject, new Error(`Export worker exited unexpectedly (code ${code}).`));
|
||||
});
|
||||
|
||||
worker.send({ dataDir, guideId, format, options, outDir, globals, maxSteps });
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { runExportInWorker };
|
||||
+9
-2
@@ -14,6 +14,7 @@ const { SearchIndex } = require('../core/search');
|
||||
const { TemplateManager, FORMATS } = require('../core/templates');
|
||||
const { buildRenderAst } = require('../core/renderast');
|
||||
const { runExport, EXPORTERS } = require('../exporters');
|
||||
const { runExportInWorker } = require('./export-runner');
|
||||
const { exportGuideArchive, importGuideArchive, saveLinkedGuide, readArchive } = require('../core/archive');
|
||||
const { createSnapshot, listSnapshots, restoreSnapshot } = require('../core/snapshots');
|
||||
const { readLock } = require('../core/locks');
|
||||
@@ -607,8 +608,14 @@ function setupIpc() {
|
||||
dir = res.filePaths[0];
|
||||
}
|
||||
settings.set(`exports.lastOutputDirs.${format}`, dir);
|
||||
const ast = buildRenderAst(store, guideId, { globals: settings.getGlobalPlaceholders() });
|
||||
const result = runExport(format, ast, dir, options || {});
|
||||
const result = await runExportInWorker({
|
||||
dataDir: store.root,
|
||||
guideId,
|
||||
format,
|
||||
options: options || {},
|
||||
outDir: dir,
|
||||
globals: settings.getGlobalPlaceholders(),
|
||||
});
|
||||
if (settings.get('exports.openFolderAfterExport')) shell.showItemInFolder(result.file);
|
||||
return { ok: true, ...result };
|
||||
});
|
||||
|
||||
@@ -317,6 +317,7 @@ class StepForgeApp {
|
||||
const rect = e.target.getBoundingClientRect();
|
||||
contextMenu(rect.left, rect.bottom + 4, [
|
||||
{ label: 'Rename guide…', action: () => this.renameGuide() },
|
||||
{ label: 'Guide information…', action: () => this.editor.openGuideInfo() },
|
||||
{ label: 'Guide placeholders…', action: () => this.editor.openGuidePlaceholders() },
|
||||
{ label: 'Backups & snapshots…', action: () => this.editor.openBackupsDialog() },
|
||||
{ label: guide && guide.linkedSource ? 'Linked guide…' : 'Linked guide (not linked)', action: () => this.editor.openLinkedGuide() },
|
||||
|
||||
@@ -639,6 +639,44 @@ function showPlaceholdersDialog({ title = 'Placeholders', hint = '', values = {}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Optional document metadata (author, co-authors, organization) shown at the
|
||||
* top of the guide, below the title, and surfaced on the PDF cover page.
|
||||
*/
|
||||
function showGuideInfoDialog({ values = {}, onSave } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const authorInput = makeInput(values.author || '', 'text', { placeholder: 'e.g. Jane Doe' });
|
||||
const coAuthorsInput = makeInput(values.coAuthors || '', 'text', { placeholder: 'e.g. Alex Lee, Sam Patel' });
|
||||
const organizationInput = makeInput(values.organization || '', 'text', { placeholder: 'e.g. Acme Corp' });
|
||||
|
||||
const { close } = openModal({
|
||||
title: 'Guide information',
|
||||
body: el('div', {},
|
||||
el('div.muted', { style: { marginBottom: '10px' } },
|
||||
'All fields are optional. They appear below the title on the guide and on the PDF cover page.'),
|
||||
labeledRow('Author', authorInput),
|
||||
labeledRow('Co-authors', coAuthorsInput),
|
||||
labeledRow('Organization', organizationInput),
|
||||
),
|
||||
footer: [
|
||||
el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'),
|
||||
el('button.primary', {
|
||||
onClick: async () => {
|
||||
await onSave?.({
|
||||
author: authorInput.value.trim(),
|
||||
coAuthors: coAuthorsInput.value.trim(),
|
||||
organization: organizationInput.value.trim(),
|
||||
});
|
||||
close();
|
||||
resolve(true);
|
||||
},
|
||||
}, 'Save'),
|
||||
],
|
||||
onClose: () => resolve(false),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const SHORTCUTS = [
|
||||
['Capture & steps', [
|
||||
['Ctrl+S', 'Save (writes linked archive when guide is linked)'],
|
||||
@@ -727,6 +765,7 @@ window.StepForgeDialogs = {
|
||||
showInfoDialog,
|
||||
showBackupsDialog,
|
||||
showPlaceholdersDialog,
|
||||
showGuideInfoDialog,
|
||||
showShortcutsDialog,
|
||||
showTemplateManager,
|
||||
showRecordingReminder,
|
||||
|
||||
@@ -1571,6 +1571,18 @@ class GuideEditor {
|
||||
});
|
||||
}
|
||||
|
||||
async openGuideInfo() {
|
||||
if (!this.guide) return;
|
||||
await dialogs.showGuideInfoDialog({
|
||||
values: this.guide.metadata || {},
|
||||
onSave: async (metadata) => {
|
||||
this.guide.metadata = metadata;
|
||||
await api.guide.save({ guide: this.guide });
|
||||
this.onToast('Guide information saved.');
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
openShortcutsHelp() {
|
||||
dialogs.showShortcutsDialog();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user