diff --git a/src/index.html b/src/index.html index 1f16398..ca7b41b 100644 --- a/src/index.html +++ b/src/index.html @@ -2058,7 +2058,7 @@ overset (red) → end of thread with overflow text */ .bookFramePort { position: absolute; - width: 10px; height: 10px; + width: 12px; height: 12px; background: #fff; border: 1.5px solid #475569; box-sizing: border-box; @@ -2066,11 +2066,28 @@ z-index: 50; pointer-events: auto; } - .bookFramePort.in { top: -6px; left: -6px; border-radius: 0; } - .bookFramePort.out { bottom: -6px; right: -6px; border-radius: 0; } + .bookFramePort.in { top: -7px; left: -7px; border-radius: 0; } + .bookFramePort.out { bottom: -7px; right: -7px; border-radius: 0; } .bookFramePort.linked { background: #16a34a; border-color: #15803d; } .bookFramePort.overset { background: #dc2626; border-color: #991b1b; } .bookFramePort:hover { box-shadow: 0 0 0 2px rgba(96,165,250,.6); } + /* Source port (the one the user clicked to start a link) pulses + so it stays visually anchored as the cursor wanders. */ + body.book-port-loading .bookFramePort.out.loadingSource { + box-shadow: 0 0 0 3px rgba(59,130,246,.9); + animation: bookFramePulse 1.2s infinite; + } + /* In ports highlight while a load is in progress so users see + where they can drop the link. */ + body.book-port-loading .bookFramePort.in { + box-shadow: 0 0 0 2px rgba(96,165,250,.55); + cursor: copy; + } + body.book-port-loading { cursor: copy; } + @keyframes bookFramePulse { + 0%,100% { box-shadow: 0 0 0 3px rgba(59,130,246,.9); } + 50% { box-shadow: 0 0 0 6px rgba(59,130,246,.35); } + } .bookFrameIdBadge { position: absolute; bottom: 4px; left: 4px; font-size: .58rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; @@ -22578,46 +22595,140 @@ ctx.restore(); } + // 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 + + // in-port highlights reflect the in-progress link operation. + let _bookPortLoading = null; // { layerId } | null + + function _findBookFrameLayer(predicate) { + for (const l of (state.layers || [])) { + if (l.type !== 'shape' || l.shape?.kind !== 'text-frame') continue; + if (predicate(l)) return l; + } + return null; + } + + // 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 _startBookPortLoad(layer) { + _bookPortLoading = { layerId: layer.id }; + document.body.classList.add('book-port-loading'); + render(); + } + function _cancelBookPortLoad() { + if (!_bookPortLoading) return; + _bookPortLoading = null; + document.body.classList.remove('book-port-loading'); + render(); + } + function _completeBookPortLink(targetLayer) { + if (!_bookPortLoading) return; + const sourceLayer = (state.layers || []).find(l => l.id === _bookPortLoading.layerId); + if (!sourceLayer || sourceLayer === targetLayer) { + _cancelBookPortLoad(); + return; + } + // Cycle guard — refuse if linking would close a loop. + if (_bookFrameChainContains(targetLayer, sourceLayer.frameId)) { + toastError?.('Refused — that link would create a thread cycle.'); + _cancelBookPortLoad(); + return; + } + pushHistory('book: link text frames'); + sourceLayer.nextFrame = { + target: String(targetLayer.target || ''), + frameId: targetLayer.frameId, + }; + _bookPortLoading = null; + document.body.classList.remove('book-port-loading'); + toastSuccess?.('Frames threaded.'); + render(); + } + function _breakBookFrameLink(layer) { + if (!layer?.nextFrame) return; + pushHistory('book: break text-frame thread'); + layer.nextFrame = null; + render(); + } + function _bookPortOnClick(layer, portKind, ev) { + ev.preventDefault(); + ev.stopPropagation(); + if (portKind === 'out') { + // Already linked? Click breaks the thread at this point. + if (layer.nextFrame?.frameId) { + _breakBookFrameLink(layer); + return; + } + // Already loading from this same port? Cancel. + if (_bookPortLoading?.layerId === layer.id) { + _cancelBookPortLoad(); + return; + } + _startBookPortLoad(layer); + return; + } + // In port click + if (_bookPortLoading) { + _completeBookPortLink(layer); + } + } + // Phase J3.a — attach in/out ports + a small frameId badge to a // text-frame layer's rendered wrapper. Reads thread-link state // by scanning state.layers (any text-frame whose nextFrame ref - // points at this layer counts as "linked in"). Click handling - // for port-to-port threading lands in J3.b; for now the ports - // are visible affordances only. + // points at this layer counts as "linked in"). J3.b adds the + // click handlers below for the load-cursor flow. function _attachBookFramePorts(el, layer) { // The frame might be a legacy text-frame layer that pre-dates // J2 — give it a frameId on the fly so the ports work. if (!layer.frameId) layer.frameId = makeBookEntryId(); - const target = String(layer.target || ''); 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? - let linkedIn = false; - for (const other of (state.layers || [])) { - if (other === layer) continue; - if (other.type !== 'shape' || other.shape?.kind !== 'text-frame') continue; - if (other.nextFrame?.frameId === myFrameId) { linkedIn = true; break; } - } + const incoming = _findBookFrameLayer(other => + other !== layer && other.nextFrame?.frameId === myFrameId); + const linkedIn = !!incoming; + const isLoadingSource = _bookPortLoading?.layerId === layer.id; + const inPort = document.createElement('div'); inPort.className = 'bookFramePort in' + (linkedIn ? ' linked' : ''); inPort.title = linkedIn - ? 'In port — text flows in from a linked previous frame.' - : 'In port — empty. Future: click an "out" port elsewhere first, then this to link.'; + ? `In port — text flows in from frame ⌗${String(incoming.frameId).slice(0,6)}.` + : (_bookPortLoading + ? 'In port — click to receive the loaded thread here.' + : 'In port — empty. Click an "out" port elsewhere first to load a thread, then click this.'); inPort.dataset.framePort = 'in'; inPort.dataset.frameId = myFrameId; + inPort.addEventListener('click', (ev) => _bookPortOnClick(layer, 'in', ev)); el.appendChild(inPort); + const outPort = document.createElement('div'); - outPort.className = 'bookFramePort out' + (linkedOut ? ' linked' : ''); + outPort.className = 'bookFramePort out' + + (linkedOut ? ' linked' : '') + + (isLoadingSource ? ' loadingSource' : ''); outPort.title = linkedOut - ? 'Out port — text continues into frame ' + String(layer.nextFrame.frameId).slice(0, 8) + '. Click to break the thread (J3.b+).' - : 'Out port — text ends here. Future: click to load and then click another frame\'s in port to link.'; + ? `Out port — text continues into frame ⌗${String(layer.nextFrame.frameId).slice(0,6)}. Click to break the thread.` + : (isLoadingSource + ? 'Out port — loaded; click an in port elsewhere to link, or click this again / press Esc to cancel.' + : 'Out port — text ends here. Click to load and then click another frame\'s in port to link.'); outPort.dataset.framePort = 'out'; outPort.dataset.frameId = myFrameId; + outPort.addEventListener('click', (ev) => _bookPortOnClick(layer, 'out', ev)); el.appendChild(outPort); - // Tiny id chip so the user can see which frameId a frame has - // (useful while authoring threads + debugging). Hidden behind - // a `.bookFrameIdBadge` class so we can dim / hide later. + const badge = document.createElement('div'); badge.className = 'bookFrameIdBadge'; badge.textContent = '⌗ ' + String(myFrameId).slice(0, 6); @@ -38227,6 +38338,10 @@ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeActionMenus(); if (e.key !== 'Escape') return; + // J3.b: Esc cancels an in-progress text-frame thread load. + // Runs before modal Esc handlers because the load-cursor + // shouldn't survive a modal close on the same keystroke. + if (_bookPortLoading) { _cancelBookPortLoad(); return; } const tipM = $('tipModal'); if (tipM && tipM.getAttribute('aria-hidden') === 'false') { closeTipModal(); return; } const pubConfirmM = $('publishConfirmModal');