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
|
// 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);
|
background: var(--paper); color: var(--ink);
|
||||||
cursor: pointer;
|
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
|
// Single tree row
|
||||||
function ItemRow({ item, depth, expanded, hasChildren, statusStyle,
|
function ItemRow({ item, depth, expanded, hasChildren, statusStyle,
|
||||||
onToggle, onSetStatus, onAddChild, onDelete, onEdit, onSetPriority,
|
onToggle, onSetStatus, onAddChild, onDelete, onArchive, onEdit, onSetPriority,
|
||||||
onMoveUp, onMoveDown, canMoveUp, canMoveDown,
|
onMoveUp, onMoveDown, canMoveUp, canMoveDown,
|
||||||
onDragStart, onDragOver, onDrop,
|
onDragStart, onDragOver, onDrop,
|
||||||
dimmed, query, dropTarget }) {
|
dimmed, query, dropTarget }) {
|
||||||
const [statusOpen, setStatusOpen] = useState(false);
|
const [statusOpen, setStatusOpen] = useState(false);
|
||||||
const [priOpen, setPriOpen] = useState(false);
|
const [priOpen, setPriOpen] = useState(false);
|
||||||
|
const [ctxMenu, setCtxMenu] = useState(null);
|
||||||
|
|
||||||
const overdue = isOverdue(item);
|
const overdue = isOverdue(item);
|
||||||
const isP0 = item.priority === "P0" && item.status !== "done" && item.status !== "cancelled";
|
const isP0 = item.priority === "P0" && item.status !== "done" && item.status !== "cancelled";
|
||||||
@@ -73,10 +133,12 @@ function ItemRow({ item, depth, expanded, hasChildren, statusStyle,
|
|||||||
<div
|
<div
|
||||||
className={`row level-${depth} ${dimmed ? "dimmed" : ""} ${isP0 ? "p0" : ""} ${dropTarget ? "drop-target" : ""}`}
|
className={`row level-${depth} ${dimmed ? "dimmed" : ""} ${isP0 ? "p0" : ""} ${dropTarget ? "drop-target" : ""}`}
|
||||||
draggable
|
draggable
|
||||||
onDragStart={(e) => onDragStart?.(e, item)}
|
onDragStart={(e) => { setCtxMenu(null); onDragStart?.(e, item); }}
|
||||||
onDragOver={(e) => onDragOver?.(e, item)}
|
onDragOver={(e) => onDragOver?.(e, item)}
|
||||||
onDrop={(e) => onDrop?.(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 }}/>
|
<span className="row-rail" style={{ width: depth * 18 }}/>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
@@ -218,6 +280,7 @@ function BacklogTree({ items, expandedMap, setExpanded, onMutate, query, statusS
|
|||||||
onSetPriority={(p) => onMutate.setPriority(item.id, p)}
|
onSetPriority={(p) => onMutate.setPriority(item.id, p)}
|
||||||
onAddChild={() => onMutate.addChild(item.id)}
|
onAddChild={() => onMutate.addChild(item.id)}
|
||||||
onDelete={() => onMutate.deleteItem(item.id)}
|
onDelete={() => onMutate.deleteItem(item.id)}
|
||||||
|
onArchive={() => onMutate.archiveItem(item.id)}
|
||||||
onEdit={() => onMutate.editItem(item.id)}
|
onEdit={() => onMutate.editItem(item.id)}
|
||||||
onMoveUp={() => onMutate.moveWithinPriority(item.id, -1)}
|
onMoveUp={() => onMutate.moveWithinPriority(item.id, -1)}
|
||||||
onMoveDown={() => onMutate.moveWithinPriority(item.id, +1)}
|
onMoveDown={() => onMutate.moveWithinPriority(item.id, +1)}
|
||||||
|
|||||||
+155
-3
@@ -1501,6 +1501,29 @@ input { font: inherit; color: inherit; }
|
|||||||
cursor: pointer;
|
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); }
|
||||||
|
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -4942,6 +4965,103 @@ function StatusPopover({
|
|||||||
}), /*#__PURE__*/React.createElement("span", null, s.label))));
|
}), /*#__PURE__*/React.createElement("span", null, s.label))));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 /*#__PURE__*/React.createElement("div", {
|
||||||
|
ref: ref,
|
||||||
|
className: "ctx-menu",
|
||||||
|
style: {
|
||||||
|
left: pos.left,
|
||||||
|
top: pos.top
|
||||||
|
},
|
||||||
|
onContextMenu: e => e.preventDefault()
|
||||||
|
}, /*#__PURE__*/React.createElement("div", {
|
||||||
|
className: "ctx-menu-label"
|
||||||
|
}, "Set status"), STATUSES.map(s => /*#__PURE__*/React.createElement("button", {
|
||||||
|
key: s.key,
|
||||||
|
className: `ctx-menu-item ${s.key === item.status ? "active" : ""}`,
|
||||||
|
onClick: () => {
|
||||||
|
onSetStatus(s.key);
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, /*#__PURE__*/React.createElement(StatusIcon, {
|
||||||
|
status: s.key,
|
||||||
|
style: statusStyle,
|
||||||
|
size: 13
|
||||||
|
}), /*#__PURE__*/React.createElement("span", null, s.label))), /*#__PURE__*/React.createElement("div", {
|
||||||
|
className: "ctx-menu-sep"
|
||||||
|
}), /*#__PURE__*/React.createElement("button", {
|
||||||
|
className: "ctx-menu-item",
|
||||||
|
onClick: () => {
|
||||||
|
onArchive();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, /*#__PURE__*/React.createElement(Icon, {
|
||||||
|
name: "archive"
|
||||||
|
}), /*#__PURE__*/React.createElement("span", null, "Archive")), /*#__PURE__*/React.createElement("button", {
|
||||||
|
className: "ctx-menu-item danger",
|
||||||
|
onClick: () => {
|
||||||
|
onDelete();
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
}, /*#__PURE__*/React.createElement(Icon, {
|
||||||
|
name: "trash"
|
||||||
|
}), /*#__PURE__*/React.createElement("span", null, "Delete")));
|
||||||
|
}
|
||||||
|
|
||||||
// Single tree row
|
// Single tree row
|
||||||
function ItemRow({
|
function ItemRow({
|
||||||
item,
|
item,
|
||||||
@@ -4953,6 +5073,7 @@ function ItemRow({
|
|||||||
onSetStatus,
|
onSetStatus,
|
||||||
onAddChild,
|
onAddChild,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onArchive,
|
||||||
onEdit,
|
onEdit,
|
||||||
onSetPriority,
|
onSetPriority,
|
||||||
onMoveUp,
|
onMoveUp,
|
||||||
@@ -4968,6 +5089,7 @@ function ItemRow({
|
|||||||
}) {
|
}) {
|
||||||
const [statusOpen, setStatusOpen] = useState(false);
|
const [statusOpen, setStatusOpen] = useState(false);
|
||||||
const [priOpen, setPriOpen] = useState(false);
|
const [priOpen, setPriOpen] = useState(false);
|
||||||
|
const [ctxMenu, setCtxMenu] = useState(null);
|
||||||
const overdue = isOverdue(item);
|
const overdue = isOverdue(item);
|
||||||
const isP0 = item.priority === "P0" && item.status !== "done" && item.status !== "cancelled";
|
const isP0 = item.priority === "P0" && item.status !== "done" && item.status !== "cancelled";
|
||||||
const isDone = item.status === "done" || item.status === "cancelled";
|
const isDone = item.status === "done" || item.status === "cancelled";
|
||||||
@@ -4982,10 +5104,30 @@ function ItemRow({
|
|||||||
return /*#__PURE__*/React.createElement("div", {
|
return /*#__PURE__*/React.createElement("div", {
|
||||||
className: `row level-${depth} ${dimmed ? "dimmed" : ""} ${isP0 ? "p0" : ""} ${dropTarget ? "drop-target" : ""}`,
|
className: `row level-${depth} ${dimmed ? "dimmed" : ""} ${isP0 ? "p0" : ""} ${dropTarget ? "drop-target" : ""}`,
|
||||||
draggable: true,
|
draggable: true,
|
||||||
onDragStart: e => onDragStart?.(e, item),
|
onDragStart: e => {
|
||||||
|
setCtxMenu(null);
|
||||||
|
onDragStart?.(e, item);
|
||||||
|
},
|
||||||
onDragOver: e => onDragOver?.(e, item),
|
onDragOver: e => onDragOver?.(e, item),
|
||||||
onDrop: e => onDrop?.(e, item)
|
onDrop: e => onDrop?.(e, item),
|
||||||
}, /*#__PURE__*/React.createElement("span", {
|
onContextMenu: e => {
|
||||||
|
e.preventDefault();
|
||||||
|
setStatusOpen(false);
|
||||||
|
setPriOpen(false);
|
||||||
|
setCtxMenu({
|
||||||
|
x: e.clientX,
|
||||||
|
y: e.clientY
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, ctxMenu && ReactDOM.createPortal(/*#__PURE__*/React.createElement(ItemContextMenu, {
|
||||||
|
item: item,
|
||||||
|
statusStyle: statusStyle,
|
||||||
|
anchor: ctxMenu,
|
||||||
|
onSetStatus: onSetStatus,
|
||||||
|
onArchive: onArchive,
|
||||||
|
onDelete: onDelete,
|
||||||
|
onClose: () => setCtxMenu(null)
|
||||||
|
}), document.body), /*#__PURE__*/React.createElement("span", {
|
||||||
className: "row-rail",
|
className: "row-rail",
|
||||||
style: {
|
style: {
|
||||||
width: depth * 18
|
width: depth * 18
|
||||||
@@ -5181,6 +5323,7 @@ function BacklogTree({
|
|||||||
onSetPriority: p => onMutate.setPriority(item.id, p),
|
onSetPriority: p => onMutate.setPriority(item.id, p),
|
||||||
onAddChild: () => onMutate.addChild(item.id),
|
onAddChild: () => onMutate.addChild(item.id),
|
||||||
onDelete: () => onMutate.deleteItem(item.id),
|
onDelete: () => onMutate.deleteItem(item.id),
|
||||||
|
onArchive: () => onMutate.archiveItem(item.id),
|
||||||
onEdit: () => onMutate.editItem(item.id),
|
onEdit: () => onMutate.editItem(item.id),
|
||||||
onMoveUp: () => onMutate.moveWithinPriority(item.id, -1),
|
onMoveUp: () => onMutate.moveWithinPriority(item.id, -1),
|
||||||
onMoveDown: () => onMutate.moveWithinPriority(item.id, +1),
|
onMoveDown: () => onMutate.moveWithinPriority(item.id, +1),
|
||||||
@@ -8313,6 +8456,15 @@ function App() {
|
|||||||
triggerSave('Deleted', projectId);
|
triggerSave('Deleted', projectId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
},
|
||||||
|
archiveItem: async id => {
|
||||||
|
try {
|
||||||
|
await flushPendingSave();
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Save failed: ' + e.message, 'err');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleArchiveItems([id]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user