diff --git a/app/export-runner.js b/app/export-runner.js new file mode 100644 index 0000000..17f65b8 --- /dev/null +++ b/app/export-runner.js @@ -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 }; diff --git a/app/main.js b/app/main.js index b383010..3dc4bbf 100644 --- a/app/main.js +++ b/app/main.js @@ -11,9 +11,10 @@ const { const { GuideStore } = require('../core/store'); const { Settings } = require('../core/settings'); const { SearchIndex } = require('../core/search'); -const { TemplateManager, FORMATS } = require('../core/templates'); +const { TemplateManager, FORMATS, FORMAT_LABELS } = 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'); @@ -578,13 +579,17 @@ function setupIpc() { }); // export + preview - h('export:formats', () => FORMATS.filter((f) => EXPORTERS[f])); + h('export:formats', () => FORMATS.filter((f) => EXPORTERS[f]).map((format) => ({ + id: format, + label: FORMAT_LABELS[format] || format, + }))); h('export:defaults', ({ format }) => { // Exporter modules expose DEFAULT_TEMPLATE; the dialog renders editable // options from it (booleans -> checkbox, numbers -> number, strings -> text). const mod = { json: '../exporters/json', markdown: '../exporters/markdown', + wikijs: '../exporters/wikijs', 'html-simple': '../exporters/html', 'html-rich': '../exporters/html', confluence: '../exporters/confluence', @@ -607,8 +612,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 }; }); diff --git a/app/renderer/app.js b/app/renderer/app.js index 96376cd..1692691 100644 --- a/app/renderer/app.js +++ b/app/renderer/app.js @@ -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() }, diff --git a/app/renderer/dialogs.js b/app/renderer/dialogs.js index 532a64e..de99f9d 100644 --- a/app/renderer/dialogs.js +++ b/app/renderer/dialogs.js @@ -413,28 +413,43 @@ function showExportDialog({ outDir: outDirInput.value.trim() || null, }); + const cancelBtn = el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'); + const previewBtn = el('button', { + onClick: async () => { + if (typeof onPreview !== 'function') return; + setButtonLoading(previewBtn, true, 'Preview…'); + try { + await onPreview(payload()); // keep dialog open so settings can be tweaked + } finally { + setButtonLoading(previewBtn, false); + } + }, + }, 'Preview'); + const exportBtn = el('button.primary', { + onClick: async () => { + if (typeof onExport !== 'function') return; + cancelBtn.disabled = true; + previewBtn.disabled = true; + setButtonLoading(exportBtn, true, 'Exporting…'); + try { + const ok = await onExport(payload()); + if (ok !== false) { + close(); + resolve(true); + return; + } + } finally { + cancelBtn.disabled = false; + previewBtn.disabled = false; + setButtonLoading(exportBtn, false); + } + }, + }, 'Export'); + const { close } = openModal({ title: 'Export', body, - footer: [ - el('button', { onClick: () => { close(); resolve(false); } }, 'Cancel'), - el('button', { - onClick: async () => { - if (typeof onPreview !== 'function') return; - await onPreview(payload()); // keep dialog open so settings can be tweaked - }, - }, 'Preview'), - el('button.primary', { - onClick: async () => { - if (typeof onExport !== 'function') return; - const ok = await onExport(payload()); - if (ok !== false) { - close(); - resolve(true); - } - }, - }, 'Export'), - ], + footer: [cancelBtn, previewBtn, exportBtn], wide: true, onClose: () => resolve(false), }); @@ -639,6 +654,53 @@ function showPlaceholdersDialog({ title = 'Placeholders', hint = '', values = {} }); } +/** + * Optional document metadata (author, co-authors, organization, description) + * shown at the top of the guide, below the title, and surfaced on the PDF + * cover page and the top of other export formats. + */ +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 descriptionInput = el('textarea', { + rows: 4, + placeholder: 'A short summary of this guide.', + }, values.description || ''); + + 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), + labeledRow('Description', descriptionInput, { stacked: true }), + el('div.muted', { style: { marginTop: '-4px' } }, + 'Shown on the first page of the PDF and at the top of other export formats.'), + ), + 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(), + description: descriptionInput.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 +789,7 @@ window.StepForgeDialogs = { showInfoDialog, showBackupsDialog, showPlaceholdersDialog, + showGuideInfoDialog, showShortcutsDialog, showTemplateManager, showRecordingReminder, diff --git a/app/renderer/editor.js b/app/renderer/editor.js index b6ab0e6..840f252 100644 --- a/app/renderer/editor.js +++ b/app/renderer/editor.js @@ -87,6 +87,16 @@ function stepNumberMap(steps) { return numbers; } +function stepDepth(step, stepMap) { + let depth = 0; + let parent = step.parentStepId; + while (parent && stepMap.has(parent)) { + depth += 1; + parent = stepMap.get(parent).parentStepId; + } + return depth; +} + function isEditableTarget(target) { return target && ( target.tagName === 'INPUT' || @@ -761,15 +771,10 @@ class GuideEditor { this.dom.selectStepsBtn.className = this.stepSelectMode ? 'primary' : ''; for (const step of this.steps) { const number = numbers.get(step.stepId) || ''; - let depth = 0; - let parent = step.parentStepId; - while (parent && this.stepMap.has(parent)) { - depth += 1; - parent = this.stepMap.get(parent).parentStepId; - } + const depth = stepDepth(step, this.stepMap); const selected = current && current.stepId === step.stepId; const checked = this.selectedSteps.has(step.stepId); - const item = el('div.step-item', { + const itemProps = { className: `step-item${selected ? ' selected' : ''}${depth ? ' sub' : ''}${step.skipped ? ' skipped' : ''}${step.hidden ? ' hiddenstep' : ''}`, dataset: { stepId: step.stepId }, onClick: () => { @@ -802,23 +807,26 @@ class GuideEditor { { label: 'Delete step', danger: true, action: () => this.deleteSelectedStep() }, ]); }, - }, - this.stepSelectMode - ? el('input', { - type: 'checkbox', - checked, - onClick: (e) => e.stopPropagation(), - onChange: () => this.toggleStepSelection(step.stepId), - }) - : null, - el('span.status-dot', { className: `status-dot status-${step.status}` }), - el('span.num', {}, number || '•'), - el('span.t', {}, step.title || 'Untitled step'), - el('span.flags', {}, [ - step.parentStepId ? 'sub' : '', - step.hidden ? 'hidden' : '', - step.skipped ? 'skipped' : '', - ].filter(Boolean).join(' · '))); + }; + if (depth) itemProps.style = { marginLeft: `${depth * 18}px` }; + const item = el('div.step-item', itemProps, + this.stepSelectMode + ? el('input', { + type: 'checkbox', + checked, + onClick: (e) => e.stopPropagation(), + onChange: () => this.toggleStepSelection(step.stepId), + }) + : null, + el('span.status-dot', { className: `status-dot status-${step.status}` }), + el('span.num', {}, number || '•'), + el('span.t', {}, step.title || 'Untitled step'), + el('span.flags', {}, [ + step.parentStepId ? 'sub' : '', + step.hidden ? 'hidden' : '', + step.skipped ? 'skipped' : '', + ].filter(Boolean).join(' · ')), + ); this.dom.stepsList.append(item); } if (!this.steps.length) { @@ -1571,6 +1579,22 @@ class GuideEditor { }); } + async openGuideInfo() { + if (!this.guide) return; + await dialogs.showGuideInfoDialog({ + values: { + ...this.guide.metadata, + description: htmlToPlainText(this.guide.descriptionHtml || ''), + }, + onSave: async ({ description, ...metadata }) => { + this.guide.metadata = metadata; + this.guide.descriptionHtml = textToHtml(description); + await api.guide.save({ guide: this.guide }); + this.onToast('Guide information saved.'); + }, + }); + } + openShortcutsHelp() { dialogs.showShortcutsDialog(); } @@ -1642,7 +1666,11 @@ class GuideEditor { } async openExportDialog() { - const formats = (await api.export.formats()).map((id) => ({ id, label: id.replace(/-/g, ' ') })); + const formats = (await api.export.formats()).map((format) => ( + typeof format === 'string' + ? { id: format, label: format.replace(/-/g, ' ') } + : format + )); const templatesByFormat = {}; for (const format of formats) { templatesByFormat[format.id] = await api.templates.list({ format: format.id }); diff --git a/app/renderer/style.css b/app/renderer/style.css index 06f6675..f0bda60 100644 --- a/app/renderer/style.css +++ b/app/renderer/style.css @@ -90,6 +90,25 @@ button.tool.active { border-color: var(--accent); color: var(--accent-fg); } +button:disabled { opacity: 0.6; cursor: default; } +button.loading { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; +} + +.spinner { + display: inline-block; + width: 13px; + height: 13px; + border: 2px solid currentColor; + border-top-color: transparent; + border-radius: 50%; + opacity: 0.85; + animation: spin 0.7s linear infinite; +} +@keyframes spin { to { transform: rotate(360deg); } } input, select, textarea { font: inherit; diff --git a/app/renderer/util.js b/app/renderer/util.js index c521fe7..dc70189 100644 --- a/app/renderer/util.js +++ b/app/renderer/util.js @@ -34,6 +34,27 @@ function clearNode(node) { while (node.firstChild) node.removeChild(node.firstChild); } +/** + * Toggle a button into/out of a busy state: disables it and swaps its label + * for a spinner + `label` (e.g. "Exporting…"), restoring the original label + * afterwards. Used for actions that block on a slow main-process call. + */ +function setButtonLoading(btn, loading, label) { + if (loading) { + if (btn.dataset.origLabel === undefined) btn.dataset.origLabel = btn.textContent; + btn.disabled = true; + btn.classList.add('loading'); + clearNode(btn); + btn.append(el('span.spinner'), label || btn.dataset.origLabel); + } else { + btn.disabled = false; + btn.classList.remove('loading'); + clearNode(btn); + btn.append(btn.dataset.origLabel ?? ''); + delete btn.dataset.origLabel; + } +} + function debounce(fn, ms) { let t = null; const wrapped = (...args) => { @@ -187,3 +208,21 @@ function fmtDate(iso) { const escapeHtml = (s) => String(s ?? '') .replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); + +/** Inverse of textToHtml, for loading sanitized description HTML into a plain textarea. */ +function htmlToPlainText(html) { + return String(html || '') + .replace(/<(br|\/p|\/div|\/li|\/h[1-6])\s*\/?>/gi, '\n') + .replace(/<[^>]*>/g, '') + .replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, '\'').replace(/&/g, '&') + .replace(/[ \t]+/g, ' ').replace(/\s*\n\s*/g, '\n').trim(); +} + +/** Plain textarea text -> sanitizer-allowed paragraph HTML (blank line = new paragraph). */ +function textToHtml(text) { + const trimmed = String(text || '').trim(); + if (!trimmed) return ''; + return trimmed.split(/\n{2,}/) + .map((para) => `
${escapeHtml(para).replace(/\n/g, '
')}
${escapeXml(line.split(': ')[0])}: ${escapeXml(line.slice(line.indexOf(': ') + 2))}
`).join('\n')} +${escapeXml(guideSummary(ast))}
${ast.guide.descriptionHtml ? `