mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-11-11 08:00:08 +00:00
refactor(book): J3-data — threading moves from inline layer fields to book.threads[]
Source of truth for thread topology is now book.threads[] — a
top-level collection on the book manifest. Each thread:
{ id, sourceChapter, frames: [{ target, frameId }, ...] }
Layers no longer carry layer.nextFrame or layer.sourceChapter; the
thread collection is the canonical store. Aligns with the spec's
'events as linked files' model: thread graphs live in the manifest,
visible to the recipient, round-trippable.
What changed:
- New thread API: setFrameOutgoing, clearFrameOutgoing,
setFrameSourceChapter, _findThreadForFrame, _frameIndexInThread.
All write helpers handle the rebalancing dance (splitting threads,
re-stitching, dropping empty single-frame threads).
- One-time migrator migrateBookThreadingToCollection — runs from
ensureBookState on every state restore. Idempotent. Converts any
legacy inline nextFrame / sourceChapter into book.threads[] then
deletes the inline fields. Existing books migrate transparently
the first time they're opened after the upgrade.
- _bookCollectThreadChain: now reads thread directly from
book.threads[]. The backward / forward walk over inline links is
gone — much simpler.
- _bookFrameChainContains: same — reads from threads.
- All readers updated to ask threads[] for the answer:
_attachBookFramePorts (link state + chip), the editor canvas text
painter, the tile thumbnail / PDF canvas pipeline, the Chapters
tab Place button + Place helper, the inspector dropdowns +
diagnostic status.
- All writers updated to call the new helpers instead of writing
to layer.nextFrame / layer.sourceChapter:
_completeBookPortLink → setFrameOutgoing
_breakBookFrameLink → clearFrameOutgoing
inspector outgoing dropdown → setFrameOutgoing / clearFrameOutgoing
inspector incoming dropdown → clearFrameOutgoing on old + setFrameOutgoing for new
inspector chapter dropdown → setFrameSourceChapter
Chapters-tab Place button + _placeChapterIntoSelectedFrame
chip × clear button → setFrameSourceChapter(null)
- bookManifestPayload + bookPayload (whole-book JSON) include
threads[]. Load path restores them.
The migrator + dual support means: legacy books in autosave/JSON/
Nostr round-trip cleanly. New writes go straight to threads[].
Cleaner manifest schema, smaller layer payloads, and the right
shape before merging to main.
Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
1 parent
3d74689eb0
commit
ee77820284
1 file changed
+324
-106
+324
-106
@@ -9189,6 +9189,12 @@
|
||||
// reflow entries yet).
|
||||
if (!Array.isArray(state.book.links)) state.book.links = [];
|
||||
if (!Array.isArray(state.book.threads)) state.book.threads = [];
|
||||
// J3-data-migration — one-time conversion of inline
|
||||
// layer.nextFrame / layer.sourceChapter fields into the
|
||||
// book.threads[] collection. Idempotent; only does work when
|
||||
// legacy inline fields are present.
|
||||
try { migrateBookThreadingToCollection(state.book); }
|
||||
catch (err) { console.warn('book threading migration failed', err); }
|
||||
return state.book;
|
||||
}
|
||||
// Physical page count — manifest entries except reflow segments,
|
||||
@@ -12788,6 +12794,15 @@
|
||||
cover: book.cover ? { ref: cleanRef(book.cover.ref) } : null,
|
||||
pages: cleanPages,
|
||||
masters: cleanMasters,
|
||||
// J3-data-migration: thread topology lives in the manifest.
|
||||
// Each thread = { id, sourceChapter, frames: [{ target, frameId }] }.
|
||||
// A receiver replays threads → text flows through the chain
|
||||
// exactly as the author intended.
|
||||
threads: Array.isArray(book.threads) ? book.threads.map(t => ({
|
||||
id: t.id,
|
||||
sourceChapter: t.sourceChapter || null,
|
||||
frames: (t.frames || []).map(f => ({ target: String(f.target || ''), frameId: f.frameId })),
|
||||
})) : [],
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -13727,7 +13742,10 @@
|
||||
const placeBtn = document.createElement('button');
|
||||
placeBtn.type = 'button';
|
||||
const selFrame = _getSelectedBookTextFrame();
|
||||
const selIsHead = selFrame && !_findBookFrameLayer(o => o !== selFrame && o.nextFrame?.frameId === selFrame.frameId);
|
||||
// J3-data-migration: head + current chapter read from threads[].
|
||||
const selThread = selFrame ? _findThreadForFrame(state.book, selFrame.frameId) : null;
|
||||
const selIsHead = !selFrame || !selThread || selThread.frames[0]?.frameId === selFrame.frameId;
|
||||
const selSourceChapter = selThread?.sourceChapter || null;
|
||||
placeBtn.textContent = '📥 Place in selected frame';
|
||||
if (!selFrame) {
|
||||
placeBtn.disabled = true;
|
||||
@@ -13735,7 +13753,7 @@
|
||||
} else if (!selIsHead) {
|
||||
placeBtn.disabled = true;
|
||||
placeBtn.title = 'The selected frame is mid-thread. Place a chapter on the head (the frame with no incoming link) to flow through the whole chain.';
|
||||
} else if (selFrame.sourceChapter === entry.id) {
|
||||
} else if (selSourceChapter === entry.id) {
|
||||
placeBtn.disabled = true;
|
||||
placeBtn.title = 'This chapter is already placed on the selected frame.';
|
||||
} else {
|
||||
@@ -23197,6 +23215,196 @@
|
||||
ctx.restore();
|
||||
}
|
||||
|
||||
// J3-data-migration — thread collection API. book.threads[] is
|
||||
// the canonical store for text-frame threading (replaces the
|
||||
// inline layer.nextFrame + layer.sourceChapter we had through
|
||||
// J4.d). Each thread:
|
||||
// { id, sourceChapter, frames: [{ target, frameId }] }
|
||||
//
|
||||
// sourceChapter is the entry.id of a reflow chapter that flows
|
||||
// through this thread, or null.
|
||||
//
|
||||
// frames is the ordered chain — frames[0] is the head.
|
||||
// A frame appears in at most one thread.
|
||||
//
|
||||
// Solo frames (no incoming or outgoing link, no chapter source)
|
||||
// are NOT represented in book.threads[].
|
||||
function _findThreadForFrame(book, frameId) {
|
||||
if (!book || !frameId) return null;
|
||||
return (book.threads || []).find(t =>
|
||||
(t.frames || []).some(f => f.frameId === frameId)
|
||||
) || null;
|
||||
}
|
||||
function _frameIndexInThread(thread, frameId) {
|
||||
if (!thread || !frameId) return -1;
|
||||
return (thread.frames || []).findIndex(f => f.frameId === frameId);
|
||||
}
|
||||
function _layerIsThreadHead(book, layer) {
|
||||
const t = _findThreadForFrame(book, layer?.frameId);
|
||||
return !!(t && t.frames[0]?.frameId === layer.frameId);
|
||||
}
|
||||
// Set sourceFrame's outgoing link to destFrame. Both may already
|
||||
// be in different threads; this stitches them together, splitting
|
||||
// destFrame's old thread + truncating sourceFrame's tail. The
|
||||
// result: a single thread that begins at sourceFrame's head and
|
||||
// ends at destFrame's tail.
|
||||
function setFrameOutgoing(sourceFrame, destFrame) {
|
||||
const book = ensureBookState();
|
||||
if (!sourceFrame.frameId) sourceFrame.frameId = makeBookEntryId();
|
||||
if (!destFrame.frameId) destFrame.frameId = makeBookEntryId();
|
||||
if (sourceFrame.frameId === destFrame.frameId) return;
|
||||
// 1. Find both threads (may be the same or null).
|
||||
const sourceThread = _findThreadForFrame(book, sourceFrame.frameId);
|
||||
const destThread = _findThreadForFrame(book, destFrame.frameId);
|
||||
// 2. From dest's thread: keep frames from destFrame onward.
|
||||
// (dest becomes the head of an orphaned tail we'll attach.)
|
||||
let destTail = [{ target: String(destFrame.target || ''), frameId: destFrame.frameId }];
|
||||
if (destThread) {
|
||||
const destIdx = _frameIndexInThread(destThread, destFrame.frameId);
|
||||
if (destIdx >= 0) destTail = destThread.frames.slice(destIdx);
|
||||
// Trim dest's old thread to just the frames BEFORE dest.
|
||||
if (destThread === sourceThread) {
|
||||
// Same thread — we're going to rebuild it below.
|
||||
} else {
|
||||
destThread.frames = destIdx > 0 ? destThread.frames.slice(0, destIdx) : [];
|
||||
// If the dest-side thread is now empty (and unsourced), remove it.
|
||||
if (!destThread.frames.length && !destThread.sourceChapter) {
|
||||
book.threads = book.threads.filter(t => t !== destThread);
|
||||
}
|
||||
}
|
||||
}
|
||||
// 3. From source's thread: keep frames up to and including sourceFrame.
|
||||
let sourceHead = sourceThread;
|
||||
if (!sourceHead) {
|
||||
sourceHead = {
|
||||
id: makeBookEntryId(),
|
||||
sourceChapter: null,
|
||||
frames: [{ target: String(sourceFrame.target || ''), frameId: sourceFrame.frameId }],
|
||||
};
|
||||
book.threads.push(sourceHead);
|
||||
} else {
|
||||
const srcIdx = _frameIndexInThread(sourceHead, sourceFrame.frameId);
|
||||
if (srcIdx >= 0) sourceHead.frames = sourceHead.frames.slice(0, srcIdx + 1);
|
||||
}
|
||||
// 4. Append destTail.
|
||||
for (const f of destTail) sourceHead.frames.push(f);
|
||||
}
|
||||
// Clear sourceFrame's outgoing link by truncating its thread
|
||||
// after sourceFrame. The downstream tail becomes its own thread
|
||||
// (orphaned head = the first frame that was downstream).
|
||||
function clearFrameOutgoing(sourceFrame) {
|
||||
const book = ensureBookState();
|
||||
const thread = _findThreadForFrame(book, sourceFrame.frameId);
|
||||
if (!thread) return;
|
||||
const idx = _frameIndexInThread(thread, sourceFrame.frameId);
|
||||
if (idx < 0) return;
|
||||
const tail = thread.frames.slice(idx + 1);
|
||||
thread.frames = thread.frames.slice(0, idx + 1);
|
||||
// Tail becomes its own thread (no chapter source — that lives
|
||||
// on the original head).
|
||||
if (tail.length) {
|
||||
book.threads.push({
|
||||
id: makeBookEntryId(),
|
||||
sourceChapter: null,
|
||||
frames: tail,
|
||||
});
|
||||
}
|
||||
// Single-frame, unsourced thread → drop it.
|
||||
if (thread.frames.length <= 1 && !thread.sourceChapter) {
|
||||
book.threads = book.threads.filter(t => t !== thread);
|
||||
}
|
||||
}
|
||||
// Set the chapter source on the thread containing headFrame.
|
||||
// Only meaningful on the head (frames[0]). If the frame isn't in
|
||||
// a thread yet, create a single-frame thread to hold the source.
|
||||
function setFrameSourceChapter(headFrame, chapterEntryId) {
|
||||
const book = ensureBookState();
|
||||
if (!headFrame.frameId) headFrame.frameId = makeBookEntryId();
|
||||
let thread = _findThreadForFrame(book, headFrame.frameId);
|
||||
if (!thread) {
|
||||
thread = {
|
||||
id: makeBookEntryId(),
|
||||
sourceChapter: null,
|
||||
frames: [{ target: String(headFrame.target || ''), frameId: headFrame.frameId }],
|
||||
};
|
||||
book.threads.push(thread);
|
||||
}
|
||||
// Source only applies to the head.
|
||||
if (thread.frames[0]?.frameId !== headFrame.frameId) return false;
|
||||
thread.sourceChapter = chapterEntryId || null;
|
||||
// If the thread is now empty of meaning, drop it.
|
||||
if (!thread.sourceChapter && thread.frames.length <= 1) {
|
||||
book.threads = book.threads.filter(t => t !== thread);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// One-time migrator: convert legacy inline layer.nextFrame +
|
||||
// layer.sourceChapter into book.threads[]. Idempotent — runs from
|
||||
// ensureBookState; if there's nothing to migrate (no inline data
|
||||
// present on any text frame), it's a no-op.
|
||||
function migrateBookThreadingToCollection(book) {
|
||||
if (!book) return;
|
||||
if (!Array.isArray(book.threads)) book.threads = [];
|
||||
const frames = (state.layers || []).filter(l =>
|
||||
l.type === 'shape' && l.shape?.kind === 'text-frame');
|
||||
if (!frames.length) return;
|
||||
// Anything to migrate? Look for either inline field.
|
||||
let needs = false;
|
||||
for (const l of frames) {
|
||||
if (l.nextFrame || l.sourceChapter) { needs = true; break; }
|
||||
}
|
||||
if (!needs) return;
|
||||
// Build a set of frameIds already represented in threads so we
|
||||
// don't duplicate.
|
||||
const alreadyInThreads = new Set();
|
||||
for (const t of book.threads) {
|
||||
for (const f of (t.frames || [])) alreadyInThreads.add(f.frameId);
|
||||
}
|
||||
const processed = new Set(alreadyInThreads);
|
||||
for (const layer of frames) {
|
||||
if (!layer.frameId) layer.frameId = makeBookEntryId();
|
||||
if (processed.has(layer.frameId)) continue;
|
||||
// Walk back to head via inline links.
|
||||
let head = layer;
|
||||
const seenBack = new Set([layer.id]);
|
||||
while (true) {
|
||||
const prev = frames.find(o => o !== head && o.nextFrame?.frameId === head.frameId);
|
||||
if (!prev || seenBack.has(prev.id)) break;
|
||||
seenBack.add(prev.id);
|
||||
head = prev;
|
||||
}
|
||||
// Walk forward from head.
|
||||
const chain = [head];
|
||||
const seenFwd = new Set([head.id]);
|
||||
let cursor = head;
|
||||
while (cursor.nextFrame?.frameId) {
|
||||
const nxt = frames.find(o => o.frameId === cursor.nextFrame.frameId);
|
||||
if (!nxt || seenFwd.has(nxt.id)) break;
|
||||
seenFwd.add(nxt.id);
|
||||
chain.push(nxt);
|
||||
cursor = nxt;
|
||||
}
|
||||
// Only materialize a thread when the chain is non-trivial
|
||||
// (more than one frame) OR the head holds a chapter source.
|
||||
const hasSource = !!head.sourceChapter;
|
||||
const hasChain = chain.length > 1;
|
||||
if (hasSource || hasChain) {
|
||||
book.threads.push({
|
||||
id: makeBookEntryId(),
|
||||
sourceChapter: head.sourceChapter || null,
|
||||
frames: chain.map(f => ({ target: String(f.target || ''), frameId: f.frameId })),
|
||||
});
|
||||
}
|
||||
for (const f of chain) processed.add(f.frameId);
|
||||
}
|
||||
// Clear inline fields — book.threads[] is now the source of truth.
|
||||
for (const layer of frames) {
|
||||
if ('nextFrame' in layer) delete layer.nextFrame;
|
||||
if ('sourceChapter' in layer) delete layer.sourceChapter;
|
||||
}
|
||||
}
|
||||
|
||||
// Phase J3 — module-level state for the port "loaded cursor"
|
||||
// pattern. When the user clicks an empty out-port we store its
|
||||
// layer reference here and flip a body class so the cursor +
|
||||
@@ -23214,14 +23422,20 @@
|
||||
// Cycle detection — walking from `startLayer` along nextFrame
|
||||
// refs, does the chain ever hit `targetFrameId`? Used to refuse
|
||||
// links that would create a loop (A → B → C, then linking C → A).
|
||||
function _bookFrameChainContains(startLayer, targetFrameId, depth = 0) {
|
||||
if (!startLayer || depth > 1000) return false;
|
||||
if (startLayer.frameId === targetFrameId) return true;
|
||||
const next = startLayer.nextFrame;
|
||||
if (!next?.frameId) return false;
|
||||
const nextLayer = _findBookFrameLayer(l => l.frameId === next.frameId);
|
||||
if (!nextLayer) return false;
|
||||
return _bookFrameChainContains(nextLayer, targetFrameId, depth + 1);
|
||||
function _bookFrameChainContains(startLayer, targetFrameId) {
|
||||
// J3-data-migration: cycle guard now reads from book.threads[].
|
||||
// True if targetFrameId is anywhere from startLayer onward in
|
||||
// its thread (used by the inspector dropdowns to refuse
|
||||
// self-loops).
|
||||
if (!startLayer || !targetFrameId) return false;
|
||||
const thread = _findThreadForFrame(state.book, startLayer.frameId);
|
||||
if (!thread) return startLayer.frameId === targetFrameId;
|
||||
const startIdx = _frameIndexInThread(thread, startLayer.frameId);
|
||||
if (startIdx < 0) return false;
|
||||
for (let i = startIdx; i < thread.frames.length; i++) {
|
||||
if (thread.frames[i].frameId === targetFrameId) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function _startBookPortLoad(layer) {
|
||||
@@ -23249,19 +23463,20 @@
|
||||
return;
|
||||
}
|
||||
pushHistory('book: link text frames');
|
||||
sourceLayer.nextFrame = {
|
||||
target: String(targetLayer.target || ''),
|
||||
frameId: targetLayer.frameId,
|
||||
};
|
||||
setFrameOutgoing(sourceLayer, targetLayer);
|
||||
_bookPortLoading = null;
|
||||
document.body.classList.remove('book-port-loading');
|
||||
toastSuccess?.('Frames threaded.');
|
||||
render();
|
||||
}
|
||||
function _breakBookFrameLink(layer) {
|
||||
if (!layer?.nextFrame) return;
|
||||
// True only when this frame has a downstream link in its thread.
|
||||
const thread = _findThreadForFrame(state.book, layer?.frameId);
|
||||
if (!thread) return;
|
||||
const idx = _frameIndexInThread(thread, layer.frameId);
|
||||
if (idx < 0 || idx >= thread.frames.length - 1) return;
|
||||
pushHistory('book: break text-frame thread');
|
||||
layer.nextFrame = null;
|
||||
clearFrameOutgoing(layer);
|
||||
render();
|
||||
}
|
||||
// J4.d — strip markdown formatting and collapse to a flat list
|
||||
@@ -23513,9 +23728,11 @@
|
||||
if (layer.type !== 'shape' || layer.shape?.kind !== 'text-frame') continue;
|
||||
const chain = _bookCollectThreadChain(layer);
|
||||
const head = chain[0];
|
||||
if (!head?.sourceChapter) continue;
|
||||
const headThread = head ? _findThreadForFrame(book, head.frameId) : null;
|
||||
const headSourceChapter = headThread?.sourceChapter || null;
|
||||
if (!headSourceChapter) continue;
|
||||
const myIndex = chain.indexOf(layer);
|
||||
const entry = (book.pages || []).find(p => p.id === head.sourceChapter);
|
||||
const entry = (book.pages || []).find(p => p.id === headSourceChapter);
|
||||
if (!entry) continue;
|
||||
const paragraphs = _flattenChapterToParagraphs(entry.chapterMarkdown || '');
|
||||
const typography = _getFrameTypography(head);
|
||||
@@ -23703,15 +23920,23 @@
|
||||
const head = chain[0];
|
||||
const myIndex = chain.indexOf(layer);
|
||||
const isHead = layer === head;
|
||||
// J3-data-migration: derive thread refs once, used below by
|
||||
// the chapter dropdown + the incoming/outgoing dropdowns.
|
||||
const myThread = _findThreadForFrame(book, layer.frameId);
|
||||
const myIdxInThread = myThread ? _frameIndexInThread(myThread, layer.frameId) : -1;
|
||||
const status = document.getElementById('bookTextFrameStatus');
|
||||
if (status) {
|
||||
// Diagnostic detail: chain summary so we can spot when the
|
||||
// walk doesn't find a downstream frame (e.g., frame across
|
||||
// pages that wasn't resolved or has a stale ref).
|
||||
let detail = '';
|
||||
if (head?.sourceChapter) {
|
||||
// J3-data-migration: chapter source comes from the head's
|
||||
// thread in book.threads[].
|
||||
const headThreadForDiag = head ? _findThreadForFrame(book, head.frameId) : null;
|
||||
const headSourceChapterForDiag = headThreadForDiag?.sourceChapter || null;
|
||||
if (headSourceChapterForDiag) {
|
||||
try {
|
||||
const entry = (book.pages || []).find(p => p.id === head.sourceChapter);
|
||||
const entry = (book.pages || []).find(p => p.id === headSourceChapterForDiag);
|
||||
if (entry) {
|
||||
const paragraphs = _flattenChapterToParagraphs(entry.chapterMarkdown || '');
|
||||
const typo = _getFrameTypography(head);
|
||||
@@ -23728,16 +23953,17 @@
|
||||
} else {
|
||||
status.textContent = `Solo frame — link in or out to extend into a thread.${detail}`;
|
||||
}
|
||||
// Warn the user (and the console) when the chain walk drops
|
||||
// a downstream frame. Most common cause: a stale nextFrame
|
||||
// ref pointing at a frameId that no longer exists (page or
|
||||
// frame was deleted).
|
||||
const expectedNext = layer.nextFrame?.frameId;
|
||||
if (expectedNext && !chain.includes(layer.nextFrame ? _findBookFrameLayer(o => o.frameId === expectedNext) : null)) {
|
||||
const found = _findBookFrameLayer(o => o.frameId === expectedNext);
|
||||
if (!found) {
|
||||
console.warn('[book threading] nextFrame ref points at a missing frameId:', expectedNext, '— clearing the broken link from layer', layer.id);
|
||||
status.textContent += ` ⚠ outgoing link is broken (target frame not found).`;
|
||||
// J3-data-migration: warn when a thread references a
|
||||
// frameId that doesn't exist in state.layers anymore (page or
|
||||
// frame deleted, leaving a stale entry in book.threads[]).
|
||||
if (myThread) {
|
||||
for (let i = 0; i < myThread.frames.length; i++) {
|
||||
const ref = myThread.frames[i];
|
||||
if (!_findBookFrameLayer(o => o.frameId === ref.frameId)) {
|
||||
console.warn('[book threading] thread refers to missing frameId:', ref.frameId);
|
||||
status.textContent += ` ⚠ thread has a broken link (missing frame ⌗${String(ref.frameId).slice(0,6)}).`;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23761,12 +23987,14 @@
|
||||
opt.textContent = e.chapterTitle || e.label || 'Untitled chapter';
|
||||
chapterSel.appendChild(opt);
|
||||
}
|
||||
chapterSel.value = (isHead ? (layer.sourceChapter || '') : (head?.sourceChapter || ''));
|
||||
// J3-data-migration: chapter source comes from the thread,
|
||||
// not inline layer fields.
|
||||
chapterSel.value = (myThread?.sourceChapter || '');
|
||||
chapterSel.disabled = !isHead;
|
||||
chapterSel.onchange = () => {
|
||||
if (!isHead) return;
|
||||
pushHistory('book: assign chapter source');
|
||||
layer.sourceChapter = chapterSel.value || null;
|
||||
setFrameSourceChapter(layer, chapterSel.value || null);
|
||||
render();
|
||||
renderBookChaptersList();
|
||||
};
|
||||
@@ -23798,25 +24026,26 @@
|
||||
sel.value = currentFrameId || '';
|
||||
};
|
||||
|
||||
// J3-data-migration: incoming + outgoing derived from
|
||||
// book.threads[]. myThread + myIdxInThread are hoisted above.
|
||||
const incomingRef = myThread && myIdxInThread > 0 ? myThread.frames[myIdxInThread - 1] : null;
|
||||
const outgoingRef = myThread && myIdxInThread >= 0 && myIdxInThread < myThread.frames.length - 1
|
||||
? myThread.frames[myIdxInThread + 1] : null;
|
||||
const currentInbound = incomingRef ? _findBookFrameLayer(o => o.frameId === incomingRef.frameId) : null;
|
||||
|
||||
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;
|
||||
// Clear any current incoming link (truncate predecessor's thread).
|
||||
if (currentInbound && currentInbound !== layer) clearFrameOutgoing(currentInbound);
|
||||
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 };
|
||||
if (src) setFrameOutgoing(src.layer, layer);
|
||||
}
|
||||
render();
|
||||
};
|
||||
@@ -23824,32 +24053,17 @@
|
||||
|
||||
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.
|
||||
fillFrameOptions(outSel, outgoingRef?.frameId || '', (f) => {
|
||||
return !_bookFrameChainContains(f.layer, layer.frameId);
|
||||
});
|
||||
outSel.onchange = () => {
|
||||
pushHistory('book: change outgoing link');
|
||||
const newFrameId = outSel.value || '';
|
||||
if (!newFrameId) {
|
||||
layer.nextFrame = null;
|
||||
clearFrameOutgoing(layer);
|
||||
} 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 };
|
||||
}
|
||||
if (dest) setFrameOutgoing(layer, dest.layer);
|
||||
}
|
||||
render();
|
||||
};
|
||||
@@ -23926,15 +24140,16 @@
|
||||
function _placeChapterIntoSelectedFrame(chapterEntryId) {
|
||||
const frame = _getSelectedBookTextFrame();
|
||||
if (!frame) { toastError?.('Select a text-frame layer on the canvas first.'); return; }
|
||||
// Refuse if the selected frame is mid-thread.
|
||||
const incoming = _findBookFrameLayer(o => o !== frame && o.nextFrame?.frameId === frame.frameId);
|
||||
if (incoming) {
|
||||
// J3-data-migration: head test reads from book.threads[].
|
||||
const thread = _findThreadForFrame(state.book, frame.frameId);
|
||||
const isMidThread = thread && thread.frames[0]?.frameId !== frame.frameId;
|
||||
if (isMidThread) {
|
||||
toastError?.('Selected frame is mid-thread. Place the chapter on the head frame (no incoming link).');
|
||||
return;
|
||||
}
|
||||
pushHistory('book: place chapter into frame');
|
||||
frame.sourceChapter = chapterEntryId;
|
||||
toastSuccess?.('Chapter placed. Real text flow lands in J4 — for now the frame shows a placeholder.');
|
||||
setFrameSourceChapter(frame, chapterEntryId);
|
||||
toastSuccess?.('Chapter placed.');
|
||||
renderBookChaptersList();
|
||||
render();
|
||||
}
|
||||
@@ -23960,37 +24175,18 @@
|
||||
});
|
||||
}
|
||||
function _bookCollectThreadChain(layer) {
|
||||
// Walk backwards to the head, then forward to the tail.
|
||||
// Returns layers in flow order (head → ... → tail).
|
||||
//
|
||||
// Bug fix: each walk needs its OWN seen-set. Sharing seen
|
||||
// between backward and forward walks meant the forward walk
|
||||
// tripped its cycle guard on the middle frames the backward
|
||||
// walk had already visited. A → B → C starting from C would
|
||||
// walk back through B + A (seen={B,A}), then the forward walk
|
||||
// from A would find B in `seen` and break — returning chain
|
||||
// [A] instead of [A, B, C].
|
||||
let head = layer;
|
||||
const seenBack = new Set([layer.id]);
|
||||
let guard = 0;
|
||||
while (guard++ < 1000) {
|
||||
const prev = _findBookFrameLayer(o => o !== head && o.nextFrame?.frameId === head.frameId);
|
||||
if (!prev || seenBack.has(prev.id)) break;
|
||||
seenBack.add(prev.id);
|
||||
head = prev;
|
||||
// J3-data-migration: chain now reads from book.threads[]
|
||||
// directly. Solo frames (not in any thread) return as a
|
||||
// single-element chain.
|
||||
if (!layer) return [];
|
||||
const thread = _findThreadForFrame(state.book, layer.frameId);
|
||||
if (!thread || !(thread.frames || []).length) return [layer];
|
||||
const out = [];
|
||||
for (const ref of thread.frames) {
|
||||
const l = _findBookFrameLayer(o => o.frameId === ref.frameId);
|
||||
if (l) out.push(l);
|
||||
}
|
||||
const chain = [head];
|
||||
const seenFwd = new Set([head.id]);
|
||||
let cursor = head;
|
||||
guard = 0;
|
||||
while (cursor.nextFrame?.frameId && guard++ < 1000) {
|
||||
const nxt = _findBookFrameLayer(o => o.frameId === cursor.nextFrame.frameId);
|
||||
if (!nxt || seenFwd.has(nxt.id)) break;
|
||||
seenFwd.add(nxt.id);
|
||||
chain.push(nxt);
|
||||
cursor = nxt;
|
||||
}
|
||||
return chain;
|
||||
return out.length ? out : [layer];
|
||||
}
|
||||
|
||||
function _bookPortOnClick(layer, portKind, ev) {
|
||||
@@ -24026,12 +24222,14 @@
|
||||
// J2 — give it a frameId on the fly so the ports work.
|
||||
if (!layer.frameId) layer.frameId = makeBookEntryId();
|
||||
const myFrameId = layer.frameId;
|
||||
// Has this frame been linked OUT to a next frame?
|
||||
const linkedOut = !!(layer.nextFrame && layer.nextFrame.frameId);
|
||||
// Has any other text-frame's nextFrame pointed AT this frame?
|
||||
const incoming = _findBookFrameLayer(other =>
|
||||
other !== layer && other.nextFrame?.frameId === myFrameId);
|
||||
const linkedIn = !!incoming;
|
||||
// J3-data-migration: derive link state from book.threads[].
|
||||
const myThread = _findThreadForFrame(state.book, myFrameId);
|
||||
const myIdxInThread = myThread ? _frameIndexInThread(myThread, myFrameId) : -1;
|
||||
const linkedIn = !!(myThread && myIdxInThread > 0);
|
||||
const linkedOut = !!(myThread && myIdxInThread >= 0 && myIdxInThread < myThread.frames.length - 1);
|
||||
const nextRef = linkedOut ? myThread.frames[myIdxInThread + 1] : null;
|
||||
const incomingRef = linkedIn ? myThread.frames[myIdxInThread - 1] : null;
|
||||
const incoming = incomingRef ? _findBookFrameLayer(o => o.frameId === incomingRef.frameId) : null;
|
||||
const isLoadingSource = _bookPortLoading?.layerId === layer.id;
|
||||
|
||||
// J3.b fix: stop pointerdown on the port from bubbling to the
|
||||
@@ -24092,9 +24290,12 @@
|
||||
: [layer];
|
||||
const head = chain[0];
|
||||
const myIndex = chain.indexOf(layer);
|
||||
if (head?.sourceChapter) {
|
||||
// J3-data-migration: chapter source lives on the thread.
|
||||
const headThread = head ? _findThreadForFrame(state.book, head.frameId) : null;
|
||||
const headSourceChapter = headThread?.sourceChapter || null;
|
||||
if (headSourceChapter) {
|
||||
const book = state.book;
|
||||
const entry = (book?.pages || []).find(p => p.id === head.sourceChapter);
|
||||
const entry = (book?.pages || []).find(p => p.id === headSourceChapter);
|
||||
if (entry) {
|
||||
const chip = document.createElement('div');
|
||||
chip.className = 'bookFrameSourceChip';
|
||||
@@ -24125,7 +24326,7 @@
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
pushHistory('book: unplace chapter from frame');
|
||||
head.sourceChapter = null;
|
||||
setFrameSourceChapter(head, null);
|
||||
render();
|
||||
renderBookChaptersList();
|
||||
});
|
||||
@@ -24146,7 +24347,7 @@
|
||||
// whole blocks jumping; markdown headings / lists /
|
||||
// bold-italic become regular prose (designer applies
|
||||
// formatting via the inspector).
|
||||
const entry = (state.book?.pages || []).find(p => p.id === head.sourceChapter);
|
||||
const entry = (state.book?.pages || []).find(p => p.id === headSourceChapter);
|
||||
const paragraphs = _flattenChapterToParagraphs(entry?.chapterMarkdown || '');
|
||||
// J4.d.2: head's typography is the chain's default; each
|
||||
// frame paints with its OWN typography (overrides allowed).
|
||||
@@ -28066,6 +28267,9 @@
|
||||
pages: book.pages || [],
|
||||
masters: book.masters || [],
|
||||
cover: book.cover || {},
|
||||
// J3-data-migration: thread topology survives the
|
||||
// whole-book JSON round-trip.
|
||||
threads: Array.isArray(book.threads) ? book.threads : [],
|
||||
},
|
||||
layerGroups: { ...ensureLayerGroups() },
|
||||
layers: nonGeneratedLayers().filter(l => {
|
||||
@@ -28979,9 +29183,11 @@
|
||||
if (layer.type !== 'shape' || layer.shape?.kind !== 'text-frame') continue;
|
||||
const chain = _bookCollectThreadChain(layer);
|
||||
const head = chain[0];
|
||||
if (!head?.sourceChapter) continue;
|
||||
const headThread = head ? _findThreadForFrame(book, head.frameId) : null;
|
||||
const headSourceChapter = headThread?.sourceChapter || null;
|
||||
if (!headSourceChapter) continue;
|
||||
const myIndex = chain.indexOf(layer);
|
||||
const entry = (book.pages || []).find(p => p.id === head.sourceChapter);
|
||||
const entry = (book.pages || []).find(p => p.id === headSourceChapter);
|
||||
if (!entry) continue;
|
||||
const paragraphs = _flattenChapterToParagraphs(entry.chapterMarkdown || '');
|
||||
// J4.d.2 — head typography is the fallback; each frame's own
|
||||
@@ -30875,6 +31081,18 @@
|
||||
cover: book.cover || null,
|
||||
masters: Array.isArray(book.masters) ? book.masters.map(m => ({ ...m })) : [],
|
||||
pages: Array.isArray(book.pages) ? book.pages.map(p => ({ ...p })) : [],
|
||||
// J3-data-migration: thread topology restored from
|
||||
// manifest / whole-book JSON.
|
||||
threads: Array.isArray(book.threads)
|
||||
? book.threads.map(t => ({
|
||||
id: t.id || makeBookEntryId(),
|
||||
sourceChapter: t.sourceChapter || null,
|
||||
frames: Array.isArray(t.frames)
|
||||
? t.frames.map(f => ({ target: String(f.target || ''), frameId: f.frameId }))
|
||||
: [],
|
||||
}))
|
||||
: [],
|
||||
links: Array.isArray(book.links) ? book.links.slice() : [],
|
||||
editingEntryId: null,
|
||||
editingMasterId: null,
|
||||
editingChapterId: null,
|
||||
|
||||
Reference in new issue
Block a user