Click a designed-page tile to edit it on the canvas. Click a chapter tile to edit its markdown. PDF export is raster-only in v1 (real-text mode for embeddable fonts lands in a follow-up slice).
+
Click a designed-page tile to edit it on the canvas. Chapter text lives under the ๐ Chapters tab. PDF export is raster-only in v1 (real-text mode for embeddable fonts lands in a follow-up slice).
+
+
+
+
+
+ Chapters are NIP-23 long-form articles. Pin / live, edit, and publish each one independently โ text flows through the designed pages it's threaded into. (Threading lands in Phase J3; for now chapters paginate against the active master, same as before.)
+
+
+
+
Each row is a linked content file. Click Edit to open the markdown editor.
@@ -8848,6 +8916,8 @@
cover: null, // { ref } once a cover event has been authored
masters: [], // [{ ref, label }]
pages: [], // discriminated union โ see spec
+ links: [], // Phase J: project's Links panel (chapters, masters, ...)
+ threads: [], // Phase J: text-flow chains through frames
};
}
function ensureBookState() {
@@ -8862,6 +8932,14 @@
for (const k of Object.keys(defaults)) {
if (state.book.spec[k] === undefined) state.book.spec[k] = defaults[k];
}
+ // Phase J collections โ links[] holds every external reference
+ // the book makes (chapters, masters, designed pages from other
+ // books, illustration sets). threads[] holds text flows. Both
+ // are scaffolded here so the rest of the codebase can assume
+ // they exist; J1 stops at scaffolding (no real migration of
+ // reflow entries yet).
+ if (!Array.isArray(state.book.links)) state.book.links = [];
+ if (!Array.isArray(state.book.threads)) state.book.threads = [];
return state.book;
}
// Physical page count โ manifest entries except reflow segments,
@@ -9849,8 +9927,9 @@
const pages = book?.pages || [];
const readout = document.getElementById('bookCountReadout');
if (readout) {
- const reflowN = pages.filter(p => p.type === 'reflow').length;
- readout.textContent = `${pages.length} page${pages.length === 1 ? '' : 's'} ยท ${reflowN} reflow`;
+ const designN = pages.filter(p => p.type === 'design').length;
+ const chapterN = pages.filter(p => p.type === 'reflow').length;
+ readout.textContent = `${designN} page${designN === 1 ? '' : 's'} ยท ${chapterN} chapter${chapterN === 1 ? '' : 's'}`;
}
if (!pages.length) {
const starter = document.createElement('div');
@@ -12707,16 +12786,21 @@
}
function setBookSubView(view) {
- const pages = document.getElementById('bookPagesView');
- const cover = document.getElementById('bookCoverView');
- const tPages = document.getElementById('bookViewTogglePages');
- const tCover = document.getElementById('bookViewToggleCover');
- const showCover = view === 'cover';
- if (pages) pages.style.display = showCover ? 'none' : '';
- if (cover) cover.style.display = showCover ? '' : 'none';
- if (tPages) tPages.classList.toggle('active', !showCover);
- if (tCover) tCover.classList.toggle('active', showCover);
- if (showCover) renderBookCoverPreview();
+ const pages = document.getElementById('bookPagesView');
+ const chapters = document.getElementById('bookChaptersView');
+ const cover = document.getElementById('bookCoverView');
+ const tPages = document.getElementById('bookViewTogglePages');
+ const tChapters = document.getElementById('bookViewToggleChapters');
+ const tCover = document.getElementById('bookViewToggleCover');
+ const want = (view === 'cover' || view === 'chapters') ? view : 'pages';
+ if (pages) pages.style.display = want === 'pages' ? '' : 'none';
+ if (chapters) chapters.style.display = want === 'chapters' ? '' : 'none';
+ if (cover) cover.style.display = want === 'cover' ? '' : 'none';
+ if (tPages) tPages.classList.toggle('active', want === 'pages');
+ if (tChapters) tChapters.classList.toggle('active', want === 'chapters');
+ if (tCover) tCover.classList.toggle('active', want === 'cover');
+ if (want === 'cover') renderBookCoverPreview();
+ if (want === 'chapters') renderBookChaptersList();
}
// Cover spread preview. Lays out back / spine / front panels with
@@ -12895,6 +12979,115 @@
}
}
+ // Phase J1: dedicated Chapters tab. Reads reflow entries from
+ // book.pages for now (data model still mixes designed pages +
+ // reflow chapters); J2+ migrates them out into book.links[] and
+ // this renderer switches to reading from there.
+ function renderBookChaptersList() {
+ const list = document.getElementById('bookChaptersList');
+ if (!list) return;
+ const book = state.book ? ensureBookState() : null;
+ list.innerHTML = '';
+ if (!book) return;
+ const chapters = (book.pages || []).filter(p => p.type === 'reflow');
+ if (!chapters.length) {
+ const empty = document.createElement('div');
+ empty.className = 'bookChapterEmpty';
+ empty.innerHTML = 'No chapters yet. Click + Add chapter to start one, or import a NIP-23 article by naddr.';
+ list.appendChild(empty);
+ return;
+ }
+ chapters.forEach((entry) => {
+ const row = document.createElement('div');
+ row.className = 'bookChapterRow';
+ row.dataset.entryId = entry.id;
+
+ const icon = document.createElement('div');
+ icon.className = 'bookChapterIcon';
+ icon.textContent = '๐';
+ row.appendChild(icon);
+
+ const meta = document.createElement('div');
+ meta.className = 'bookChapterMeta';
+ const title = document.createElement('div');
+ title.className = 'bookChapterTitle';
+ const pills = [];
+ const isPinned = !!entry.textPinnedEventId;
+ const isLive = !isPinned && !!entry.textRef;
+ const isPrivate = !!(entry.textRef?.encrypted);
+ if (isLive) pills.push('live');
+ if (isPinned) pills.push('pinned');
+ if (isPrivate) pills.push('๐ private');
+ title.innerHTML = pills.join('') + escapeHtml(entry.chapterTitle || entry.label || 'Untitled chapter');
+ meta.appendChild(title);
+
+ const sub = document.createElement('div');
+ sub.className = 'bookChapterSub';
+ const md = String(entry.chapterMarkdown || '');
+ const len = md.length;
+ const sizeText = len > 1024 ? `${(len / 1024).toFixed(1)} KB` : `${len} chars`;
+ const refText = entry.textRef
+ ? `naddr ยท ${entry.textRef.dTag || '(no d-tag)'}`
+ : 'local draft (not yet published)';
+ sub.textContent = `${refText} ยท ${sizeText}`;
+ meta.appendChild(sub);
+ row.appendChild(meta);
+
+ const actions = document.createElement('div');
+ actions.className = 'bookChapterActions';
+ const editBtn = document.createElement('button');
+ editBtn.type = 'button';
+ editBtn.textContent = 'โ Edit';
+ editBtn.title = 'Open the markdown editor for this chapter';
+ editBtn.addEventListener('click', () => openBookChapter(entry.id));
+ actions.appendChild(editBtn);
+
+ // Pin / live toggle โ only meaningful when there's a Nostr ref.
+ if (entry.textRef) {
+ const pinBtn = document.createElement('button');
+ pinBtn.type = 'button';
+ pinBtn.textContent = isPinned ? '๐ Unpin' : '๐ Pin to current';
+ pinBtn.title = isPinned
+ ? 'Currently locked to a specific event id โ click to track the latest version'
+ : 'Currently tracks the latest version โ click to lock to a specific event id';
+ pinBtn.addEventListener('click', () => {
+ pushHistory('book: toggle chapter pin');
+ if (isPinned) {
+ entry.textPinnedEventId = null;
+ } else {
+ entry.textPinnedEventId = entry.textLastEventId || entry.textRef?.lastEventId || null;
+ if (!entry.textPinnedEventId) {
+ toastError?.('No resolved event id yet to pin to โ click โป Refresh from Nostr first.');
+ return;
+ }
+ }
+ renderBookChaptersList();
+ syncInputs();
+ render();
+ });
+ actions.appendChild(pinBtn);
+ }
+
+ const delBtn = document.createElement('button');
+ delBtn.type = 'button';
+ delBtn.textContent = '๐';
+ delBtn.title = 'Remove this chapter from the book';
+ delBtn.style.cssText = 'color:#b91c1c';
+ delBtn.addEventListener('click', () => {
+ if (!confirm(`Remove "${entry.chapterTitle || 'this chapter'}" from the book? The NIP-23 event (if published) is not deleted from relays.`)) return;
+ pushHistory('book: remove chapter');
+ book.pages = book.pages.filter(p => p.id !== entry.id);
+ renderBookChaptersList();
+ syncInputs();
+ render();
+ });
+ actions.appendChild(delBtn);
+ row.appendChild(actions);
+
+ list.appendChild(row);
+ });
+ }
+
function syncBookSidebarSummary() {
const el = $('bookSpecSummary');
if (!el) return;
@@ -17130,6 +17323,13 @@
updateBodyClasses();
renderDeckSorter();
renderBookPagesGrid();
+ // Phase J1: chapters list only repaints when its tab is the
+ // active sub-view (cheap when collapsed; the chapter list can
+ // get long).
+ if (document.getElementById('bookChaptersView')?.style.display !== 'none'
+ && state.templateMode === 'book') {
+ renderBookChaptersList();
+ }
syncBookPageEditBar();
syncBookChapterEditor();
// Only repaint the cover preview when its view is the active
@@ -37428,8 +37628,9 @@
on('bookChapterEditorSourceInput', 'keydown', (e) => {
if (e.key === 'Enter') { e.preventDefault(); importBookChapterFromNostr(); }
});
- on('bookViewTogglePages', 'click', () => setBookSubView('pages'));
- on('bookViewToggleCover', 'click', () => setBookSubView('cover'));
+ on('bookViewTogglePages', 'click', () => setBookSubView('pages'));
+ on('bookViewToggleChapters', 'click', () => setBookSubView('chapters'));
+ on('bookViewToggleCover', 'click', () => setBookSubView('cover'));
on('bookCoverEditBtn', 'click', openBookCover);
on('bookCoverReplaceBtn', 'click', importBookCoverFromNostr);
on('bookCoverImportJsonBtn', 'click', () => {