From 314b582b0cad257bbe4fd5067af4adeb222d3be4 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 2 Jun 2026 10:58:54 -0400 Subject: [PATCH 01/64] book(A1): state shape + autosave round-trip for templateMode 'book' MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First commit of the Book Designer feature. Pure plumbing — no UI yet. Establishes the foundation for Phase A: - New `templateMode: 'book'` recognised by `normalizeTemplateMode` (matches either `payload.templateMode === 'book'` or the presence of a `payload.book` block). - `templateTypeForMode` returns `'book'` for the new mode. - `primaryTargetForMode` returns `'book-page'`. - `isLayerForCurrentMode` accepts every `book-*` target (`book-page`, `book-master`, `book-cover-front/back`, `book-spine`) in book mode. - `state.book` initialised to `null`; the new `ensureBookState()` helper lazily fills it with `defaultBookState()` (empty spec, null cover, empty masters + pages arrays). - `defaultBookSpec()` returns the v1 defaults documented in docs/BOOK_DESIGNER_FEATURE.md (US Trade 6×9, 0.125 bleed, paperThickness 0.0042, spineFromPages true, page numbering on, default master null). - `defaultBookTextStyles()` returns the v1 markdown stylesheet (Georgia body, Helvetica headings, monospace code, accent-blue links, blockquote indented italic, etc.) — used by Phase C.5's pagination engine. - SCHEMA_VERSION bumped to 6 with a comment explaining the additive `book` block. Existing autosave path serialises + restores `state.book` for free via Object.assign. No behaviour change to existing modes — Book mode is unreachable through the UI in this commit. The next commit (A2) adds the Template-tab entry that switches into book mode. --- src/index.html | 83 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 82 insertions(+), 1 deletion(-) diff --git a/src/index.html b/src/index.html index d7d1e38..23431b7 100644 --- a/src/index.html +++ b/src/index.html @@ -7674,7 +7674,10 @@ } } - const SCHEMA_VERSION = 5; + // Schema 6: adds the Book Designer (templateMode 'book') block. + // Old clients see an unknown templateMode and either skip or + // render a stub. The `book` block is additive on the payload. + const SCHEMA_VERSION = 6; const MIN_SUPPORTED_SCHEMA_VERSION = 4; const COLOR_LAYER_DEFAULT_FILL = '#3b82f6'; @@ -7741,9 +7744,73 @@ if (mode === 'disc') return 'disc-top'; if (mode === 'jewel') return 'jewel-front'; if (isCanvasMode(mode)) return 'canvas'; + if (mode === 'book') return 'book-page'; return 'cover-trim'; // cover mode } + // ---- Book Designer state helpers --------------------------------- + // + // Build a fresh `book` block at the defaults documented in + // docs/BOOK_DESIGNER_FEATURE.md (US Trade 6×9, 0.125 bleed, + // uncoated 60# paper thickness, page numbering enabled, + // empty pages / masters / cover). Anything that creates a new + // Book document goes through this helper so the shape stays + // consistent across init, mode-switch, and import paths. + function defaultBookSpec() { + return { + trimW: 6.0, + trimH: 9.0, + bleed: 0.125, + paperThickness: 0.0042, + spineFromPages: true, + spineWManual: 0.5, + coverHasSpine: true, + coverHasBack: true, + pageNumbering: { + enabled: true, + startAt: 1, + firstNumberedPage: 1, + format: '{n}', + }, + bookSize: 'US Trade 6×9', + defaultMasterRef: null, + textStyles: defaultBookTextStyles(), + }; + } + function defaultBookTextStyles() { + // Sane v1 stylesheet — readable Georgia body, Helvetica headings, + // monospace code blocks, accent-coloured links. Authors override + // via Document settings; the engine merges with built-in + // fallbacks for any key not explicitly set. + return { + body: { fontFamily: 'Georgia', fontSize: 11, lineHeight: 1.5, color: '#111111', alignment: 'left', marginAfter: 0.08 }, + h1: { fontFamily: 'Helvetica', fontSize: 24, fontWeight: 700, alignment: 'center', marginBefore: 0.4, marginAfter: 0.20, keepWithNext: true }, + h2: { fontFamily: 'Helvetica', fontSize: 18, fontWeight: 700, marginBefore: 0.3, marginAfter: 0.15, keepWithNext: true }, + h3: { fontFamily: 'Helvetica', fontSize: 14, fontWeight: 700, marginBefore: 0.2, marginAfter: 0.10, keepWithNext: true }, + h4: { fontFamily: 'Helvetica', fontSize: 12, fontWeight: 700, marginBefore: 0.15, marginAfter: 0.08, keepWithNext: true }, + h5: { fontFamily: 'Helvetica', fontSize: 11, fontWeight: 700, marginBefore: 0.12, marginAfter: 0.06, keepWithNext: true }, + h6: { fontFamily: 'Helvetica', fontSize: 11, fontWeight: 700, fontStyle: 'italic', marginBefore: 0.10, marginAfter: 0.06, keepWithNext: true }, + blockquote: { fontStyle: 'italic', marginLeft: 0.3, marginRight: 0.3, marginAfter: 0.08 }, + codeBlock: { fontFamily: 'monospace', fontSize: 10, background: '#f5f5f5', padding: 0.05, marginAfter: 0.08 }, + inlineCode: { fontFamily: 'monospace', fontSize: 10, background: '#f5f5f5' }, + link: { color: '#0066cc', underline: true }, + list: { marginAfter: 0.08, indent: 0.2 }, + hr: { color: '#cccccc', thickness: 0.5, marginBefore: 0.15, marginAfter: 0.15 }, + }; + } + function defaultBookState() { + return { + spec: defaultBookSpec(), + cover: null, // { ref } once a cover event has been authored + masters: [], // [{ ref, label }] + pages: [], // discriminated union — see spec + }; + } + function ensureBookState() { + if (!state.book) state.book = defaultBookState(); + return state.book; + } + function isLayerForCurrentMode(layer) { const t = String(layer?.target || ''); if (!t) return false; // strict isolation: no target = doesn't belong here @@ -7752,6 +7819,12 @@ if (mode === 'disc') return t.startsWith('disc-'); if (mode === 'jewel') return t.startsWith('jewel-'); if (mode === 'cover') return t === 'cover-trim' || t === 'back' || t === 'spine' || t === 'front'; + // Book mode targets are scoped to whichever surface the editor + // is bound to (a specific book-page, master, or cover panel). + // The active-target plumbing for Phase B will narrow this further + // by entry id; for now all book-* targets belong to book mode. + if (mode === 'book') return t === 'book-page' || t === 'book-master' + || t === 'book-cover-front' || t === 'book-cover-back' || t === 'book-spine'; return true; } @@ -7950,6 +8023,11 @@ deck: { theme: { fontFamily: '', background: '' }, slides: [], editingIndex: null }, // Standalone Pixel Art design (templateMode 'pixelart') — one grid. pixelArt: null, + // Book Designer (templateMode 'book'). The block holds the shared + // document spec + sidecar refs; pages / masters / cover are + // resolved through the Linked Designs resolver in later phases. + // See docs/BOOK_DESIGNER_FEATURE.md for the full schema. + book: null, discSpineLabels: { top: '', bottom: '' }, jewelSpineLabels: { left: '', right: '' }, jewelInserts: { @@ -8320,7 +8398,9 @@ if (raw === 'slide') return 'slide'; if (raw === 'deck') return 'deck'; if (raw === 'pixelart' || raw === 'pixel-art') return 'pixelart'; + if (raw === 'book') return 'book'; if (raw === 'cover' || raw === 'disc' || raw === 'jewel') return raw; + if (payload.book) return 'book'; if (payload.deck) return 'deck'; if (payload.slide) return 'slide'; if (payload.pixelArt) return 'pixelart'; @@ -8354,6 +8434,7 @@ if (mode === 'deck') return 'deck'; if (mode === 'pixelart') return 'pixelart'; if (mode === 'customart') return 'customart'; + if (mode === 'book') return 'book'; const preset = state.preset === 'jewel8693' ? 'dvd' : (state.preset || 'custom'); if (preset === 'dvd' || preset === 'bluray') return preset; return 'custom-case-cover'; From 7bed2a6a3fd19958f1aabb8b383e56dea94b68a3 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:14:32 -0400 Subject: [PATCH 02/64] book(A2): Template-tab "Book" entry + mode switching MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the Book button to the Template tab's layout picker (alongside Case cover / Disc labels / Slide deck) and wires the applyLayout('book') path so the user can switch into book mode. - `mode-book` button + click binding via the existing applyLayout loop (one-line list addition keeps every layout tab routed through the same handler). - `applyLayout('book')` calls `ensureBookState()` to lazy-init the manifest the first time a user enters the mode. - New `template-book` body class + CSS gating: the new `#bookFieldset` sidebar block shows only in book mode, and the cover / disc / deck / custom-art fieldsets hide in book mode so the sidebar stays focused on the spec + masters affordances. - `WORKFLOW_ORDER` learns the book column. - `sheetW` / `sheetH` fall back to trim + bleed when `templateMode === 'book'` so the canvas-host placeholder is sized correctly (it stays blank in this phase; the actual Pages overview + Cover view come in A4/A5). - Sidebar fieldset shows a derived spec summary (trim, bleed, paper thickness, derived spine width, page + master count) rendered by a new `syncBookSidebarSummary()` hook into `syncInputs()`. Book-state helpers (`bookPageCount`, `bookSpineWidthInches`) are pure functions so future phases can reuse them. - Two placeholder buttons (Document settings… / Manage masters…) are stubbed disabled with a hint pointing at the upcoming phases, so users can see where the controls will land. --- src/index.html | 83 ++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 2 deletions(-) diff --git a/src/index.html b/src/index.html index 23431b7..3d2c3b4 100644 --- a/src/index.html +++ b/src/index.html @@ -500,6 +500,20 @@ body.template-deck #deckThemeFieldset { display: block; } #deckSlideThemeWrap { display: none; margin-top: 10px; } body.editing-deck-slide #deckSlideThemeWrap { display: block; } + /* Book Designer sidebar fieldset shows only in book mode. */ + #bookFieldset { display: none; } + body.template-book #bookFieldset { display: block; } + /* Hide cover / disc / artwork panels that aren't relevant in book + mode (Phase A2 — book mode shows only its own fieldset + layers + + metadata + save). The Pages overview canvas-host comes in + Phase A4; until then the canvas-host remains blank. */ + body.template-book #coverSizeFieldset, + body.template-book #coverArtworkFieldset, + body.template-book #discArtworkFieldset, + body.template-book #jewelArtworkFieldset, + body.template-book #customArtFieldset, + body.template-book #deckFieldset, + body.template-book #deckThemeFieldset { display: none !important; } #layersFieldset { order: 40; } #designerMetadataFieldset { order: 45; } #exportFieldset { order: 50; } @@ -3013,6 +3027,7 @@ + @@ -3196,6 +3211,17 @@

Click a slide thumbnail to edit it. Drag thumbnails to reorder.

+
+ Book +

A book combines a cover spread (front + spine + back), an ordered list of designed pages, optional reflowable chapters written as NIP-23 markdown, and master pages for shared running heads and page numbers. See docs/BOOK_DESIGNER_FEATURE.md for the full spec.

+
+
+ + +
+

Document settings, Pages overview, Cover view, and PDF export ship in subsequent phases. This entry is the Phase A2 skeleton.

+
+
Deck theme

A deck theme restyles every slide in the thumbnails and Presenter Mode — the slides themselves are never changed.

@@ -7810,6 +7836,38 @@ if (!state.book) state.book = defaultBookState(); return state.book; } + // Page count + derived spine width readouts for the sidebar. + // Page count is the manifest entry count for Phase A — once reflow + // segments land in C.5 this counts physical pages after pagination. + function bookPageCount() { + return (state.book?.pages || []).length; + } + function bookSpineWidthInches() { + const spec = state.book?.spec; + if (!spec) return 0; + if (!spec.spineFromPages) return Number(spec.spineWManual) || 0; + return bookPageCount() * (Number(spec.paperThickness) || 0); + } + function syncBookSidebarSummary() { + const el = $('bookSpecSummary'); + if (!el) return; + if (state.templateMode !== 'book' || !state.book) { + el.textContent = ''; + return; + } + const s = state.book.spec; + const pageCount = bookPageCount(); + const spineW = bookSpineWidthInches(); + const spineNote = s.spineFromPages + ? `derived from ${pageCount} page${pageCount === 1 ? '' : 's'}` + : 'manual'; + const fmt = (n) => Number(n).toFixed(n % 1 === 0 ? 0 : 3); + el.innerHTML = '' + + `${s.bookSize || 'Custom'} · ${fmt(s.trimW)} × ${fmt(s.trimH)} in` + + `
Bleed ${fmt(s.bleed)} in · Paper ${fmt(s.paperThickness)} in/page` + + `
Spine ${fmt(spineW)} in (${spineNote})` + + `
${pageCount} page${pageCount === 1 ? '' : 's'} · ${(state.book.masters || []).length} master${(state.book.masters || []).length === 1 ? '' : 's'}`; + } function isLayerForCurrentMode(layer) { const t = String(layer?.target || ''); @@ -8857,6 +8915,10 @@ if (state.templateMode === 'disc') return discTemplate.sheetW; if (state.templateMode === 'jewel') return jewelTemplate.sheetW; if (isCanvasMode(state.templateMode)) return customArtCanvasInches().wIn; + if (state.templateMode === 'book') { + const s = state.book?.spec; + return (s ? (Number(s.trimW) || 0) + (Number(s.bleed) || 0) * 2 : 6); + } return totalTrimW() + state.bleed * 2; } @@ -8864,6 +8926,13 @@ if (state.templateMode === 'disc') return discTemplate.sheetH; if (state.templateMode === 'jewel') return jewelTemplate.sheetH; if (isCanvasMode(state.templateMode)) return customArtCanvasInches().hIn; + if (state.templateMode === 'book') { + // Phase A2: the canvas-host renders blank in book mode (no + // active page surface yet). Use trim + bleed so the host has + // sensible dimensions for the placeholder. + const s = state.book?.spec; + return (s ? (Number(s.trimH) || 0) + (Number(s.bleed) || 0) * 2 : 9); + } return state.trimH + state.bleed * 2; } @@ -8925,12 +8994,13 @@ cover: !isDesignerApp && state.templateMode === 'cover', disc: !isDesignerApp && state.templateMode === 'disc', deck: !isDesignerApp && state.templateMode === 'deck', + book: !isDesignerApp && state.templateMode === 'book', customart: !isDesignerApp && state.templateMode === 'customart', slide: !isDesignerApp && state.templateMode === 'slide', pixelart: !isDesignerApp && state.templateMode === 'pixelart', discdesign: isDesignerApp }; - for (const m of ['cover', 'disc', 'deck', 'customart', 'slide', 'pixelart', 'discdesign']) { + for (const m of ['cover', 'disc', 'deck', 'book', 'customart', 'slide', 'pixelart', 'discdesign']) { const button = $(`mode-${m}`); if (!button) continue; button.classList.toggle('active', !!layoutActive[m]); @@ -9050,6 +9120,7 @@ customart: ['designerLayoutFieldset', 'customArtFieldset', 'layersFieldset', 'metadataFieldset', 'exportFieldset', 'printSaveFieldset'], slide: ['designerLayoutFieldset', 'customArtFieldset', 'layersFieldset', 'metadataFieldset', 'exportFieldset', 'printSaveFieldset'], deck: ['layoutFieldset', 'deckFieldset', 'deckThemeFieldset', 'metadataFieldset', 'printSaveFieldset'], + book: ['layoutFieldset', 'bookFieldset', 'metadataFieldset', 'printSaveFieldset'], pixelart: ['designerLayoutFieldset', 'metadataFieldset', 'printSaveFieldset'], designer: ['designerLayoutFieldset', 'designerFieldset', 'layersFieldset', 'designerMetadataFieldset', 'designerPrintSaveFieldset'] }; @@ -9078,6 +9149,7 @@ document.body.classList.toggle('template-pixelart', state.templateMode === 'pixelart'); document.body.classList.toggle('template-slide', state.templateMode === 'slide'); document.body.classList.toggle('template-deck', state.templateMode === 'deck'); + document.body.classList.toggle('template-book', state.templateMode === 'book'); 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); @@ -9089,6 +9161,7 @@ document.body.classList.toggle('app-template', state.appMode !== 'designer'); updateLayerTargetOptions(); renumberWorkflowSteps(); + syncBookSidebarSummary(); } function renderDimensions() { @@ -28588,6 +28661,12 @@ state.deck = state.deck || { theme: { fontFamily: '', background: '' }, slides: [], editingIndex: null }; state.deck.editingIndex = null; } + if (layout === 'book') { + // Lazy-init the book block when first entering Book mode. The + // factory lives next to the other state defaults so the shape + // matches docs/BOOK_DESIGNER_FEATURE.md. + ensureBookState(); + } if (isCanvasMode(layout) || layout === 'pixelart') state._lastDesignerLayout = layout; else state._lastTemplateLayout = layout; syncInputs(); @@ -28839,7 +28918,7 @@ function attachEvents() { // Layout tabs: cover/disc live on the Template tab; customart/discdesign // on the Designer tab. applyLayout() sorts out the appMode crossing. - for (const layout of ['cover', 'disc', 'deck', 'customart', 'slide', 'pixelart', 'discdesign']) { + for (const layout of ['cover', 'disc', 'deck', 'book', 'customart', 'slide', 'pixelart', 'discdesign']) { const btn = $(`mode-${layout}`); if (btn) btn.addEventListener('click', () => applyLayout(layout)); } From 57897a462ba62d813ad1d8d11da594835d67c1fe Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:17:16 -0400 Subject: [PATCH 03/64] book(A3): Document settings modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wires the full Document settings modal for book mode. Every field from `book.spec` is editable, applies live on Apply, and persists via the existing autosave. - New `#bookDocumentSettingsModal` markup: - Book size dropdown with five named presets (US Trade, US Letter, A5, A4, Square 8×8) plus Custom; selecting a preset locks the trim inputs to the right dimensions, Custom unlocks them. - Trim width / height (inches). - Bleed (default 0.125). - Paper thickness (in / page). - "Derive spine width from page count × paper thickness" checkbox; manual spine input shown only when unchecked. A live note below shows the computed spine ("Spine = N pages × T in = D in"). - Cover layout toggles: include spine, include back panel. - Page numbering: enabled toggle reveals start-at, first- numbered-page (manifest index), and a format string field with a `{n}` placeholder hint. - New JS helpers: - `BOOK_SIZE_PRESETS` table (one source of truth for the five preset sizes). - `openBookDocumentSettings()` / `closeBookDocumentSettings()` matching the existing publish-confirm modal lifecycle. - `_syncBookSettingsSpineNote()` updates the derived-spine note on paper-thickness / from-pages toggle changes. - `_syncBookSettingsPresetLock()` locks / unlocks trim inputs when the preset dropdown changes. - `applyBookDocumentSettings()` writes every field back to `state.book.spec`, pushes a single history entry, then runs `syncInputs()` + `render()` so the sidebar summary refreshes. - Sidebar "Document settings…" button is now enabled (was disabled placeholder in A2) and wired to the new modal. - Esc-to-close hooked into the existing global Escape handler next to publishConfirm. Click-outside-to-close handled by the same delegation pattern other modals use. --- src/index.html | 216 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 215 insertions(+), 1 deletion(-) diff --git a/src/index.html b/src/index.html index 3d2c3b4..2d30c68 100644 --- a/src/index.html +++ b/src/index.html @@ -3216,7 +3216,7 @@

A book combines a cover spread (front + spine + back), an ordered list of designed pages, optional reflowable chapters written as NIP-23 markdown, and master pages for shared running heads and page numbers. See docs/BOOK_DESIGNER_FEATURE.md for the full spec.

- +

Document settings, Pages overview, Cover view, and PDF export ship in subsequent phases. This entry is the Phase A2 skeleton.

@@ -4768,6 +4768,91 @@ + + @@ -8274,6 +8348,87 @@ if (cover) cover.style.display = showCover ? '' : 'none'; if (tPages) tPages.classList.toggle('active', !showCover); if (tCover) tCover.classList.toggle('active', showCover); + if (showCover) renderBookCoverPreview(); + } + + // Cover spread preview. Lays out back / spine / front panels with + // pixel widths proportional to their spec dimensions. The spine + // width derives from page count × paper thickness when + // spineFromPages is on (the existing `bookSpineWidthInches()` + // helper handles either mode). A readout above the spread shows + // every spec value so the user sees the math behind the geometry. + function renderBookCoverPreview() { + const wrap = document.getElementById('bookCoverPreview'); + const summary = document.getElementById('bookCoverSummary'); + const book = state.book; + if (!wrap || !summary || !book) return; + const s = book.spec; + const spineW = bookSpineWidthInches(); + const pageCount = bookPageCount(); + const trimW = Number(s.trimW) || 0; + const trimH = Number(s.trimH) || 0; + const bleed = Number(s.bleed) || 0; + const hasSpine = !!s.coverHasSpine; + const hasBack = !!s.coverHasBack; + + // Total spread width = (back?) + (spine?) + front, each + // including its own outer bleed; the inner edges between + // panels touch (no double-bleed). + const totalW = (hasBack ? trimW + bleed : 0) + + (hasSpine ? spineW : 0) + + (trimW + bleed) + + (hasBack ? 0 : bleed); + const totalH = trimH + bleed * 2; + + // Summary block. + const fmt = (n) => Number(n).toFixed(n % 1 === 0 ? 0 : 3); + summary.innerHTML = [ + ['Book size', `${s.bookSize || 'Custom'}`], + ['Trim', `${fmt(trimW)} × ${fmt(trimH)} in`], + ['Bleed', `${fmt(bleed)} in / edge`], + ['Spine', `${fmt(spineW)} in`, + s.spineFromPages + ? `derived: ${pageCount} pages × ${fmt(s.paperThickness)} in` + : 'manual' + ], + ['Spread', `${fmt(totalW)} × ${fmt(totalH)} in`, + hasSpine ? (hasBack ? 'back + spine + front' : 'spine + front') : (hasBack ? 'back + front' : 'front only') + ], + ['Pages', `${pageCount}`], + ].map(([dt, dd, note]) => ` +
+
${dt}
+ ${dd}${note ? `${note}` : ''} +
+ `).join(''); + + // Spread preview. Scale to fit in ~720 px wide; min 200 px. + const previewMaxPx = 720; + const pxPerIn = totalW > 0 ? Math.min(previewMaxPx / totalW, 60) : 60; + const heightPx = Math.max(40, totalH * pxPerIn); + wrap.innerHTML = ''; + + const addPanel = (cls, label, widthIn, dim) => { + const panel = document.createElement('div'); + panel.className = `bookCoverPanel ${cls}`; + panel.style.width = `${widthIn * pxPerIn}px`; + panel.style.height = `${heightPx}px`; + const lab = document.createElement('div'); + lab.className = 'bookCoverPanelLabel'; + lab.textContent = label; + panel.appendChild(lab); + if (dim) { + const dimEl = document.createElement('div'); + dimEl.className = 'bookCoverPanelDim'; + dimEl.textContent = dim; + panel.appendChild(dimEl); + } + wrap.appendChild(panel); + }; + + if (hasBack) addPanel('back', 'Back', trimW + bleed, `${fmt(trimW)} in`); + if (hasSpine && spineW > 0) addPanel('spine', 'Spine', spineW, `${fmt(spineW)} in`); + addPanel('front', 'Front', trimW + bleed, `${fmt(trimW)} in`); } function syncBookSidebarSummary() { @@ -12280,6 +12435,12 @@ updateBodyClasses(); renderDeckSorter(); renderBookPagesGrid(); + // 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' + && state.templateMode === 'book') { + renderBookCoverPreview(); + } renderCreditsPanel(); if (state.templateMode === 'pixelart') ensureStandalonePixelEditor(); // After updateBodyClasses so the designer view is displayed/laid out — From 2df533a93daba28ebb5670f5196fc2be3101acf6 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:49:36 -0400 Subject: [PATCH 07/64] book(B1): drag-reorder + duplicate on Pages overview MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tiles are now draggable; grabbing one and dropping on another swaps it into that position. Visual feedback: source tile fades to 50% opacity while dragging; the hovered drop target shows an accent-coloured outline. - New `moveBookPage(from, to)` splices the manifest array and pushes a single history entry; nothing else changes (anchors reference entry ids, so reordering doesn't break refs). - Per-tile hover overlay now has two actions instead of one: - `⎘` Duplicate — clones the entry with a fresh id, drops any anchor (the duplicate sits inserted-position by default), and appends "copy" to the label. - `×` Delete — unchanged, plus a soft danger hover state. - `attachBookPagesDnd()` is idempotent (guards via `data-dnd-attached`) so re-rendering the grid doesn't pile up listeners. --- src/index.html | 119 ++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 112 insertions(+), 7 deletions(-) diff --git a/src/index.html b/src/index.html index 40bc640..81906b2 100644 --- a/src/index.html +++ b/src/index.html @@ -1534,14 +1534,26 @@ position: absolute; top: 6px; left: 8px; font-size: .72rem; color: var(--muted); font-weight: 600; } - .bookPageTileDelete { + .bookPageTileActions { position: absolute; top: 4px; right: 4px; - width: 22px; height: 22px; border-radius: 0; - border: 1px solid var(--line); background: #fff; - cursor: pointer; font-size: .9rem; line-height: 1; + display: flex; gap: 4px; opacity: 0; transition: opacity .12s; } - .bookPageTile:hover .bookPageTileDelete { opacity: 1; } + .bookPageTile:hover .bookPageTileActions { opacity: 1; } + .bookPageTileAction { + width: 22px; height: 22px; padding: 0; border-radius: 0; + border: 1px solid var(--line); background: #fff; + cursor: pointer; font-size: .85rem; line-height: 1; + } + .bookPageTileAction.danger:hover { + background: var(--warn, #fee2e2); + border-color: var(--warn, #fca5a5); + } + .bookPageTile.dragging { opacity: .5; } + .bookPageTile.dragover { + outline: 2px solid var(--accent); + outline-offset: -2px; + } .bookPageTileLabel { font-size: .8rem; color: var(--muted); text-align: center; padding: 0 8px; @@ -8273,6 +8285,41 @@ syncInputs(); render(); } + // Duplicate a designed-page entry. The duplicate gets its own + // fresh manifest id; the underlying ref (if any) stays the same + // so two tiles point at one page event. Authors can break the + // share later by "Make local copy" in Phase D2. + function duplicateBookPage(entryId) { + const book = state.book; + if (!book) return; + const idx = book.pages.findIndex(p => p.id === entryId); + if (idx < 0) return; + pushHistory('book: duplicate page'); + const copy = JSON.parse(JSON.stringify(book.pages[idx])); + copy.id = makeBookEntryId(); + copy.label = copy.label ? copy.label + ' copy' : ''; + // Anchors on the original copy don't translate cleanly — clear + // them so the duplicate just sits at its inserted position. + copy.anchor = null; + book.pages.splice(idx + 1, 0, copy); + renderBookPagesGrid(); + syncInputs(); + render(); + } + // Reorder pages within the manifest. Drag-and-drop in the grid + // calls this. `to` is the destination index AFTER the source has + // been removed, matching the deck-sorter's contract. + function moveBookPage(from, to) { + const book = state.book; + if (!book) return; + if (from === to || from < 0 || from >= book.pages.length) return; + pushHistory('book: reorder pages'); + const [moved] = book.pages.splice(from, 1); + book.pages.splice(to, 0, moved); + renderBookPagesGrid(); + syncInputs(); + render(); + } // Render the Pages overview grid. Each tile is a placeholder // (the actual rendered-page thumbnail lands in Phase B once the @@ -8307,6 +8354,8 @@ const tile = document.createElement('div'); tile.className = `bookPageTile ${p.type === 'reflow' ? 'reflowSegment' : 'designedPage'}`; tile.dataset.entryId = p.id; + tile.dataset.index = String(idx); + tile.draggable = true; const numEl = document.createElement('div'); numEl.className = 'bookPageTileNum'; if ((numbering?.enabled !== false) && !p.skipNumber && idx >= firstNumberedIdx) { @@ -8316,16 +8365,32 @@ numEl.textContent = '—'; } tile.appendChild(numEl); + // Per-tile action group (top-right). Visible on hover only — + // matches the deck sorter's intent of "tiles read as artwork, + // chrome stays out of the way until you point at them". + const actions = document.createElement('div'); + actions.className = 'bookPageTileActions'; + const dup = document.createElement('button'); + dup.type = 'button'; + dup.className = 'bookPageTileAction'; + dup.title = 'Duplicate'; + dup.textContent = '⎘'; + dup.addEventListener('click', (e) => { + e.stopPropagation(); + duplicateBookPage(p.id); + }); + actions.appendChild(dup); const del = document.createElement('button'); del.type = 'button'; - del.className = 'bookPageTileDelete'; + del.className = 'bookPageTileAction danger'; del.title = 'Remove from book'; del.textContent = '×'; del.addEventListener('click', (e) => { e.stopPropagation(); deleteBookPage(p.id); }); - tile.appendChild(del); + actions.appendChild(del); + tile.appendChild(actions); const label = document.createElement('div'); label.className = 'bookPageTileLabel'; if (p.type === 'reflow') { @@ -8336,6 +8401,46 @@ tile.appendChild(label); grid.appendChild(tile); }); + attachBookPagesDnd(); + } + + // Drag-and-drop reorder on the Pages grid. Mirrors the deck + // sorter's pattern. `_bookPageDragFrom` carries the source index + // across the dragstart → dragover → drop chain. + let _bookPageDragFrom = null; + function attachBookPagesDnd() { + const grid = document.getElementById('bookPagesGrid'); + if (!grid || grid.dataset.dndAttached === '1') return; + grid.dataset.dndAttached = '1'; + grid.addEventListener('dragstart', (e) => { + const tile = e.target.closest('.bookPageTile'); + if (!tile) return; + _bookPageDragFrom = Number(tile.dataset.index); + tile.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + }); + grid.addEventListener('dragend', (e) => { + const tile = e.target.closest('.bookPageTile'); + if (tile) tile.classList.remove('dragging'); + grid.querySelectorAll('.dragover').forEach(el => el.classList.remove('dragover')); + _bookPageDragFrom = null; + }); + grid.addEventListener('dragover', (e) => { + const tile = e.target.closest('.bookPageTile'); + if (!tile || _bookPageDragFrom === null) return; + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + grid.querySelectorAll('.dragover').forEach(el => el.classList.remove('dragover')); + tile.classList.add('dragover'); + }); + grid.addEventListener('drop', (e) => { + const tile = e.target.closest('.bookPageTile'); + if (!tile || _bookPageDragFrom === null) return; + e.preventDefault(); + const to = Number(tile.dataset.index); + moveBookPage(_bookPageDragFrom, to); + _bookPageDragFrom = null; + }); } function setBookSubView(view) { From 70b270748e388ccb5b06ed334698b9d48d269ddf Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:52:41 -0400 Subject: [PATCH 08/64] book(B2): per-page editor + page-context bar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clicking a designed-page tile in the Pages overview now opens that page in the existing Custom Art canvas, bound to the entry-qualified `book-page:` target so layers stay scoped to one page across the manifest. Plumbing: - `state.book.editingEntryId` carries the active page id. Mirrored to the `editing-book-page` body class via updateBodyClasses(). - `currentDrawTarget()` returns `book-page:` when the book editor is active, so new layers drop on the right page. - `isLayerForCurrentMode()` only accepts targets prefixed with `book-page:` (or matching master target) — pages don't leak each other's layers, and the Pages overview shows no canvas layers (it doesn't render the sheet anyway). - New CSS: `body.template-book:not(.editing-book-page)` keeps the Pages / Cover view visible and hides the sheet + tool palette. Flipping to `.editing-book-page` restores them, mirroring the deck-sorter's pattern for slide editing. UI: - `openBookPage(entryId)` enters the editor; default tool resets to Select so users don't land mid-pen-draw. - `closeBookPage()` (also `← Back to Pages` button) returns to the overview. - `gotoBookPageRelative(±1)` walks only designed-page entries — reflow segments aren't editable on the per-page canvas, they open in the markdown editor that ships in C.5. - Page-context bar above the canvas shows `Page N / M [· label]`, prev / next arrows (auto-disable at the ends), and the back button. - Bar visibility is gated by both `template-book` and `editing-book-page` so it doesn't leak into other modes. Reflow tiles still render in the overview (struck through the spec in A4) but clicking them surfaces "Reflow chapter editor lands in Phase C.5." instead of opening the canvas. That keeps the routing honest until C.5 lands. --- src/index.html | 155 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 137 insertions(+), 18 deletions(-) diff --git a/src/index.html b/src/index.html index 81906b2..0705c0e 100644 --- a/src/index.html +++ b/src/index.html @@ -1467,18 +1467,19 @@ body.template-pixelart #layersFieldset, body.template-pixelart #printExportRow { display: none !important; } - /* Book mode (Phase A): the canvas-host is replaced by the - Pages overview / Cover view (see #bookView below). Hide the - sheet, tool palette, canvas toolbar, layers panel, export - fieldset — none of them apply until pages or a cover exist - under the book's own surfaces. */ - body.template-book #sheetWrap, - body.template-book #toolOptions, - body.template-book #canvasToolbar, - body.template-book #exportFieldset, - body.template-book #layersFieldset, - body.template-book #printExportRow, - body.template-book .toolPalette, + /* Book mode: the canvas-host is replaced by the Pages overview + / Cover view (see #bookView below). Hide the sheet, tool + palette, canvas toolbar, layers panel, export fieldset — + UNLESS a specific page is being edited (`editing-book-page` + class), in which case those panels return and #bookView + hides. Same pattern the deck sorter uses for the slide editor. */ + body.template-book:not(.editing-book-page) #sheetWrap, + body.template-book:not(.editing-book-page) #toolOptions, + body.template-book:not(.editing-book-page) #canvasToolbar, + body.template-book:not(.editing-book-page) #exportFieldset, + body.template-book:not(.editing-book-page) #layersFieldset, + body.template-book:not(.editing-book-page) #printExportRow, + body.template-book:not(.editing-book-page) .toolPalette, body.template-book .coverOnly, body.template-book .diskLayer, body.template-book .jewelLayer, @@ -1491,7 +1492,7 @@ /* Book view shell — mirrors the deck-sorter layout pattern. */ #bookView { display: none; } - body.template-book #bookView { + body.template-book:not(.editing-book-page) #bookView { display: flex; flex-direction: column; width: 100%; @@ -1500,6 +1501,20 @@ box-sizing: border-box; min-height: calc(100vh - 160px); } + /* Page-context bar above the canvas while editing a book page. */ + #bookPageEditBar { + display: none; + align-items: center; gap: 10px; + padding: 6px 12px; + background: var(--panel, #fff); + border-bottom: 1px solid var(--line); + font-size: .88rem; + } + body.template-book.editing-book-page #bookPageEditBar { display: flex; } + #bookPageEditBar button { + padding: 4px 10px; font-size: .85rem; + } + #bookPageEditLabel { font-weight: 600; } .bookViewToggleBar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding-bottom: 8px; border-bottom: 1px solid var(--line); @@ -4203,6 +4218,13 @@ + +
@@ -8399,6 +8421,9 @@ label.textContent = p.label || 'Designed page'; } tile.appendChild(label); + // Click the tile body to enter the per-page editor. Action + // buttons (delete/dup) stop propagation so they don't open. + tile.addEventListener('click', () => openBookPage(p.id)); grid.appendChild(tile); }); attachBookPagesDnd(); @@ -8443,6 +8468,80 @@ }); } + // ---- Per-page editor (Phase B2) ----------------------------- + // + // Click a tile in the Pages overview → open that page in the + // Custom Art canvas, bound to `book-page:`. The canvas + // is the existing #sheetWrap; in book mode we normally hide it + // but flip the `editing-book-page` body class to re-show it when + // a page is open. A small page-context bar above the canvas + // shows "Page N / M" + prev / next / Back to Pages. + function openBookPage(entryId) { + const book = ensureBookState(); + 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.'); + return; + } + book.editingEntryId = entryId; + document.body.classList.add('editing-book-page'); + clearLayerSelection?.(); + // Default the editor tool to Select so users land on a familiar + // pointer; the V/A split + tool palette still works as ever. + try { setEditorTool('select'); } catch {} + syncBookPageEditBar(); + syncInputs(); + render(); + } + function closeBookPage() { + const book = state.book; + if (book) book.editingEntryId = null; + document.body.classList.remove('editing-book-page'); + clearLayerSelection?.(); + syncBookPageEditBar(); + syncInputs(); + render(); + } + function gotoBookPageRelative(delta) { + const book = state.book; + if (!book || !book.editingEntryId) return; + // Walk only designed-page entries — reflow segments aren't + // editable on the per-page canvas yet. + const designedIdxs = book.pages + .map((p, i) => p.type === 'design' ? i : -1) + .filter(i => i >= 0); + const here = book.pages.findIndex(p => p.id === book.editingEntryId); + const inDesigned = designedIdxs.indexOf(here); + if (inDesigned < 0) return; + const nextDesignedIdx = designedIdxs[inDesigned + delta]; + if (nextDesignedIdx == null) return; + const nextEntry = book.pages[nextDesignedIdx]; + openBookPage(nextEntry.id); + } + function syncBookPageEditBar() { + const bar = document.getElementById('bookPageEditBar'); + const label = document.getElementById('bookPageEditLabel'); + const prev = document.getElementById('bookPageEditPrev'); + const next = document.getElementById('bookPageEditNext'); + if (!bar) return; + const book = state.book; + const editingId = book?.editingEntryId; + const editing = !!editingId && state.templateMode === 'book'; + bar.style.display = editing ? '' : 'none'; + if (!editing) return; + const pages = book.pages; + const designed = pages.filter(p => p.type === 'design'); + const idx = designed.findIndex(p => p.id === editingId); + const total = designed.length; + if (label) label.textContent = `Page ${idx + 1} / ${total}${(pages[idx]?.label || '') ? ' · ' + pages[idx].label : ''}`; + if (prev) prev.disabled = idx <= 0; + if (next) next.disabled = idx >= total - 1; + } + function setBookSubView(view) { const pages = document.getElementById('bookPagesView'); const cover = document.getElementById('bookCoverView'); @@ -8566,11 +8665,19 @@ if (mode === 'jewel') return t.startsWith('jewel-'); if (mode === 'cover') return t === 'cover-trim' || t === 'back' || t === 'spine' || t === 'front'; // Book mode targets are scoped to whichever surface the editor - // is bound to (a specific book-page, master, or cover panel). - // The active-target plumbing for Phase B will narrow this further - // by entry id; for now all book-* targets belong to book mode. - if (mode === 'book') return t === 'book-page' || t === 'book-master' - || t === 'book-cover-front' || t === 'book-cover-back' || t === 'book-spine'; + // is bound to. Targets are entry-id qualified + // (`book-page:`, `book-master:`) so multiple + // pages can coexist in `state.layers` without leaking onto each + // other. The active editing entry's id is what we filter on. + if (mode === 'book') { + const editId = state.book?.editingEntryId; + if (editId) { + return t === `book-page:${editId}` || t === `book-master:${editId}`; + } + // No page being edited yet — Pages overview is showing, so no + // canvas layers should render. + return false; + } return true; } @@ -9838,6 +9945,8 @@ document.body.classList.toggle('template-slide', state.templateMode === 'slide'); document.body.classList.toggle('template-deck', state.templateMode === 'deck'); document.body.classList.toggle('template-book', state.templateMode === 'book'); + document.body.classList.toggle('editing-book-page', + state.templateMode === 'book' && !!state.book?.editingEntryId); 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); @@ -10310,6 +10419,12 @@ } function currentDrawTarget() { if (state.appMode === 'designer') return 'designer-disc'; + // Book mode: when a specific page is open in the editor, new + // layers land on that page's entry-qualified target so they + // stay scoped to one page across the manifest. + if (state.templateMode === 'book' && state.book?.editingEntryId) { + return `book-page:${state.book.editingEntryId}`; + } return primaryTargetForMode(state.templateMode || 'cover'); } @@ -12540,6 +12655,7 @@ updateBodyClasses(); renderDeckSorter(); renderBookPagesGrid(); + syncBookPageEditBar(); // 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' @@ -31465,6 +31581,9 @@ on('bookAddTenBtn', 'click', addBookTenPages); on('bookViewTogglePages', 'click', () => setBookSubView('pages')); on('bookViewToggleCover', 'click', () => setBookSubView('cover')); + on('bookPageEditBack', 'click', closeBookPage); + on('bookPageEditPrev', 'click', () => gotoBookPageRelative(-1)); + on('bookPageEditNext', 'click', () => gotoBookPageRelative(1)); // Book Designer — Document settings modal. on('bookDocumentSettingsBtn', 'click', openBookDocumentSettings); From 01ecb8a85bfe1eede7070156b6d8947c2d4bce4c Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 2 Jun 2026 14:55:42 -0400 Subject: [PATCH 09/64] book: stop layer-creation paths from bouncing book mode to cover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adding a text layer on a book page kicked templateMode back to 'cover' and rendered the DVD case panels behind the book editor. Three layer-creation helpers didn't know about book-* targets: - applyTargetMode(target): the trailing else set templateMode = 'cover' for anything it didn't recognise. Book targets (book-page:, book-master:, book-cover-*, book-spine) now keep templateMode = 'book'. - targetBounds(target): the switch fell through to sheetW/sheetH for book targets, returning the wrong rectangle. Added a book-* branch that returns the page's trim rect (inset by bleed) for pages + masters, and the per-panel slice of the spread for cover panels. - updateLayerTargetOptions(): the #layerTarget