import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { createPortal } from 'react-dom' import { CORNER, PAD, ROW_H, branchLabel, headLineage, layoutTree, momentTicks, savePointsUnder } from './branches' // The story tree, drawn. // // The Branches panel lists every line; this draws the same list as lanes on one // clock, which is the thing a list cannot say — *where* two tellings parted, and // how much story each of them is. A lane runs from the moment its branch left // its parent to the moment it ends, so length is story and a fork is a corner. // // It reads nothing of its own. Everything here comes from the `GET /branches` // the panel already made, and every operation goes back through the panel's, so // there is one copy of the rules and one thing to keep honest. // // Portalled to
: this is opened from inside `.side-panel`, whose panel-in // animation (fill mode `both`) makes it the containing block for position:fixed // children — an overlay rendered in place would be trapped in the 420px panel // and clipped by its overflow. Same trap for anything opened from a drawer. // A label is drawn into the space its lane leaves. `perChar` is the average // width of the face it is drawn in — about 7px for the 15px serif a name uses // and 5px for the 10.5px UI face under it. Overshooting only costs an ellipsis, // so this is deliberately a guess rather than a measurement pass. function clipToWidth(text, px, perChar = 7) { const max = Math.max(4, Math.floor(px / perChar)) return text.length <= max ? text : `${text.slice(0, max - 1)}…` } export function BranchMap({ branches, busyId, onSwitch, onRename, onDelete, onClose }) { const [selectedId, setSelectedId] = useState(() => branches.find((b) => b.is_head)?.id ?? null) const [renameText, setRenameText] = useState(null) // null = not renaming const [confirming, setConfirming] = useState(false) const [width, setWidth] = useState(0) const boxRef = useRef(null) const { lanes, maxDepth, height } = useMemo(() => layoutTree(branches), [branches]) const lineage = useMemo(() => headLineage(branches), [branches]) // The map is drawn in real pixels rather than scaled from a fixed viewBox, // so a narrow window gets a narrower map and not smaller writing. // // Both halves are load-bearing, and each covers the other's gap. The seed // measures the *content* box, because that is what the observer reports and // `clientWidth` is not — it counts the canvas padding, so seeding from it // drew an svg 24px wider than the box it sits in and the map ran off the // right edge. The observer then keeps up with a window being dragged; it // cannot be the only source, because its initial observation is not // guaranteed to arrive and without the seed the map never drew at all. useLayoutEffect(() => { const el = boxRef.current if (!el) return undefined const style = getComputedStyle(el) const padding = parseFloat(style.paddingLeft) + parseFloat(style.paddingRight) setWidth(el.clientWidth - padding) const observer = new ResizeObserver(([entry]) => setWidth(entry.contentRect.width)) observer.observe(el) return () => observer.disconnect() }, []) // A branch can vanish under the selection — deleting one takes everything // forked from it, which is more rows than the one that was clicked. const selected = branches.find((b) => b.id === selectedId) || null useEffect(() => { if (!selected) { setSelectedId(branches.find((b) => b.is_head)?.id ?? null) setRenameText(null) setConfirming(false) } }, [selected, branches]) useEffect(() => { const onKey = (e) => { if (e.key !== 'Escape') return // Escape backs out of the smallest thing that is open, so it never // throws away a half-typed name along with the map. if (renameText !== null) setRenameText(null) else if (confirming) setConfirming(false) else onClose() } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [renameText, confirming, onClose]) const W = Math.max(width, 320) const span = Math.max(maxDepth, 1) // Ticks are spaced by the room there is to print them in, not by a constant. const ticks = momentTicks(maxDepth, Math.max(3, Math.round(W / 160))) const x = (depth) => PAD.left + (depth / span) * (W - PAD.left - PAD.right) const laneY = (row) => PAD.top + row * ROW_H + 30 const pick = (branch) => { setSelectedId(branch.id) setRenameText(null) setConfirming(false) } const busy = busyId !== null && busyId !== undefined // The server refuses to delete the line being read or anything it was forked // from; saying so on the button is friendlier than a toast after the click. const isLoadBearing = selected ? lineage.has(selected.id) : false const isRoot = selected ? selected.parent_branch_id === null : false // Each of these resolves to whether it worked. The panel owns the request // and reports its own failures, so all this has to decide is whether to put // the editor away — clearing a half-typed name on a rename that was refused // would throw away the only copy of it. const save = async () => { if (await onRename(selected, renameText)) setRenameText(null) } const drop = async () => { if (await onDelete(selected)) setConfirming(false) } // Save Points naming a moment on the selected branch, or on anything forked // from it, keep it alive — the server refuses to delete them along with it. const protecting = selected ? savePointsUnder(branches, selected.id) : 0 return createPortal(One thread so far. Retry a turn, then write on from an attempt the story moved past — that is what makes a second line.
)} {selected && (