book(C.5.1): vendor marked + Add chapter button + markdown editor

Reflowable chapters take their first step. The Pages overview
now has a "+ Add chapter" button next to "+ Add page" / "+ Add
10 pages". Clicking it appends a `type: 'reflow'` entry to the
manifest, opens a fresh markdown editor pane, and seeds it with
"# Chapter N\\n\\nWrite your chapter here."

- Vendored `[email protected]` (~36 KB minified, MIT) as
  src/vendor/marked.min.js. Loaded as a regular <script> next to
  polygon-clipping and pdf-lib's future home.
- Reflow entries store `chapterMarkdown` locally; the
  `textRef` / `textPinnedEventId` fields stay null until D1 maps
  each chapter to a real NIP-23 article event. masterId defaults
  to spec.defaultMasterId.
- New `#bookChapterEditor` two-pane view: textarea on the left
  (autosaves to entry.chapterMarkdown via pushHistoryDebounced),
  rendered HTML preview on the right via `marked.parse`. Mangling
  + header-id auto-generation off; <script> tags get
  belt-and-braces stripped from the parse output.
- Body class `editing-book-chapter` flips the view: bookView and
  the canvas chrome (sheet, palette, tools, layers panel) all
  hide so the user sees only the editor. Shared
  editing-book-page CSS already handled hiding canvas chrome via
  the :not(.editing-book-page) selector, which I widened to also
  :not(.editing-book-chapter) for #bookView.
- Toolbar: chapter title input (writes back to entry.chapterTitle
  on input), "← Back to Pages" button (clears editingChapterId,
  returns to the grid), and a pages-readout placeholder showing
  "Pagination preview lands in Phase C.5.2".
- Clicking a reflow tile in the overview now opens the chapter
  editor (was a no-op toast in B2). Esc returns to the overview
  same as the page editor / master editor paths.
- syncBookChapterEditor() runs from render() so autosave restore
  lands the user on the right pane without losing typed input
  (textarea/title skip syncing when focused).

Phase C.5.2 wires the pagination engine and replaces the textarea
preview with a paginated stack of master-themed pages.
This commit is contained in:
thePR0M3TH3AN committed 2026-06-02 21:17:38 -04:00
1 parent 4e75db99b8
commit f41e72e942
2 files changed
+223 -7

No files matched your search

+217 -7
View File
@@ -1502,9 +1502,68 @@
body.template-book .jewelSpineGuide,
body.template-book .spineText { display: none !important; }
/* Markdown chapter editor — shown when a reflow entry is open
on the book canvas. Two-pane layout: textarea on the left,
paginated preview on the right. */
#bookChapterEditor { display: none; }
body.template-book.editing-book-chapter #bookChapterEditor {
display: flex;
flex-direction: column;
width: 100%; gap: 10px;
padding: 14px 18px;
box-sizing: border-box;
min-height: calc(100vh - 160px);
}
.bookChapterEditorToolbar {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding-bottom: 8px; border-bottom: 1px solid var(--line);
}
.bookChapterEditorToolbar input[type="text"] {
flex: 1; min-width: 160px;
font-size: 1rem; font-weight: 600;
padding: 4px 8px;
border: 1px solid var(--line);
}
.bookChapterEditorPages {
font-size: .78rem; color: var(--muted); white-space: nowrap;
}
.bookChapterEditorBody {
flex: 1;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
min-height: 0;
}
#bookChapterEditorTextarea {
width: 100%; height: 100%;
padding: 12px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 13px; line-height: 1.55;
border: 1px solid var(--line); background: #fff;
resize: none;
}
.bookChapterEditorPreviewWrap {
border: 1px solid var(--line); background: #fff;
overflow: auto;
padding: 16px 20px;
}
#bookChapterEditorPreview { font-family: Georgia, serif; line-height: 1.6; }
#bookChapterEditorPreview h1 { font-family: Helvetica, Arial, sans-serif; font-size: 2rem; margin: 0 0 .6em; }
#bookChapterEditorPreview h2 { font-family: Helvetica, Arial, sans-serif; font-size: 1.5rem; margin: 1.2em 0 .4em; }
#bookChapterEditorPreview h3 { font-family: Helvetica, Arial, sans-serif; font-size: 1.2rem; margin: 1em 0 .4em; }
#bookChapterEditorPreview p { margin: 0 0 .8em; }
#bookChapterEditorPreview blockquote { margin: .8em 1.2em; font-style: italic; color: #4b5563; }
#bookChapterEditorPreview code { background: #f5f5f5; padding: 1px 5px; font-size: .92em; }
#bookChapterEditorPreview pre { background: #f5f5f5; padding: 10px 12px; overflow-x: auto; }
#bookChapterEditorPreview pre code { background: none; padding: 0; }
#bookChapterEditorPreview a { color: #0066cc; }
#bookChapterEditorPreview hr { border: 0; border-top: 1px solid #cccccc; margin: 1.2em 0; }
#bookChapterEditorPreview ul, #bookChapterEditorPreview ol { padding-left: 1.4em; margin: 0 0 .8em; }
#bookChapterEditorPreview img { max-width: 100%; }
/* Book view shell — mirrors the deck-sorter layout pattern. */
#bookView { display: none; }
body.template-book:not(.editing-book-page) #bookView {
body.template-book:not(.editing-book-page):not(.editing-book-chapter) #bookView {
display: flex;
flex-direction: column;
width: 100%;
@@ -4259,6 +4318,21 @@
<div class="deckSlideGrid" id="deckSlideGrid"></div>
</div>
<div id="bookChapterEditor">
<div class="bookChapterEditorToolbar">
<button type="button" id="bookChapterEditorBack">← Back to Pages</button>
<input type="text" id="bookChapterEditorTitle" placeholder="Chapter title" />
<span class="bookChapterEditorPages" id="bookChapterEditorPages"></span>
<span class="bookViewSpacer"></span>
<span class="hint" id="bookChapterEditorHint">Markdown autosaves to this book. Phase D1 publishes as a NIP-23 article.</span>
</div>
<div class="bookChapterEditorBody">
<textarea id="bookChapterEditorTextarea" placeholder="# Chapter 1&#10;&#10;Write your chapter here…"></textarea>
<div class="bookChapterEditorPreviewWrap">
<div id="bookChapterEditorPreview"></div>
</div>
</div>
</div>
<div id="bookView">
<div class="bookViewToggleBar">
<button type="button" id="bookViewTogglePages" class="bookViewToggle active">Pages</button>
@@ -4271,10 +4345,11 @@
<div class="buttonRow">
<button type="button" id="bookAddPageBtn" class="primary">+ Add page</button>
<button type="button" id="bookAddTenBtn">+ Add 10 pages</button>
<button type="button" id="bookAddChapterBtn">+ Add chapter</button>
</div>
</div>
<div class="deckSlideGrid" id="bookPagesGrid"></div>
<p class="hint" id="bookPagesHint" style="margin:8px 0 0">Phase A4 skeleton: tiles are placeholders. Click a tile in Phase B to edit the page. Reflow / NIP-23 chapters arrive in Phase C.5; per-page Nostr publish in Phase D1.</p>
<p class="hint" id="bookPagesHint" style="margin:8px 0 0">Click a designed-page tile to edit it on the canvas. Click a chapter tile to edit its markdown. Per-page Nostr publish (per-event sidecars + manifest) lands in Phase D1.</p>
</div>
<div id="bookCoverView" style="display:none">
<div class="bookCoverSummary" id="bookCoverSummary"></div>
@@ -5363,6 +5438,7 @@
<script src="vendor/qrcode.min.js"></script>
<script src="vendor/polygon-clipping.min.js"></script>
<script src="vendor/marked.min.js"></script>
<script>
const $ = (id) => document.getElementById(id);
const safe$ = (id) => { try { return $(id); } catch { return null; } };
@@ -8561,6 +8637,39 @@
syncInputs();
render();
}
// Add a fresh reflow chapter entry. Phase C.5 stores the
// markdown locally on the entry; Phase D1 will publish it as a
// NIP-23 article (kind-30023) and replace `chapterMarkdown`
// with a real `textRef`. The default master is required for the
// chapter to paginate, and the picker in C.5.x will let users
// pick a different one.
function addBookChapter() {
const book = ensureBookState();
pushHistory('book: add chapter');
const num = (book.pages.filter(p => p.type === 'reflow').length || 0) + 1;
const entry = {
id: makeBookEntryId(),
type: 'reflow',
textRef: null,
textPinnedEventId: null,
chapterMarkdown: `# Chapter ${num}\n\nWrite your chapter here.`,
masterId: book.spec?.defaultMasterId || null,
masterRef: null,
chapterTitle: `Chapter ${num}`,
chapterNumber: num,
startsOnPage: 'any',
skipNumberOnFirstPage: false,
styleOverrides: null,
gating: null,
};
book.pages.push(entry);
renderBookPagesGrid();
syncInputs();
render();
// Drop straight into the editor so the user can start writing.
openBookChapter(entry.id);
}
function addBookTenPages() {
const book = ensureBookState();
pushHistory('book: add 10 pages');
@@ -8815,15 +8924,18 @@
const idx = book.pages.findIndex(p => p.id === entryId);
if (idx < 0) return;
const entry = book.pages[idx];
// Phase B2 supports designed pages only; reflow segments open
// in the chapter / markdown editor in C.5.
if (entry.type !== 'design') {
toastError?.('Reflow chapter editor lands in Phase C.5.');
// Reflow segments route to the markdown editor instead of the
// canvas editor.
if (entry.type === 'reflow') {
openBookChapter(entryId);
return;
}
if (entry.type !== 'design') return;
book.editingEntryId = entryId;
book.editingMasterId = null; // only one editor open at a time
book.editingChapterId = null;
document.body.classList.add('editing-book-page');
document.body.classList.remove('editing-book-chapter');
clearLayerSelection?.();
try { setEditorTool('select'); } catch {}
syncBookPageEditBar();
@@ -8839,6 +8951,91 @@
syncInputs();
render();
}
// Chapter editor (Phase C.5). Reflow entries open a two-pane
// textarea + live preview instead of the canvas. The book stays
// in 'book' template mode; an `editingChapterId` field tracks
// which entry is active and a body class flips the view.
function openBookChapter(entryId) {
const book = ensureBookState();
const entry = book.pages.find(p => p.id === entryId);
if (!entry || entry.type !== 'reflow') return;
book.editingChapterId = entryId;
book.editingEntryId = null;
book.editingMasterId = null;
document.body.classList.add('editing-book-chapter');
syncBookChapterEditor();
syncInputs();
render();
}
function closeBookChapter() {
const book = state.book;
if (book) book.editingChapterId = null;
document.body.classList.remove('editing-book-chapter');
syncInputs();
render();
}
// Push entry fields into the editor inputs + render the
// markdown preview. Skips the textarea when it currently has
// focus so live typing isn't blown away by re-renders.
function syncBookChapterEditor() {
const book = state.book;
const id = book?.editingChapterId;
if (!id) return;
const entry = book.pages.find(p => p.id === id);
if (!entry) return;
const titleEl = document.getElementById('bookChapterEditorTitle');
const ta = document.getElementById('bookChapterEditorTextarea');
const preview = document.getElementById('bookChapterEditorPreview');
const pagesEl = document.getElementById('bookChapterEditorPages');
if (titleEl && titleEl !== document.activeElement) titleEl.value = entry.chapterTitle || '';
if (ta && ta !== document.activeElement) ta.value = entry.chapterMarkdown || '';
if (preview) preview.innerHTML = markdownToSafeHtml(entry.chapterMarkdown || '');
if (pagesEl) pagesEl.textContent = 'Pagination preview lands in Phase C.5.2';
}
// Minimal HTML escape + marked render. Sanitizes by routing
// through marked.parse with `mangle:false, headerIds:false`
// and post-strips any `<script>` tags as belt-and-braces.
function markdownToSafeHtml(src) {
if (typeof window.marked === 'undefined' || !window.marked.parse) {
return '<p>(marked library failed to load)</p>';
}
try {
const html = window.marked.parse(String(src || ''), {
mangle: false,
headerIds: false,
breaks: false,
gfm: false,
});
// Belt-and-braces: drop any embedded <script> just in case.
return String(html).replace(/<script[\s\S]*?<\/script>/gi, '');
} catch (err) {
console.warn('marked parse failed', err);
return '<p>(markdown parse error)</p>';
}
}
function updateBookChapterMarkdown(value) {
const book = state.book;
const id = book?.editingChapterId;
if (!id) return;
const entry = book.pages.find(p => p.id === id);
if (!entry) return;
pushHistoryDebounced(`book:chapter:${id}:md`, 'book: edit chapter', 600);
entry.chapterMarkdown = String(value || '');
// Only re-render preview (cheap); thumb / pagination will
// re-run in C.5.2 once the engine exists.
const preview = document.getElementById('bookChapterEditorPreview');
if (preview) preview.innerHTML = markdownToSafeHtml(entry.chapterMarkdown);
}
function updateBookChapterTitle(value) {
const book = state.book;
const id = book?.editingChapterId;
if (!id) return;
const entry = book.pages.find(p => p.id === id);
if (!entry) return;
pushHistoryDebounced(`book:chapter:${id}:title`, 'book: rename chapter', 400);
entry.chapterTitle = String(value || '');
}
// Master-edit mode: opens a master on the same canvas as the
// per-page editor. Same body class so the CSS that hides
// Pages overview / shows the sheet + tool palette applies; the
@@ -10357,6 +10554,8 @@
state.templateMode === 'book' && (!!state.book?.editingEntryId || !!state.book?.editingMasterId));
document.body.classList.toggle('editing-book-master',
state.templateMode === 'book' && !!state.book?.editingMasterId);
document.body.classList.toggle('editing-book-chapter',
state.templateMode === 'book' && !!state.book?.editingChapterId);
document.body.classList.toggle('editing-deck-slide', !!state.deck && state.deck.editingIndex !== null);
document.body.classList.toggle('hide-guides', !state.showGuides);
document.body.classList.toggle('hide-bleed', !state.showBleed);
@@ -13067,6 +13266,7 @@
renderDeckSorter();
renderBookPagesGrid();
syncBookPageEditBar();
syncBookChapterEditor();
// Only repaint the cover preview when its view is the active
// sub-view — saves work on every render() in book mode.
if (document.getElementById('bookCoverView')?.style.display !== 'none'
@@ -32059,6 +32259,11 @@
// Book Designer — Pages overview + Cover view toggle.
on('bookAddPageBtn', 'click', addBookDesignedPage);
on('bookAddTenBtn', 'click', addBookTenPages);
on('bookAddChapterBtn', 'click', addBookChapter);
// Chapter editor panes.
on('bookChapterEditorBack', 'click', closeBookChapter);
on('bookChapterEditorTextarea', 'input', (e) => updateBookChapterMarkdown(e.target.value));
on('bookChapterEditorTitle', 'input', (e) => updateBookChapterTitle(e.target.value));
on('bookViewTogglePages', 'click', () => setBookSubView('pages'));
on('bookViewToggleCover', 'click', () => setBookSubView('cover'));
on('bookPageEditBack', 'click', () => {
@@ -32398,11 +32603,16 @@
const bookMastersM = $('bookMastersModal');
if (bookMastersM && bookMastersM.getAttribute('aria-hidden') === 'false') { closeBookMastersModal(); return; }
// Esc while editing a book surface returns to the right home:
// master-edit → masters modal, page-edit → Pages overview.
// master-edit → masters modal, page-edit / chapter-edit →
// Pages overview.
if (state.templateMode === 'book' && state.book?.editingMasterId) {
closeBookMaster();
return;
}
if (state.templateMode === 'book' && state.book?.editingChapterId) {
closeBookChapter();
return;
}
if (state.templateMode === 'book' && state.book?.editingEntryId) {
closeBookPage();
return;
+6
View File
@@ -0,0 +1,6 @@
/**
* marked v14.1.4 - a markdown parser
* Copyright (c) 2011-2024, Christopher Jeffrey. (MIT Licensed)
* https://github.com/markedjs/marked
*/
!function(e,t){"object"==typeof exports&&"undefined"!=typeof module?t(exports):"function"==typeof define&&define.amd?define(["exports"],t):t((e="undefined"!=typeof globalThis?globalThis:e||self).marked={})}(this,(function(e){"use strict";function t(){return{async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null}}function n(t){e.defaults=t}e.defaults={async:!1,breaks:!1,extensions:null,gfm:!0,hooks:null,pedantic:!1,renderer:null,silent:!1,tokenizer:null,walkTokens:null};const s=/[&<>"']/,r=new RegExp(s.source,"g"),i=/[<>"']|&(?!(#\d{1,7}|#[Xx][a-fA-F0-9]{1,6}|\w+);)/,l=new RegExp(i.source,"g"),o={"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"},a=e=>o[e];function c(e,t){if(t){if(s.test(e))return e.replace(r,a)}else if(i.test(e))return e.replace(l,a);return e}const h=/(^|[^\[])\^/g;function p(e,t){let n="string"==typeof e?e:e.source;t=t||"";const s={replace:(e,t)=>{let r="string"==typeof t?t:t.source;return r=r.replace(h,"$1"),n=n.replace(e,r),s},getRegex:()=>new RegExp(n,t)};return s}function u(e){try{e=encodeURI(e).replace(/%25/g,"%")}catch{return null}return e}const k={exec:()=>null};function g(e,t){const n=e.replace(/\|/g,((e,t,n)=>{let s=!1,r=t;for(;--r>=0&&"\\"===n[r];)s=!s;return s?"|":" |"})).split(/ \|/);let s=0;if(n[0].trim()||n.shift(),n.length>0&&!n[n.length-1].trim()&&n.pop(),t)if(n.length>t)n.splice(t);else for(;n.length<t;)n.push("");for(;s<n.length;s++)n[s]=n[s].trim().replace(/\\\|/g,"|");return n}function f(e,t,n){const s=e.length;if(0===s)return"";let r=0;for(;r<s;){const i=e.charAt(s-r-1);if(i!==t||n){if(i===t||!n)break;r++}else r++}return e.slice(0,s-r)}function d(e,t,n,s){const r=t.href,i=t.title?c(t.title):null,l=e[1].replace(/\\([\[\]])/g,"$1");if("!"!==e[0].charAt(0)){s.state.inLink=!0;const e={type:"link",raw:n,href:r,title:i,text:l,tokens:s.inlineTokens(l)};return s.state.inLink=!1,e}return{type:"image",raw:n,href:r,title:i,text:c(l)}}class x{options;rules;lexer;constructor(t){this.options=t||e.defaults}space(e){const t=this.rules.block.newline.exec(e);if(t&&t[0].length>0)return{type:"space",raw:t[0]}}code(e){const t=this.rules.block.code.exec(e);if(t){const e=t[0].replace(/^(?: {1,4}| {0,3}\t)/gm,"");return{type:"code",raw:t[0],codeBlockStyle:"indented",text:this.options.pedantic?e:f(e,"\n")}}}fences(e){const t=this.rules.block.fences.exec(e);if(t){const e=t[0],n=function(e,t){const n=e.match(/^(\s+)(?:```)/);if(null===n)return t;const s=n[1];return t.split("\n").map((e=>{const t=e.match(/^\s+/);if(null===t)return e;const[n]=t;return n.length>=s.length?e.slice(s.length):e})).join("\n")}(e,t[3]||"");return{type:"code",raw:e,lang:t[2]?t[2].trim().replace(this.rules.inline.anyPunctuation,"$1"):t[2],text:n}}}heading(e){const t=this.rules.block.heading.exec(e);if(t){let e=t[2].trim();if(/#$/.test(e)){const t=f(e,"#");this.options.pedantic?e=t.trim():t&&!/ $/.test(t)||(e=t.trim())}return{type:"heading",raw:t[0],depth:t[1].length,text:e,tokens:this.lexer.inline(e)}}}hr(e){const t=this.rules.block.hr.exec(e);if(t)return{type:"hr",raw:f(t[0],"\n")}}blockquote(e){const t=this.rules.block.blockquote.exec(e);if(t){let e=f(t[0],"\n").split("\n"),n="",s="";const r=[];for(;e.length>0;){let t=!1;const i=[];let l;for(l=0;l<e.length;l++)if(/^ {0,3}>/.test(e[l]))i.push(e[l]),t=!0;else{if(t)break;i.push(e[l])}e=e.slice(l);const o=i.join("\n"),a=o.replace(/\n {0,3}((?:=+|-+) *)(?=\n|$)/g,"\n $1").replace(/^ {0,3}>[ \t]?/gm,"");n=n?`${n}\n${o}`:o,s=s?`${s}\n${a}`:a;const c=this.lexer.state.top;if(this.lexer.state.top=!0,this.lexer.blockTokens(a,r,!0),this.lexer.state.top=c,0===e.length)break;const h=r[r.length-1];if("code"===h?.type)break;if("blockquote"===h?.type){const t=h,i=t.raw+"\n"+e.join("\n"),l=this.blockquote(i);r[r.length-1]=l,n=n.substring(0,n.length-t.raw.length)+l.raw,s=s.substring(0,s.length-t.text.length)+l.text;break}if("list"!==h?.type);else{const t=h,i=t.raw+"\n"+e.join("\n"),l=this.list(i);r[r.length-1]=l,n=n.substring(0,n.length-h.raw.length)+l.raw,s=s.substring(0,s.length-t.raw.length)+l.raw,e=i.substring(r[r.length-1].raw.length).split("\n")}}return{type:"blockquote",raw:n,tokens:r,text:s}}}list(e){let t=this.rules.block.list.exec(e);if(t){let n=t[1].trim();const s=n.length>1,r={type:"list",raw:"",ordered:s,start:s?+n.slice(0,-1):"",loose:!1,items:[]};n=s?`\\d{1,9}\\${n.slice(-1)}`:`\\${n}`,this.options.pedantic&&(n=s?n:"[*+-]");const i=new RegExp(`^( {0,3}${n})((?:[\t ][^\\n]*)?(?:\\n|$))`);let l=!1;for(;e;){let n=!1,s="",o="";if(!(t=i.exec(e)))break;if(this.rules.block.hr.test(e))break;s=t[0],e=e.substring(s.length);let a=t[2].split("\n",1)[0].replace(/^\t+/,(e=>" ".repeat(3*e.length))),c=e.split("\n",1)[0],h=!a.trim(),p=0;if(this.options.pedantic?(p=2,o=a.trimStart()):h?p=t[1].length+1:(p=t[2].search(/[^ ]/),p=p>4?1:p,o=a.slice(p),p+=t[1].length),h&&/^[ \t]*$/.test(c)&&(s+=c+"\n",e=e.substring(c.length+1),n=!0),!n){const t=new RegExp(`^ {0,${Math.min(3,p-1)}}(?:[*+-]|\\d{1,9}[.)])((?:[ \t][^\\n]*)?(? Line truncated