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 <clio-agent@sisyphuslabs.ai>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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); }
|
||||
|
||||
+65
-2
@@ -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 (
|
||||
<div ref={ref} className="ctx-menu" style={{ left: pos.left, top: pos.top }}
|
||||
onContextMenu={(e) => e.preventDefault()}>
|
||||
<div className="ctx-menu-label">Set status</div>
|
||||
{STATUSES.map(s => (
|
||||
<button key={s.key} className={`ctx-menu-item ${s.key === item.status ? "active" : ""}`}
|
||||
onClick={() => { onSetStatus(s.key); onClose(); }}>
|
||||
<StatusIcon status={s.key} style={statusStyle} size={13}/>
|
||||
<span>{s.label}</span>
|
||||
</button>
|
||||
))}
|
||||
<div className="ctx-menu-sep"/>
|
||||
<button className="ctx-menu-item" onClick={() => { onArchive(); onClose(); }}>
|
||||
<Icon name="archive"/>
|
||||
<span>Archive</span>
|
||||
</button>
|
||||
<button className="ctx-menu-item danger" onClick={() => { onDelete(); onClose(); }}>
|
||||
<Icon name="trash"/>
|
||||
<span>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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,
|
||||
<div
|
||||
className={`row level-${depth} ${dimmed ? "dimmed" : ""} ${isP0 ? "p0" : ""} ${dropTarget ? "drop-target" : ""}`}
|
||||
draggable
|
||||
onDragStart={(e) => 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(<ItemContextMenu item={item} statusStyle={statusStyle} anchor={ctxMenu} onSetStatus={onSetStatus} onArchive={onArchive} onDelete={onDelete} onClose={() => setCtxMenu(null)}/>, document.body)}
|
||||
<span className="row-rail" style={{ width: depth * 18 }}/>
|
||||
|
||||
<button
|
||||
@@ -218,6 +280,7 @@ function BacklogTree({ items, expandedMap, setExpanded, onMutate, query, statusS
|
||||
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)}
|
||||
|
||||
Reference in New Issue
Block a user