From be9a33a44c027ecad9489acd661f94aaa11a71cc Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Thu, 4 Jun 2026 21:09:48 -0400 Subject: [PATCH] =?UTF-8?q?feat(book):=20text-frame=20inspector=20in=20the?= =?UTF-8?q?=20right=20panel=20=E2=80=94=20discoverable=20threading?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the port-click dance as the primary path for threading + chapter placement. Whenever a text-frame layer is the primary selection in book mode, a new #bookTextFrameOptions section appears in the right-side #toolOptions panel with: - Status line: 'Frame N of M in this thread' / 'Solo frame' / '(Head — chapter source goes here)'. - Chapter source dropdown — every reflow entry in book.pages. Only enabled when the selected frame is the head of its thread; mid-thread frames inherit and show a hint to that effect. - Incoming-from dropdown — every other text frame in the book that could safely link TO this one (no cycle). Selecting one sets the source frame's nextFrame to point here; selecting (none) clears whatever was linking in. - Outgoing-to dropdown — every other text frame this one could safely link to. Selecting one sets this frame's nextFrame; selecting (none) breaks the link. Both dropdowns share _enumerateBookTextFrames, which labels each candidate by its surface ('Page 3', 'Master · Standard interior') + its ⌗frameId. Cycle detection reuses _bookFrameChainContains so the dropdown only shows safe candidates. Force-shows the outer #toolOptions panel when the inspector is visible — the default toggling in syncToolPaletteUI hides it for the Select tool, but with a text-frame selected we want the inspector regardless of active tool. Port-click + Chapters-tab 'Place' button still work as alternative paths. The dropdown panel is the discoverable one. Co-Authored-By: Claude Opus 4.7 --- src/index.html | 209 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 209 insertions(+) diff --git a/src/index.html b/src/index.html index 26e2ed1..f33941e 100644 --- a/src/index.html +++ b/src/index.html @@ -5233,6 +5233,27 @@

Click the canvas to place a text box. With a text box selected, these controls restyle it.

+ + +
@@ -17574,6 +17595,10 @@ && state.templateMode === 'book') { renderBookChaptersList(); } + // Phase J3.b polish: text-frame inspector — visible when a + // text-frame layer is selected on the canvas, regardless of + // tool. Self-hides when selection isn't a text-frame. + renderBookTextFrameInspector(); syncBookPageEditBar(); syncBookChapterEditor(); // Only repaint the cover preview when its view is the active @@ -22971,6 +22996,190 @@ el.appendChild(overlay); } + // J3.b polish — enumerate every text-frame layer in the book + // (any page or master target). Returns rich entries used by the + // text-frame inspector's Incoming / Outgoing dropdowns. + function _enumerateBookTextFrames(book) { + if (!book) return []; + const out = []; + const pageById = new Map((book.pages || []).map((p, i) => [p.id, { entry: p, index: i }])); + const masterById = new Map((book.masters || []).map(m => [m.id, m])); + for (const l of (state.layers || [])) { + if (l.type !== 'shape' || l.shape?.kind !== 'text-frame') continue; + const t = String(l.target || ''); + let context = ''; + if (t.startsWith('book-page:')) { + const pageId = t.slice('book-page:'.length); + const ref = pageById.get(pageId); + if (ref) { + const numbered = (book.pages || []).filter(p => p.type === 'design').findIndex(p => p.id === pageId); + context = numbered >= 0 ? `Page ${numbered + 1}` : 'Page'; + } else { context = 'Page (orphan)'; } + } else if (t.startsWith('book-master:')) { + const masterId = t.slice('book-master:'.length); + const m = masterById.get(masterId); + context = m ? `Master · ${m.label || 'Untitled'}` : 'Master (orphan)'; + } + out.push({ + layer: l, + frameId: l.frameId, + target: t, + label: `${context} · ⌗${String(l.frameId || '').slice(0, 6)}`, + }); + } + return out; + } + + // J3.b polish — render the right-panel text-frame inspector. Reads + // selection from state.selectedLayerId; the section is hidden when + // the selected layer isn't a text-frame in book mode. Wires + // dropdown change handlers each time so the closures always see + // the current frame. + function renderBookTextFrameInspector() { + const root = document.getElementById('bookTextFrameOptions'); + const outer = document.getElementById('toolOptions'); + if (!root) return; + const layer = _getSelectedBookTextFrame(); + if (!layer || state.templateMode !== 'book') { + root.style.display = 'none'; + return; + } + // Force the outer #toolOptions panel visible — the normal + // toggling in syncToolPaletteUI hides it for non-drawing tools + // (e.g., Select). With a text-frame selected we want the + // inspector regardless of active tool. + if (outer && outer.style.display === 'none') outer.style.display = ''; + root.style.display = ''; + const book = state.book || {}; + + // Status line: where in the chain is this frame? + const chain = _bookCollectThreadChain(layer); + const head = chain[0]; + const myIndex = chain.indexOf(layer); + const isHead = layer === head; + const status = document.getElementById('bookTextFrameStatus'); + if (status) { + if (chain.length > 1) { + status.textContent = `Frame ${myIndex + 1} of ${chain.length} in this thread.${isHead ? ' (Head — chapter source goes here.)' : ''}`; + } else { + status.textContent = 'Solo frame — link in or out to extend into a thread.'; + } + } + + // Source chapter dropdown — only meaningful on the head; the + // mid-chain frames inherit the chapter via the walk. We still + // show the dropdown but disable it when this isn't the head, + // and surface where the source actually lives. + const chapterSel = document.getElementById('bookTextFrameChapter'); + const chapterHint = document.getElementById('bookTextFrameChapterHint'); + if (chapterSel) { + const reflowEntries = (book.pages || []).filter(p => p.type === 'reflow'); + chapterSel.innerHTML = ''; + const noneOpt = document.createElement('option'); + noneOpt.value = ''; + noneOpt.textContent = '(none)'; + chapterSel.appendChild(noneOpt); + for (const e of reflowEntries) { + const opt = document.createElement('option'); + opt.value = e.id; + opt.textContent = e.chapterTitle || e.label || 'Untitled chapter'; + chapterSel.appendChild(opt); + } + chapterSel.value = (isHead ? (layer.sourceChapter || '') : (head?.sourceChapter || '')); + chapterSel.disabled = !isHead; + chapterSel.onchange = () => { + if (!isHead) return; + pushHistory('book: assign chapter source'); + layer.sourceChapter = chapterSel.value || null; + render(); + renderBookChaptersList(); + }; + if (chapterHint) { + chapterHint.textContent = isHead + ? (reflowEntries.length ? 'Chapter feeds this thread.' : 'No chapters yet. Add one in the Chapters tab.') + : 'Mid-thread frames inherit the chapter from the head.'; + } + } + + // Incoming / Outgoing dropdowns enumerate every other text-frame + // in the book, label them by page / master + frameId, and only + // include candidates whose link wouldn't create a cycle. + const allFrames = _enumerateBookTextFrames(book); + const fillFrameOptions = (sel, currentFrameId, candidatePredicate) => { + sel.innerHTML = ''; + const none = document.createElement('option'); + none.value = ''; + none.textContent = '(none)'; + sel.appendChild(none); + for (const f of allFrames) { + if (f.layer === layer) continue; + if (!candidatePredicate(f)) continue; + const opt = document.createElement('option'); + opt.value = f.frameId; + opt.textContent = f.label; + sel.appendChild(opt); + } + sel.value = currentFrameId || ''; + }; + + const inSel = document.getElementById('bookTextFrameInLink'); + const currentInbound = _findBookFrameLayer(o => o !== layer && o.nextFrame?.frameId === layer.frameId); + if (inSel) { + // Candidate predicate: a frame F can link TO `layer` if doing + // so wouldn't put F inside the chain that descends from + // `layer` (which would mean layer → ... → F → layer = cycle). + fillFrameOptions(inSel, currentInbound?.frameId || '', (f) => { + // F.nextFrame = layer.frameId would create a cycle if + // `layer` already reaches F through its forward chain. + return !_bookFrameChainContains(layer, f.frameId); + }); + inSel.onchange = () => { + pushHistory('book: change incoming link'); + // Clear any previous incoming pointer to this frame. + if (currentInbound && currentInbound !== layer) currentInbound.nextFrame = null; + const newFrameId = inSel.value || ''; + if (newFrameId) { + const src = allFrames.find(f => f.frameId === newFrameId); + if (src) src.layer.nextFrame = { target: layer.target, frameId: layer.frameId }; + } + render(); + }; + } + + const outSel = document.getElementById('bookTextFrameOutLink'); + if (outSel) { + // Candidate predicate: layer can link OUT to F if F isn't + // already reachable from layer through its forward chain + // (i.e., F can't be on the chain that descends from layer). + fillFrameOptions(outSel, layer.nextFrame?.frameId || '', (f) => { + // Reject if F is already in the head→tail chain that + // includes layer (cycle), but allow F if F is the head and + // we're a tail — that's still a cycle. The cleanest check: + // simulate the link and walk forward, refuse if we loop. + return !_bookFrameChainContains(f.layer, layer.frameId); + }); + outSel.onchange = () => { + pushHistory('book: change outgoing link'); + const newFrameId = outSel.value || ''; + if (!newFrameId) { + layer.nextFrame = null; + } else { + const dest = allFrames.find(f => f.frameId === newFrameId); + if (dest) { + // If dest already has an incoming link from someone + // other than us, clear it so we don't end up with two + // frames pointing at the same destination. + const otherIncoming = _findBookFrameLayer(o => + o !== layer && o !== dest.layer && o.nextFrame?.frameId === dest.frameId); + if (otherIncoming) otherIncoming.nextFrame = null; + layer.nextFrame = { target: dest.target, frameId: dest.frameId }; + } + } + render(); + }; + } + } + // J5 — return the user's currently selected layer if it's a // text-frame in book mode, else null. Used by the Chapters tab // 'Place into selected frame' affordance.