From 41d18d88aaba1146d09cbe1decd60dcadb8e192e Mon Sep 17 00:00:00 2001 From: Alexander Date: Fri, 28 Aug 2026 19:47:46 +0200 Subject: [PATCH] Add right-click context menu with status, archive, and delete actions Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent) Co-authored-by: Sisyphus --- web/app.jsx | 2 + web/styles.css | 23 ++++++ web/tree.jsx | 67 +++++++++++++++- webapp/index-style-v2.html | 158 ++++++++++++++++++++++++++++++++++++- 4 files changed, 245 insertions(+), 5 deletions(-) diff --git a/web/app.jsx b/web/app.jsx index dacef73..262c741 100644 --- a/web/app.jsx +++ b/web/app.jsx @@ -682,6 +682,8 @@ function App() { }, }); }, + + archiveItem: async (id) => { try { await flushPendingSave(); } catch (e) { showToast('Save failed: ' + e.message, 'err'); return; } handleArchiveItems([id]); }, }; // Which project does a NEW item belong to? An explicit hint (dialog select diff --git a/web/styles.css b/web/styles.css index 54de842..63c2207 100644 --- a/web/styles.css +++ b/web/styles.css @@ -1488,3 +1488,26 @@ input { font: inherit; color: inherit; } background: var(--paper); color: var(--ink); cursor: pointer; } + +/* ---------- Row context menu ---------- */ +.ctx-menu { + position: fixed; z-index: 60; + width: 200px; padding: 4px; + background: var(--paper); border: 1px solid var(--rule); + border-radius: var(--radius-lg); box-shadow: var(--shadow-pop); +} +.ctx-menu-label { + padding: 6px 8px 4px; + font-size: 11px; font-weight: 600; + color: var(--ink-3); text-transform: uppercase; letter-spacing: 0.06em; +} +.ctx-menu-item { + width: 100%; display: flex; align-items: center; gap: 8px; + padding: 6px 8px; border-radius: 4px; + font-size: 13px; color: var(--ink); text-align: left; +} +.ctx-menu-item:hover { background: var(--bg-2); } +.ctx-menu-item.active { background: var(--accent-soft); color: var(--accent-deep); } +.ctx-menu-item.danger { color: var(--danger); } +.ctx-menu-item.danger:hover { background: var(--danger-soft); } +.ctx-menu-sep { height: 1px; margin: 4px 6px; background: var(--rule-2); } diff --git a/web/tree.jsx b/web/tree.jsx index 45db8c8..2dae31c 100644 --- a/web/tree.jsx +++ b/web/tree.jsx @@ -45,14 +45,74 @@ function StatusPopover({ value, style, onChange, onClose, anchorRect }) { ); } +// 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, onEdit, onSetPriority, + 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"; @@ -73,10 +133,12 @@ function ItemRow({ item, depth, expanded, hasChildren, statusStyle,
onDragStart?.(e, item)} + onDragStart={(e) => { 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)}