// Backlog tree view: rows, expand/collapse, status cycling, drag handle, add/delete. const { useState, useRef, useEffect, useMemo } = React; // Inline priority popover function PriorityPicker({ value, onChange, onClose }) { const ref = useRef(null); useEffect(() => { const close = (e) => { if (!ref.current?.contains(e.target)) onClose(); }; document.addEventListener("mousedown", close); return () => document.removeEventListener("mousedown", close); }, [onClose]); return (
e.stopPropagation()}> {PRIORITIES.map(p => ( ))}
); } // Inline status popover (replaces ●●● menu) function StatusPopover({ value, style, onChange, onClose, anchorRect }) { const ref = useRef(null); useEffect(() => { const close = (e) => { if (!ref.current?.contains(e.target)) onClose(); }; document.addEventListener("mousedown", close); return () => document.removeEventListener("mousedown", close); }, [onClose]); return (
e.stopPropagation()}> {STATUSES.map(s => ( ))}
); } // Right-click context menu: set status / archive / delete, fixed at the cursor. function ItemContextMenu({ item, statusStyle, anchor, onSetStatus, onArchive, onDelete, onClose }) { const ref = useRef(null); const [pos, setPos] = useState({ left: anchor.x, top: anchor.y }); // Viewport clamp (upstream ItemPeek pattern): flip left / above when overflowing. useEffect(() => { const el = ref.current; if (!el) return; const r = el.getBoundingClientRect(); const pad = 12, gap = 14; let left = anchor.x + gap, top = anchor.y + gap; if (left + r.width > window.innerWidth - pad) left = Math.max(pad, anchor.x - r.width - gap); if (top + r.height > window.innerHeight - pad) top = Math.max(pad, anchor.y - r.height - gap); setPos({ left, top }); }, [anchor.x, anchor.y, item]); // Capture-phase scroll/wheel: any scrollable container must close the cursor-anchored menu. useEffect(() => { const onPointerDown = (e) => { if (!ref.current?.contains(e.target)) onClose(); }; const onKeyDown = (e) => { if (e.key === "Escape") onClose(); }; const onMove = () => onClose(); document.addEventListener("pointerdown", onPointerDown); document.addEventListener("keydown", onKeyDown); document.addEventListener("scroll", onMove, true); document.addEventListener("wheel", onMove, true); window.addEventListener("resize", onMove); return () => { document.removeEventListener("pointerdown", onPointerDown); document.removeEventListener("keydown", onKeyDown); document.removeEventListener("scroll", onMove, true); document.removeEventListener("wheel", onMove, true); window.removeEventListener("resize", onMove); }; }, [onClose]); return (
e.preventDefault()}>
Set status
{STATUSES.map(s => ( ))}
); } // Single tree row function ItemRow({ item, depth, expanded, hasChildren, statusStyle, onToggle, onSetStatus, onAddChild, onDelete, onArchive, onEdit, onSetPriority, onMoveUp, onMoveDown, canMoveUp, canMoveDown, onDragStart, onDragOver, onDrop, dimmed, query, dropTarget }) { const [statusOpen, setStatusOpen] = useState(false); const [priOpen, setPriOpen] = useState(false); const [ctxMenu, setCtxMenu] = useState(null); const overdue = isOverdue(item); const isP0 = item.priority === "P0" && item.status !== "done" && item.status !== "cancelled"; const isDone = item.status === "done" || item.status === "cancelled"; const renderTitle = () => { if (!query) return item.title; const i = item.title.toLowerCase().indexOf(query.toLowerCase()); if (i < 0) return item.title; return (<> {item.title.slice(0, i)} {item.title.slice(i, i + query.length)} {item.title.slice(i + query.length)} ); }; return (
{ setCtxMenu(null); onDragStart?.(e, item); }} onDragOver={(e) => onDragOver?.(e, item)} onDrop={(e) => onDrop?.(e, item)} onContextMenu={(e) => { e.preventDefault(); setStatusOpen(false); setPriOpen(false); setCtxMenu({ x: e.clientX, y: e.clientY }); }} > {ctxMenu && ReactDOM.createPortal( setCtxMenu(null)}/>, document.body)} {statusOpen && ( setStatusOpen(false)}/> )} {priOpen && ( onSetPriority(p)} onClose={() => setPriOpen(false)}/> )} {renderTitle()} {shouldShowProgress(item) && ( )} {item.due && ( {dueRelative(item)} )} {item.tags?.length > 0 && ( {item.tags.map(t => #{t})} )} {item.status === "blocked" && item.reason && ( ↳ {item.reason} )} {depth < 3 && ( )}
); } // Expanded-state map kept in parent so filtering doesn't lose it. function BacklogTree({ items, expandedMap, setExpanded, onMutate, query, statusStyle, manualOrder }) { const [draggedId, setDraggedId] = useState(null); const [dropTargetId, setDropTargetId] = useState(null); const onDragStart = (e, item) => { setDraggedId(item.id); e.dataTransfer.effectAllowed = "move"; try { e.dataTransfer.setData("text/plain", item.id); } catch (_) {} }; const onDragOver = (e, item) => { if (!draggedId || draggedId === item.id) return; e.preventDefault(); e.dataTransfer.dropEffect = "move"; setDropTargetId(item.id); }; const onDrop = (e, item) => { e.preventDefault(); if (draggedId && draggedId !== item.id) { onMutate.reorder(draggedId, item.id); } setDraggedId(null); setDropTargetId(null); }; function renderList(list, depth, parentList) { return list.map((item, idx) => { const expanded = expandedMap[item.id] ?? !item.collapsed; const hasChildren = item.children?.length > 0; const forceOpen = query && item._matches !== true && hasChildren; const showKids = (expanded || forceOpen) && hasChildren; // can-move logic: only swap with adjacent same-priority sibling const samePri = parentList .map((x, i) => ({ x, i })) .filter(({ x }) => x.priority === item.priority); const myIdx = samePri.findIndex(({ x }) => x.id === item.id); const canMoveUp = myIdx > 0; const canMoveDown = myIdx >= 0 && myIdx < samePri.length - 1; return ( setExpanded(item.id, !expanded)} onSetStatus={(s) => onMutate.setStatus(item.id, s)} onSetPriority={(p) => onMutate.setPriority(item.id, p)} onAddChild={() => onMutate.addChild(item.id)} onDelete={() => onMutate.deleteItem(item.id)} onArchive={() => onMutate.archiveItem(item.id)} onEdit={() => onMutate.editItem(item.id)} onMoveUp={() => onMutate.moveWithinPriority(item.id, -1)} onMoveDown={() => onMutate.moveWithinPriority(item.id, +1)} canMoveUp={canMoveUp} canMoveDown={canMoveDown} onDragStart={onDragStart} onDragOver={onDragOver} onDrop={onDrop} /> {showKids && renderList(item.children, depth + 1, item.children)} ); }); } return
{renderList(items, 0, items)}
; } window.BacklogTree = BacklogTree;