Add guide metadata, redesign PDF cover, paginate steps, and run exports in a helper process #19
+11
-2
@@ -640,14 +640,19 @@ 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.
|
||||
* 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',
|
||||
@@ -657,6 +662,9 @@ function showGuideInfoDialog({ values = {}, onSave } = {}) {
|
||||
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'),
|
||||
@@ -666,6 +674,7 @@ function showGuideInfoDialog({ values = {}, onSave } = {}) {
|
||||
author: authorInput.value.trim(),
|
||||
coAuthors: coAuthorsInput.value.trim(),
|
||||
organization: organizationInput.value.trim(),
|
||||
description: descriptionInput.value.trim(),
|
||||
});
|
||||
close();
|
||||
resolve(true);
|
||||
|
||||
@@ -1574,9 +1574,13 @@ class GuideEditor {
|
||||
async openGuideInfo() {
|
||||
if (!this.guide) return;
|
||||
await dialogs.showGuideInfoDialog({
|
||||
values: this.guide.metadata || {},
|
||||
onSave: async (metadata) => {
|
||||
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.');
|
||||
},
|
||||
|
||||
@@ -187,3 +187,21 @@ function fmtDate(iso) {
|
||||
|
||||
const escapeHtml = (s) => String(s ?? '')
|
||||
.replace(/&/g, '&').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) => `<p>${escapeHtml(para).replace(/\n/g, '<br>')}</p>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user