"use client"; import { cloneElement, isValidElement, useRef, useState, type ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { IconCopy, IconEdit, IconPencil, IconPin, IconPinFilled, IconTrash, } from "@tabler/icons-react"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "@kandev/ui/context-menu"; import { TaskMoveContextMenuItems, type TaskMoveWorkflow, } from "@/components/task/task-move-context-menu"; import { TaskNestContextMenuItems } from "@/components/task/task-nest-context-menu"; import { useTaskWorkflowMove } from "@/hooks/use-task-workflow-move"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { useUpdateTaskPriority } from "@/hooks/use-update-task-priority"; import { TaskColorMenu } from "./task-switcher-color-menu"; import { TaskPluginLinkMenu, selectTaskLinkActions, type TaskLinkHandlers, } from "./task-switcher-link-menu"; import { TaskArchiveItem, TaskCreateSubtaskItem, TaskDeleteItem, TaskDetachItem, } from "./task-switcher-action-items"; import type { StepDef, TaskSwitcherItem } from "./task-switcher-types"; import { TaskPluginPrimaryMenuItems } from "./task-switcher-plugin-menu-items"; import { useTaskSwitcherArchiveConfirmation } from "./task-switcher-archive-confirmation"; import { TaskPriorityContextMenu } from "./task-priority-context-menu"; export type { StepDef } from "./task-switcher-types"; export { createTaskLinkSelectAction } from "./task-switcher-link-menu"; type ContextMenuProps = TaskLinkHandlers & { task: TaskSwitcherItem; workflows?: TaskMoveWorkflow[]; stepsByWorkflowId?: Record; steps?: StepDef[]; children: React.ReactElement<{ menuOpen?: boolean; archiveConfirmation?: ReactNode }>; onEditTask?: (task: TaskSwitcherItem) => void; onRenameTask?: (taskId: string, currentTitle: string) => void; onArchiveTask?: (taskId: string, opts?: { cascade?: boolean }) => void; onCreateSubtask?: (taskId: string, taskTitle: string) => void; onDeleteTask?: (taskId: string) => void; onDetachTask?: (taskId: string) => void; onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void; onTogglePin?: (taskId: string) => void; isPinned?: boolean; pinnedTaskIds?: string[]; isDeleting?: boolean; isArchiving?: boolean; /** Active multi-selection; when this task is part of it, actions apply to the whole set. */ selectedTaskIds?: Set; onBulkArchive?: (taskIds: string[]) => void; onBulkDelete?: (taskIds: string[]) => void; onBulkPin?: (taskIds: string[]) => void; onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void; onClearSelection?: () => void; /** True when the selection spans more than one workflow (disables bulk "Move to step"). */ isMixedWorkflowSelection?: boolean; }; /** * dnd-kit's TouchSensor arms on touchstart and activates after the 250ms * delay — before a long-press (≈700ms) can open this context menu. A * stationary long-press therefore starts a row drag that is still live when * the menu opens. While a drag is active the TouchSensor listens for * `touchcancel` on the element the touch started on, so dispatching one at * that element aborts the drag (onDragCancel) instead of dropping it: the * row stays put and the menu remains usable. Inert when no touch has started * on this row (desktop right-click, or a sensor that already detached after a * quick tap), because then nothing listens for the event. */ type CancelTouchDrag = (touchStartTarget: EventTarget | null) => void; const cancelTouchDrag: CancelTouchDrag = (touchStartTarget) => { if (touchStartTarget instanceof Element && typeof TouchEvent === "function") { touchStartTarget.dispatchEvent( new TouchEvent("touchcancel", { bubbles: true, cancelable: true }), ); } }; /** * Coordinates the context menu with the row's touch-drag sensor: remembers * the element the touch began on and cancels the in-flight drag when the menu * opens. Returns the menu `onOpenChange` handler and the trigger-wrapper * capture props. */ function useMenuTouchDragCancel(onOpenChange: (open: boolean) => void) { const touchStartRef = useRef<{ target: EventTarget; identifier: number } | null>(null); const menuOpenRef = useRef(false); const handleOpenChange = (open: boolean) => { onOpenChange(open); menuOpenRef.current = open; if (open) { // A touch long-press has already armed the row's TouchSensor (250ms) // when the menu opens (~700ms); cancel that drag at the touchstart // target so the menu gesture never moves the row. const target = touchStartRef.current?.target ?? null; touchStartRef.current = null; cancelTouchDrag(target); } else { touchStartRef.current = null; } }; return { handleOpenChange, triggerProps: { // The TouchSensor attaches its touchcancel listener to the element the // touch began on while a drag is active. Track only the first touch of // a single-touch gesture (dnd-kit's TouchSensor rejects multi-touch) // and only while the menu is closed, and drop the target when that // touch ends or the menu closes — not when another finger lifts — so a // later open never dispatches a synthetic touchcancel for a gesture // that is no longer active (pull-to-refresh and touch-scroll listen for // bubbled touchcancel). onTouchStartCapture: (event: React.TouchEvent) => { if (menuOpenRef.current || event.touches.length !== 1) return; if (!touchStartRef.current) { touchStartRef.current = { target: event.target, identifier: event.touches[0].identifier, }; } }, onTouchEndCapture: (event: React.TouchEvent) => { const tracked = touchStartRef.current; if ( tracked && Array.from(event.changedTouches).some((t) => t.identifier === tracked.identifier) ) { touchStartRef.current = null; } }, onTouchCancelCapture: (event: React.TouchEvent) => { const tracked = touchStartRef.current; if ( tracked && Array.from(event.changedTouches).some((t) => t.identifier === tracked.identifier) ) { touchStartRef.current = null; } }, }, }; } // This component coordinates the context menu and drag cancellation. Archive // state lives in its focused adapter so unavailable actions stay unavailable. export function TaskItemWithContextMenu(props: ContextMenuProps) { const { children, ...menuProps } = props; const [contextOpen, setContextOpen] = useState(false); const [menuKey, setMenuKey] = useState(0); const moveTasks = useTaskWorkflowMove(); const closeMenu = () => { setContextOpen(false); setMenuKey((k) => k + 1); }; const { handleOpenChange, triggerProps } = useMenuTouchDragCancel(setContextOpen); const { isFinePointer } = useResponsiveBreakpoint(); const archive = useTaskSwitcherArchiveConfirmation({ task: menuProps.task, onArchiveTask: menuProps.onArchiveTask, isArchiving: menuProps.isArchiving, closeMenu, }); const archiveConfirmation = archive.archiveOpen ? archive.archiveConfirmation : undefined; const inlineArchiveConfirmation = isFinePointer ? undefined : archiveConfirmation; const portaledArchiveConfirmation = isFinePointer ? archiveConfirmation : undefined; return (
{cloneWithMenuOpen(children, contextOpen, inlineArchiveConfirmation)} {portaledArchiveConfirmation}
event.stopPropagation()} onPointerDown={(event) => event.stopPropagation()} onTouchStart={(event) => event.stopPropagation()} onClick={(event) => event.stopPropagation()} >
); } type TaskContextMenuItemsProps = Omit & { closeMenu: () => void; moveTasks: ReturnType; }; function TaskContextMenuItems(props: TaskContextMenuItemsProps) { const { task, selectedTaskIds } = props; // Right-clicking any row that's part of the active selection acts on the whole // selection (even a one-row selection, so the action clears it); right-clicking // a non-selected row acts on just that task and leaves the selection intact. const actingOnSelection = !!selectedTaskIds?.has(task.id); const actingIds = actingOnSelection ? [...selectedTaskIds!] : [task.id]; // With several tasks selected, only actions that make sense for all of them // are offered (Pin / Move / Archive / Delete) — the single-task actions // (Rename, Color, Link, Duplicate) are hidden. if (actingOnSelection && actingIds.length > 1) { return ; } return ( ); } function SingleSelectionMenuItems({ task, workflows, stepsByWorkflowId, steps, onEditTask, onRenameTask, onArchiveTask, onCreateSubtask, onDeleteTask, onDetachTask, onMoveToStep, onTogglePin, isPinned, isDeleting, isArchiving, onBulkArchive, onBulkMove, onClearSelection, isMixedWorkflowSelection, closeMenu, moveTasks, actingIds, actingOnSelection, ...linkHandlers }: TaskContextMenuItemsProps & { actingIds: string[]; actingOnSelection: boolean }) { const { t } = useTranslation(); const updateTaskPriority = useUpdateTaskPriority(); // Acting on a lone selected row (Pin / Delete) must drop it from the selection // so later plain clicks navigate instead of toggling. const onDelete = withSelectionClear(actingOnSelection, onClearSelection, onDeleteTask); const onDetach = withSelectionClear(actingOnSelection, onClearSelection, onDetachTask); return ( <> {!task.isArchived && ( void updateTaskPriority(task.id, priority)} /> )} {!task.isArchived && ( {t("settings:duplicate")} )} {!task.isArchived && ( )} {!task.isArchived && ( )} ); } function withSelectionClear( actingOnSelection: boolean, onClearSelection: (() => void) | undefined, handler: ((id: string) => void) | undefined, ) { if (!actingOnSelection || !onClearSelection || !handler) return handler; return (id: string) => { onClearSelection(); handler(id); }; } /** Reduced menu shown when 2+ tasks are selected — only bulk-valid actions. */ function BulkSelectionMenuItems({ task, actingIds, workflows, stepsByWorkflowId, steps, isMixedWorkflowSelection, pinnedTaskIds, onBulkPin, onBulkArchive, onBulkDelete, onBulkMove, closeMenu, moveTasks, }: { task: TaskSwitcherItem; actingIds: string[]; workflows?: TaskMoveWorkflow[]; stepsByWorkflowId?: Record; steps?: StepDef[]; isMixedWorkflowSelection?: boolean; pinnedTaskIds?: string[]; onBulkPin?: (taskIds: string[]) => void; onBulkArchive?: (taskIds: string[]) => void; onBulkDelete?: (taskIds: string[]) => void; onBulkMove?: (taskIds: string[], targetWorkflowId: string, targetStepId: string) => void; closeMenu: () => void; moveTasks: ReturnType; }) { const { t } = useTranslation(); const n = actingIds.length; const allPinned = actingIds.length > 0 && actingIds.every((id) => pinnedTaskIds?.includes(id) ?? false); const pinLabel = allPinned ? t("task:unpinTasksCount", { count: n }) : t("task:pinTasksCount", { count: n }); return ( <> {onBulkPin && ( onBulkPin(actingIds)}> {allPinned ? ( ) : ( )} {pinLabel} )} {onBulkDelete && ( <> onBulkDelete(actingIds)}> {t("task:deleteTasksCount", { count: n })} )} ); } function cloneWithMenuOpen( children: React.ReactElement<{ menuOpen?: boolean; archiveConfirmation?: ReactNode }>, menuOpen: boolean, archiveConfirmation?: ReactNode, ): React.ReactNode { if (isValidElement(children)) return cloneElement(children, { menuOpen, archiveConfirmation }); return children; } function TaskPinItem({ taskId, isPinned, disabled, onTogglePin, }: { taskId: string; isPinned?: boolean; disabled?: boolean; onTogglePin?: (taskId: string) => void; }) { const { t } = useTranslation(); if (!onTogglePin) return null; return ( onTogglePin(taskId)}> {isPinned ? : } {isPinned ? t("task:unpin") : t("task:annotationPin")} ); } function TaskRenameItem({ task, disabled, onRenameTask, }: { task: TaskSwitcherItem; disabled?: boolean; onRenameTask?: (taskId: string, currentTitle: string) => void; }) { const { t } = useTranslation(); if (!onRenameTask) return null; return ( onRenameTask(task.id, task.title)}> {t("task:rename")} ); } function TaskEditItem({ task, disabled, onEditTask, }: { task: TaskSwitcherItem; disabled?: boolean; onEditTask?: (task: TaskSwitcherItem) => void; }) { const { t } = useTranslation(); if (!onEditTask || task.isArchived || !task.workflowId || !task.workflowStepId) return null; return ( onEditTask(task)}> {t("common:edit")} ); } function TaskMoveItems({ task, workflows, stepsByWorkflowId, steps, isDeleting, onMoveToStep, actingIds, actingOnSelection, onBulkMove, isMixedWorkflowSelection, closeMenu, moveTasks, }: Omit & { actingIds: string[]; actingOnSelection: boolean; }) { if (!task.workflowId) return null; const workflowId = task.workflowId; // Moving a selection routes through the sidebar hook's bulkMove, which clears // the selection afterwards. Fall back to a raw move when no bulk handler is // wired (e.g. the kanban-less callers that don't manage a selection). const runSelectionMove = ( targetWorkflowId: string, stepId: string, destination: "step" | "workflow", ) => { closeMenu(); if (onBulkMove) { onBulkMove(actingIds, targetWorkflowId, stepId); return; } void moveTasks(actingIds, targetWorkflowId, stepId, destination).catch(() => { // useTaskWorkflowMove already shows the failure toast. }); }; // Single-task right-click keeps the optimistic same-workflow move. A selection // spanning workflows makes "Move to step" of one workflow ambiguous, so disable // it there (Send to workflow remains the explicit path). let moveToStep: ((stepId: string) => void) | undefined; if (actingOnSelection) { moveToStep = isMixedWorkflowSelection ? undefined : (stepId) => runSelectionMove(workflowId, stepId, "step"); } else { moveToStep = (stepId) => { closeMenu(); if (onMoveToStep) { onMoveToStep(task.id, workflowId, stepId); return; } void moveTasks([task.id], workflowId, stepId, "step").catch(() => { // useTaskWorkflowMove already shows the failure toast. }); }; } return ( { if (actingOnSelection) { runSelectionMove(targetWorkflowId, stepId, "workflow"); return; } closeMenu(); void moveTasks([task.id], targetWorkflowId, stepId, "workflow").catch(() => { // useTaskWorkflowMove already shows the failure toast. }); }} /> ); }