{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"gantt-dnd","type":"registry:ui","title":"Custom pointer-event interaction engine - edge resize and drag-create over the horizontal axis with live validation; bars are never dragged whole.","description":"Custom pointer-event interaction engine - edge resize and drag-create over the horizontal axis with live validation; bars are never dragged whole.","dependencies":["date-fns"],"registryDependencies":["@neui/gantt","@neui/gantt-lib","@neui/gantt-types"],"files":[{"path":"gantt-dnd.tsx","type":"registry:ui","content":"// Title: Gantt Dnd\n// Description: Custom pointer-event interaction engine - edge resize and drag-create over the horizontal axis with live validation; bars are never dragged whole.\n\n\"use client\"\n\nimport { useCallback, useEffect } from \"react\"\nimport {\n resolveScheduleMode,\n useGantt,\n useGanttViewConfig,\n type GanttInstance,\n} from \"@/components/neui/gantt/gantt\"\nimport {\n findResource,\n snapMinutes,\n toZoned,\n zonedStartOfDay,\n} from \"@/components/neui/gantt/gantt-lib\"\nimport type {\n GanttProposedUpdate,\n GanttScheduleMode,\n GanttSegment,\n} from \"@/components/neui/gantt/gantt-types\"\nimport { addDays, differenceInCalendarDays } from \"date-fns\"\n\n/**\n * Activation policy (dnd-kit parity where proven):\n * mouse move 5px before a drag starts (below = click), create 4px;\n * touch long-press 250ms with 5px tolerance (movement past tolerance\n * before the delay cancels the drag so taps stay taps).\n */\nconst GANTT_ACTIVATION = {\n moveDistancePx: 5,\n createDistancePx: 4,\n touchDelayMs: 250,\n touchTolerancePx: 5,\n} as const\n\ntype GestureKind = \"move\" | \"resize-start\" | \"resize-end\" | \"create\"\n\ninterface GanttSurface {\n rect: DOMRect\n rangeStart: number\n rangeEnd: number\n snapMin: number\n /** Mirrored axis: in RTL the range START sits at the rect's RIGHT edge. */\n isRtl: boolean\n rows: Array<{ resourceId: string; rect: DOMRect }>\n}\n\n/**\n * Pointer x (viewport px) to minutes from the range start, clamped to the\n * track. The single place the horizontal axis direction is resolved: every\n * gesture mapping (move, both resizes, create, grab offset) goes through it.\n */\nfunction surfaceMinutesAt(tl: GanttSurface, x: number): number {\n const clamped = Math.min(Math.max(x, tl.rect.left), tl.rect.right)\n const traveled = tl.isRtl ? tl.rect.right - clamped : clamped - tl.rect.left\n return (traveled / tl.rect.width) * ((tl.rangeEnd - tl.rangeStart) / 60000)\n}\n\n/** Module flag so bar onClick can ignore the click that ends a drag. */\nlet lastGestureEndedAt = 0\nfunction wasRecentDrag(): boolean {\n return performance.now() - lastGestureEndedAt < 250\n}\n\n/** Mark a non-dnd gesture (e.g. a timeline pan) so the click it ends is ignored. */\nfunction markGestureEnd(): void {\n lastGestureEndedAt = performance.now()\n}\n\n/**\n * Registry of in-flight gesture cancels. A gesture measures its surface\n * (axis + row rects) once at activation, so the VIEW - not the bar, bars\n * legitimately unmount mid-gesture - must be able to abort gestures when it\n * unmounts or when the measured geometry changes under them (zoom, scale,\n * range growth, splitter). Cancel fully reverts: listeners, overlays and the\n * body drag state all clear, and no update is committed.\n */\nconst activeGestureCancels = new Set<() => void>()\n\n/** Cancel (and fully revert) every in-flight gantt pointer gesture. */\nfunction cancelActiveGanttGestures(): void {\n for (const cancel of [...activeGestureCancels]) cancel()\n}\n\n/**\n * View-level teardown, mounted once by GanttView: aborts any in-flight\n * gesture on unmount so window listeners, body-appended overlays and the\n * gantt-dragging body class never outlive the gantt.\n */\nfunction useGanttGestureTeardown(): void {\n useEffect(() => cancelActiveGanttGestures, [])\n}\n\n/**\n * Snap a translate offset to the device pixel grid. The cursor-following\n * overlays (the move clone and the resize indicator) are their own\n * `will-change: transform` compositing layers: the GPU rasterizes their text\n * once and repositions that texture each frame, so a subpixel translate\n * (getBoundingClientRect and raw clientX/Y are routinely fractional) resamples\n * the texture and blurs the text. Rounding each offset to a whole device pixel\n * lands the layer on the grid so glyphs stay crisp, without giving up the\n * per-frame GPU transform.\n */\nfunction snapToPixel(value: number): number {\n const dpr = typeof window !== \"undefined\" ? window.devicePixelRatio || 1 : 1\n return Math.round(value * dpr) / dpr\n}\n\nfunction collectSurface(root: HTMLElement | null): GanttSurface | null {\n if (!root) return null\n const axis = root.querySelector(\"[data-gantt-axis]\")\n if (!axis) return null\n return {\n rect: axis.getBoundingClientRect(),\n rangeStart: Number(axis.dataset.ganttRangeStart),\n rangeEnd: Number(axis.dataset.ganttRangeEnd),\n snapMin: Number(axis.dataset.ganttSnap) || 15,\n isRtl: getComputedStyle(axis).direction === \"rtl\",\n rows: [...root.querySelectorAll(\"[data-gantt-row]\")]\n // static rows (parents that aggregate their subtree) take no drops\n .filter((row) => row.dataset.ganttRowStatic === undefined)\n .map((row) => ({\n resourceId: row.dataset.ganttResource ?? \"\",\n rect: row.getBoundingClientRect(),\n })),\n }\n}\n\ninterface BeginGestureConfig {\n instance: GanttInstance\n kind: GestureKind\n origin: HTMLElement\n startEvent: PointerEvent\n segment?: GanttSegment\n /** Consumer renders the move preview (renderDragPreview); the engine only positions it. */\n customMoveOverlay?: boolean\n /** Consumer renders the resize indicator; the engine only positions it. */\n customResizeOverlay?: boolean\n /** View-level cardinality default; a node's own scheduleMode wins. */\n scheduleMode?: GanttScheduleMode\n}\n\nfunction beginGesture(config: BeginGestureConfig) {\n const {\n instance,\n kind,\n origin,\n startEvent,\n segment,\n customMoveOverlay,\n customResizeOverlay,\n } = config\n const { settings, internals, api } = instance\n const activation = { ...GANTT_ACTIVATION, ...settings.activation }\n const startX = startEvent.clientX\n const startY = startEvent.clientY\n const pointerId = startEvent.pointerId\n // stable ancestors: bar nodes may be replaced by re-renders mid-gesture\n const viewRoot = origin.closest(\"[data-slot=gantt-view]\")\n const ganttRoot = origin.closest(\"[data-slot=gantt]\")\n const announcer = ganttRoot?.querySelector(\n \"[data-slot=gantt-announcer]\"\n )\n\n /**\n * The cursor-following overlays are appended to document.body so no ancestor\n * transform or overflow can clip them - which also cuts them off from the\n * gantt root, and the root is what OWNS the type scale (its `text-xs` is\n * what every resting label inherits). Without this the clone's label jumps\n * to the document default and reads visibly bigger than the bar it left.\n * Copying the ROOT's resolved metrics - rather than hardcoding a size -\n * keeps the documented contract that one class on the root (e.g.\n * className=\"text-sm\") rescales the whole gantt, drag clone included.\n */\n const adoptRootTypography = (el: HTMLElement) => {\n if (!ganttRoot) return\n const rootStyle = getComputedStyle(ganttRoot)\n el.style.fontSize = rootStyle.fontSize\n el.style.lineHeight = rootStyle.lineHeight\n el.style.fontFamily = rootStyle.fontFamily\n el.style.letterSpacing = rootStyle.letterSpacing\n // physical positioning, logical content: the flex row mirrors so the\n // label lands on the same side of the bar as the resting one in RTL\n el.style.direction = rootStyle.direction\n }\n\n const isTouch = startEvent.pointerType === \"touch\"\n // resize activates immediately on precise pointers; on touch it waits for\n // the same long-press as a move, so a stray brush over a bar edge can\n // never start an accidental resize\n let active = kind.startsWith(\"resize\") && !isTouch\n let surface: GanttSurface | null = active ? collectSurface(viewRoot) : null\n let lastProposalKey = \"\"\n let touchTimer: ReturnType | null = null\n let lastPointer: PointerEvent = startEvent\n\n const occurrence = segment?.occurrence\n\n // ----- neighbour awareness: the other schedules in the SAME node -----\n // A node in \"single\" mode rejects any concurrency regardless of the\n // overlap option; otherwise the option decides. \"allow\" short-circuits\n // everything below, so the default gesture path is untouched.\n const nodeId = occurrence?.event.resourceId\n const nodeMode = resolveScheduleMode(\n nodeId === undefined ? null : findResource(settings.resources, nodeId),\n config.scheduleMode\n )\n const overlapPolicy =\n nodeMode === \"single\" ? (\"reject\" as const) : settings.overlap\n // Read once per gesture: the gantt never mutates events mid-drag, so the\n // neighbours cannot move under us.\n let neighbourCache: Array<{ start: number; end: number }> | null = null\n const getNeighbours = () => {\n if (neighbourCache) return neighbourCache\n neighbourCache =\n !occurrence || nodeId === undefined || overlapPolicy === \"allow\"\n ? []\n : api\n .getOccurrences()\n .filter(\n (other) =>\n other.event.resourceId === nodeId &&\n other.key !== occurrence.key\n )\n .map((other) => ({\n start: other.start.getTime(),\n end: other.end.getTime(),\n }))\n return neighbourCache\n }\n const overlapsNeighbour = (start: Date, end: Date) =>\n getNeighbours().some(\n (other) => other.start < end.getTime() && other.end > start.getTime()\n )\n /**\n * Stop the gesture at the neighbour's edge. Runs AFTER snapping so the\n * clamp always wins, and only against neighbours that sit clear of the\n * bar's CURRENT span - a pre-existing overlap has no edge to stop at.\n */\n const clampToNeighbours = (\n start: Date,\n end: Date\n ): { start: Date; end: Date } => {\n if (overlapPolicy !== \"clamp\" || !occurrence) return { start, end }\n const anchorStart = occurrence.start.getTime()\n const anchorEnd = occurrence.end.getTime()\n let floor = -Infinity\n let ceiling = Infinity\n for (const other of getNeighbours()) {\n if (other.end <= anchorStart) floor = Math.max(floor, other.end)\n else if (other.start >= anchorEnd)\n ceiling = Math.min(ceiling, other.start)\n }\n if (floor === -Infinity && ceiling === Infinity) return { start, end }\n let from = start.getTime()\n let to = end.getTime()\n if (kind === \"resize-start\") {\n from = Math.min(Math.max(from, floor), to)\n } else if (kind === \"resize-end\") {\n to = Math.max(Math.min(to, ceiling), from)\n } else {\n // a move keeps its duration and parks against whichever edge it meets\n const duration = to - from\n if (from < floor) {\n from = floor\n to = from + duration\n }\n if (to > ceiling) {\n to = ceiling\n from = to - duration\n }\n // window narrower than the bar itself: park at the earlier edge\n if (from < floor) {\n from = floor\n to = from + duration\n }\n }\n return { start: new Date(from), end: new Date(to) }\n }\n // Set by applyProposal when a \"reject\" policy refuses the current proposal;\n // read on pointerup so the commit is actually blocked, not merely styled.\n let overlapRejected = false\n\n // Preserve the grab offset so the bar does not jump to the pointer\n let grabOffsetMin = 0\n // Smooth cursor-following clone for a move: a real-looking bar that tracks\n // the pointer's x via transform (no per-frame React), lifted with a shadow.\n let overlay: HTMLDivElement | null = null\n let grabOffsetPx = 0\n let barTop = 0\n let barWidth = 0\n let barHeight = 0\n\n const createMoveOverlay = () => {\n if (kind !== \"move\" || !occurrence || overlay || barWidth === 0) return\n // consumer-rendered preview (renderDragPreview): the view mounts it from\n // drag state; positionOverlay adopts it lazily and only writes transforms\n if (customMoveOverlay) return\n const color = occurrence.event.color ?? \"var(--color-primary)\"\n overlay = document.createElement(\"div\")\n overlay.setAttribute(\"data-slot\", \"gantt-drag-overlay\")\n // container: the bar + its label ride together; the label stays OUTSIDE\n // the bar (to the right), matching the resting look - no in-bar text\n overlay.className =\n // physical left-0 anchor: the clone is positioned by translate3d from\n // raw clientX, which is physical - a logical start-0 anchor would pin\n // it to the RIGHT edge in RTL and fling the clone off screen\n \"pointer-events-none fixed top-0 left-0 z-100 flex items-center gap-2 will-change-transform\"\n adoptRootTypography(overlay)\n overlay.style.height = `${barHeight}px`\n const barEl = document.createElement(\"div\")\n barEl.className = \"shrink-0 rounded-sm shadow-lg\"\n barEl.style.width = `${barWidth}px`\n barEl.style.height = \"100%\"\n barEl.style.background = `color-mix(in oklab, ${color} 22%, var(--color-background))`\n barEl.style.outline = `1px solid color-mix(in oklab, ${color} 55%, transparent)`\n overlay.appendChild(barEl)\n const label = document.createElement(\"span\")\n label.className = \"text-foreground truncate font-medium whitespace-nowrap\"\n label.textContent = occurrence.event.title\n overlay.appendChild(label)\n document.body.appendChild(overlay)\n positionOverlay(lastPointer)\n }\n\n const positionOverlay = (e: PointerEvent) => {\n if (!overlay && customMoveOverlay && active && kind === \"move\") {\n overlay = document.querySelector(\n \"[data-slot=gantt-drag-overlay][data-custom]\"\n )\n if (overlay) overlay.style.visibility = \"visible\"\n }\n if (!overlay) return\n // x follows the pointer freely (smooth); y stays on the bar's own row\n overlay.style.transform = `translate3d(${snapToPixel(e.clientX - grabOffsetPx)}px, ${snapToPixel(barTop)}px, 0)`\n }\n\n // Resize status indicator: a smooth cursor-following edge line plus a live\n // range + duration chip. The dashed ghost still shows the SNAPPED landing;\n // this overlay is the continuous feedback between snap steps.\n let resizeOverlay: HTMLDivElement | null = null\n let resizeLine: HTMLDivElement | null = null\n let resizeRange: HTMLSpanElement | null = null\n let resizeDot: HTMLSpanElement | null = null\n let resizeDuration: HTMLSpanElement | null = null\n\n const positionResizeOverlay = (e: PointerEvent) => {\n if (!resizeOverlay && customResizeOverlay && kind.startsWith(\"resize\")) {\n resizeOverlay = document.querySelector(\n \"[data-slot=gantt-resize-indicator][data-custom]\"\n )\n if (resizeOverlay) resizeOverlay.style.visibility = \"visible\"\n }\n if (!resizeOverlay || !surface) return\n // x follows the pointer freely (clamped to the track); y stays on the bar\n const x = Math.min(\n Math.max(e.clientX, surface.rect.left),\n surface.rect.right\n )\n resizeOverlay.style.transform = `translate3d(${snapToPixel(x)}px, ${snapToPixel(barTop)}px, 0)`\n }\n\n const createResizeOverlay = () => {\n if (!kind.startsWith(\"resize\") || !occurrence || resizeOverlay) return\n const barEl = origin.closest(\"[data-slot=gantt-bar]\")\n const rect = (barEl ?? origin).getBoundingClientRect()\n barTop = rect.top\n barHeight = rect.height\n // consumer-rendered indicator: rect capture above still runs (the engine\n // positions the consumer's wrapper), only the default DOM is skipped\n if (customResizeOverlay) return\n const color = occurrence.event.color ?? \"var(--color-primary)\"\n resizeOverlay = document.createElement(\"div\")\n resizeOverlay.setAttribute(\"data-slot\", \"gantt-resize-indicator\")\n resizeOverlay.className =\n // physical left-0 anchor, same reason as the move clone above\n \"pointer-events-none fixed top-0 left-0 z-100 will-change-transform\"\n adoptRootTypography(resizeOverlay)\n resizeOverlay.style.height = `${barHeight}px`\n resizeLine = document.createElement(\"div\")\n resizeLine.className = \"h-full w-0.5 -translate-x-1/2 rounded-full\"\n resizeLine.style.background = color\n resizeOverlay.appendChild(resizeLine)\n const chip = document.createElement(\"div\")\n chip.className =\n // physical left-0: centered with a physical translate on a physical anchor\n // no text size of its own: it inherits the root scale adopted above, so\n // the chip tracks a consumer rescale instead of pinning itself to 12px\n \"bg-foreground text-background absolute bottom-full left-0 mb-1.5 flex -translate-x-1/2 items-center gap-1.5 rounded-md px-2 py-1 font-medium whitespace-nowrap\"\n resizeRange = document.createElement(\"span\")\n chip.appendChild(resizeRange)\n resizeDot = document.createElement(\"span\")\n resizeDot.className = \"bg-background/40 size-1 shrink-0 rounded-full\"\n resizeDot.setAttribute(\"aria-hidden\", \"true\")\n chip.appendChild(resizeDot)\n resizeDuration = document.createElement(\"span\")\n chip.appendChild(resizeDuration)\n // The arrow. Every other bubble in the gantt has one pointing at what it\n // describes; this chip had none, so a resize looked like a different\n // component from the hover hint. Physical left-1/2 to match the chip's own\n // physical anchor, and out of flow so the chip's flex gap ignores it.\n const chipArrow = document.createElement(\"span\")\n chipArrow.setAttribute(\"aria-hidden\", \"true\")\n chipArrow.className =\n \"bg-foreground absolute -bottom-1 left-1/2 size-2.5 -translate-x-1/2 rotate-45 rounded-[2px]\"\n chip.appendChild(chipArrow)\n resizeOverlay.appendChild(chip)\n document.body.appendChild(resizeOverlay)\n // seed the chip with the CURRENT range so it never flashes empty;\n // zoned so the label names the same day the grid shows\n resizeRange.textContent = settings.i18n.functions.formatEventTime(\n toZoned(occurrence.start, settings.timeZone),\n toZoned(occurrence.end, settings.timeZone),\n occurrence.allDay ?? false,\n settings.locale\n )\n const days = Math.round(\n (occurrence.end.getTime() - occurrence.start.getTime()) / 86_400_000\n )\n if (days >= 1) {\n resizeDuration.textContent = settings.i18n.labels.durationDays(days)\n } else {\n resizeDot.style.display = \"none\"\n resizeDuration.style.display = \"none\"\n }\n positionResizeOverlay(startEvent)\n }\n\n // resize activates immediately, so its indicator mounts with the gesture\n if (active) createResizeOverlay()\n\n const activationDistance =\n kind === \"create\" ? activation.createDistancePx : activation.moveDistancePx\n\n // Each gesture keeps its own cursor: a resize must stay ew-resize for the\n // whole drag (flipping to grabbing reads as a move), a move grabs.\n const gestureCursor = kind.startsWith(\"resize\") ? \"ew-resize\" : \"grabbing\"\n const setBodyDragging = (on: boolean, invalid = false) => {\n document.body.classList.toggle(\"gantt-dragging\", on)\n document.body.style.cursor = on\n ? invalid\n ? \"not-allowed\"\n : gestureCursor\n : \"\"\n document.body.style.userSelect = on ? \"none\" : \"\"\n if (!on) document.body.style.removeProperty(\"-webkit-user-select\")\n }\n\n const activate = () => {\n if (active) return\n active = true\n surface = collectSurface(viewRoot)\n // touch resize activates here (long-press) instead of at gesture start,\n // so its indicator mounts now; the guard inside makes this a no-op for\n // every other path\n createResizeOverlay()\n if (kind === \"move\" && occurrence && surface) {\n const pointerMin = surfaceMinutesAt(surface, startX)\n // TRUE start, never clamped to the range: a bar that begins before the\n // visible window (negative minutes) must keep its real grab offset, or\n // the first snapped proposal teleports its start to the range edge\n const occStartMin =\n (occurrence.start.getTime() - surface.rangeStart) / 60000\n grabOffsetMin = pointerMin - occStartMin\n const rect = origin.getBoundingClientRect()\n barTop = rect.top\n barWidth = rect.width\n barHeight = rect.height\n grabOffsetPx = startX - rect.left\n createMoveOverlay()\n }\n setBodyDragging(true)\n }\n\n const computeProposal = (\n e: PointerEvent\n ): {\n start: Date\n end: Date\n allDay: boolean\n resourceId?: string\n } | null => {\n if (!surface) return null\n const tl = surface\n const rangeMinutes = (tl.rangeEnd - tl.rangeStart) / 60000\n const minutesAt = (x: number) => surfaceMinutesAt(tl, x)\n // Day-grid scales snap to real zoned midnights, not 1440-minute\n // multiples from the range start - those drift by an hour across DST\n const snapMin = (minutes: number) => {\n if (tl.snapMin < 24 * 60) return snapMinutes(minutes, tl.snapMin)\n const ms = tl.rangeStart + minutes * 60000\n const dayStart = zonedStartOfDay(new Date(ms), settings.timeZone)\n const dayEnd = zonedStartOfDay(\n addDays(toZoned(new Date(ms), settings.timeZone), 1),\n settings.timeZone\n )\n const snapped =\n ms - dayStart.getTime() < dayEnd.getTime() - ms ? dayStart : dayEnd\n return (snapped.getTime() - tl.rangeStart) / 60000\n }\n const rowAt = (y: number) => {\n let best = tl.rows[0]\n for (const row of tl.rows) {\n if (y >= row.rect.top && y < row.rect.bottom) return row\n if (\n best &&\n Math.abs(y - (row.rect.top + row.rect.height / 2)) <\n Math.abs(y - (best.rect.top + best.rect.height / 2))\n ) {\n best = row\n }\n }\n return best\n }\n const at = (minutes: number) => new Date(tl.rangeStart + minutes * 60000)\n\n if (kind === \"create\") {\n const anchorMin = snapMin(minutesAt(startX))\n const curMin = snapMin(minutesAt(e.clientX))\n const lo = Math.min(anchorMin, curMin)\n // a bare click still yields a usable slot: at least slotDuration long\n const hi = Math.max(\n anchorMin,\n curMin,\n lo + Math.max(tl.snapMin, settings.slotDuration)\n )\n return {\n start: at(lo),\n end: at(hi),\n allDay: false,\n resourceId: rowAt(startY)?.resourceId,\n }\n }\n if (!occurrence) return null\n const midnightAligned = (d: Date) =>\n zonedStartOfDay(d, settings.timeZone).getTime() === d.getTime()\n if (kind === \"move\") {\n // x-axis only: the bar slides along its OWN row, never across rows.\n // The proposal preserves the pointer DELTA - no clamping to the visible\n // range, or bars crossing the window edge would teleport to it.\n const start = at(snapMin(minutesAt(e.clientX) - grabOffsetMin))\n // Day-snapped scales preserve the CALENDAR span for day-aligned bars:\n // a 3-day bar dragged across a DST change stays midnight-to-midnight\n // (72h +/- 1h), never drifting to a 23:00 end. Sub-day events keep\n // their exact ms duration.\n let end: Date\n if (\n tl.snapMin >= 24 * 60 &&\n (occurrence.allDay ||\n (midnightAligned(occurrence.start) &&\n midnightAligned(occurrence.end)))\n ) {\n const daySpan = Math.max(\n differenceInCalendarDays(\n toZoned(occurrence.end, settings.timeZone),\n toZoned(occurrence.start, settings.timeZone)\n ),\n 1\n )\n end = zonedStartOfDay(\n addDays(toZoned(start, settings.timeZone), daySpan),\n settings.timeZone\n )\n } else {\n end = new Date(\n start.getTime() +\n (occurrence.end.getTime() - occurrence.start.getTime())\n )\n }\n const bounded = clampToNeighbours(start, end)\n return {\n start: bounded.start,\n end: bounded.end,\n allDay: occurrence.allDay,\n resourceId: occurrence.event.resourceId,\n }\n }\n const min = snapMin(minutesAt(e.clientX))\n if (kind === \"resize-start\") {\n const endMin = (occurrence.end.getTime() - tl.rangeStart) / 60000\n // Minimum length = one snap unit; on day grids that unit is the LAST\n // zoned midnight before the end (raw 1440-minute arithmetic lands off\n // the midnight grid across DST changes).\n const maxStartMin =\n tl.snapMin >= 24 * 60\n ? (zonedStartOfDay(\n midnightAligned(occurrence.end)\n ? addDays(toZoned(occurrence.end, settings.timeZone), -1)\n : occurrence.end,\n settings.timeZone\n ).getTime() -\n tl.rangeStart) /\n 60000\n : endMin - tl.snapMin\n const clamped = Math.min(Math.max(min, 0), maxStartMin)\n const bounded = clampToNeighbours(at(clamped), occurrence.end)\n return {\n start: bounded.start,\n end: bounded.end,\n allDay: occurrence.allDay,\n resourceId: occurrence.event.resourceId,\n }\n }\n const startMin = (occurrence.start.getTime() - tl.rangeStart) / 60000\n // Mirror of the resize-start bound: the FIRST zoned midnight after the\n // start on day grids, plain snap arithmetic otherwise.\n const minEndMin =\n tl.snapMin >= 24 * 60\n ? (zonedStartOfDay(\n addDays(toZoned(occurrence.start, settings.timeZone), 1),\n settings.timeZone\n ).getTime() -\n tl.rangeStart) /\n 60000\n : startMin + tl.snapMin\n const clamped = Math.max(Math.min(min, rangeMinutes), minEndMin)\n const bounded = clampToNeighbours(occurrence.start, at(clamped))\n return {\n start: bounded.start,\n end: bounded.end,\n allDay: occurrence.allDay,\n resourceId: occurrence.event.resourceId,\n }\n }\n\n const applyProposal = (e: PointerEvent) => {\n const proposal = computeProposal(e)\n if (!proposal) return\n const key = `${proposal.start.getTime()}-${proposal.end.getTime()}-${proposal.allDay}-${proposal.resourceId ?? \"\"}`\n if (key === lastProposalKey) return\n lastProposalKey = key\n\n if (kind === \"create\") {\n const draft = { ...proposal }\n if (settings.canSelectSlot && !settings.canSelectSlot(draft)) return\n internals.setSlotDraft(draft)\n return\n }\n const update: GanttProposedUpdate = {\n event: occurrence!.event,\n occurrence: occurrence!,\n ...proposal,\n source:\n kind === \"move\" ? \"drag\" : (kind as \"resize-start\" | \"resize-end\"),\n }\n // \"reject\" is the one veto the engine owns: it both styles the ghost AND\n // blocks the commit below. canDropEvent stays advisory, as documented.\n overlapRejected =\n overlapPolicy === \"reject\" &&\n overlapsNeighbour(proposal.start, proposal.end)\n const valid =\n !overlapRejected &&\n (settings.canDropEvent ? settings.canDropEvent(update) : true)\n // live status: the indicator chip always names the CURRENT proposed\n // range; the edge line flips to destructive on an invalid drop\n if (resizeRange && resizeDot && resizeDuration) {\n resizeRange.textContent = settings.i18n.functions.formatEventTime(\n toZoned(proposal.start, settings.timeZone),\n toZoned(proposal.end, settings.timeZone),\n proposal.allDay,\n settings.locale\n )\n const days = Math.round(\n (proposal.end.getTime() - proposal.start.getTime()) / 86_400_000\n )\n const showDays = days >= 1\n resizeDot.style.display = showDays ? \"\" : \"none\"\n resizeDuration.style.display = showDays ? \"\" : \"none\"\n if (showDays) {\n resizeDuration.textContent = settings.i18n.labels.durationDays(days)\n }\n }\n if (resizeLine) {\n resizeLine.style.background = valid\n ? (occurrence!.event.color ?? \"var(--color-primary)\")\n : \"var(--color-destructive)\"\n }\n setBodyDragging(true, !valid)\n internals.setDrag({\n kind: kind === \"move\" ? \"move\" : (kind as \"resize-start\" | \"resize-end\"),\n occurrence: occurrence!,\n proposedStart: proposal.start,\n proposedEnd: proposal.end,\n proposedAllDay: proposal.allDay,\n proposedResourceId: proposal.resourceId,\n valid,\n })\n }\n\n // ----- edge auto-scroll: pan the timeline while dragging near its edge -----\n // The pointer is clamped to the visible track, so without this a bar can\n // never travel past the window. Holding the pointer inside the edge zone\n // scrolls the viewport (speed eased by proximity), refreshes the track rect\n // (the axis moved under the pointer) and re-derives the proposal from the\n // same pointer position. Programmatic scrolls never mark user intent, so\n // this can never trigger infinite-range growth mid-gesture.\n const AUTO_SCROLL_EDGE_PX = 24\n const AUTO_SCROLL_MAX_SPEED = 14\n let autoScrollRaf = 0\n const timelineViewport = viewRoot?.querySelector(\n \"[data-slot=gantt-timeline-pane] [data-slot=scroll-area-viewport]\"\n )\n const autoScrollTick = () => {\n autoScrollRaf = 0\n if (finished || !active || !surface || !timelineViewport) return\n const paneRect = timelineViewport.getBoundingClientRect()\n const x = lastPointer.clientX\n let speed = 0\n if (x < paneRect.left + AUTO_SCROLL_EDGE_PX) {\n speed =\n -((paneRect.left + AUTO_SCROLL_EDGE_PX - x) / AUTO_SCROLL_EDGE_PX) *\n AUTO_SCROLL_MAX_SPEED\n } else if (x > paneRect.right - AUTO_SCROLL_EDGE_PX) {\n speed =\n ((x - (paneRect.right - AUTO_SCROLL_EDGE_PX)) / AUTO_SCROLL_EDGE_PX) *\n AUTO_SCROLL_MAX_SPEED\n }\n if (speed === 0) return\n const before = timelineViewport.scrollLeft\n timelineViewport.scrollLeft = before + speed\n if (timelineViewport.scrollLeft === before) return // parked on the end\n const axis = viewRoot?.querySelector(\"[data-gantt-axis]\")\n if (axis) surface.rect = axis.getBoundingClientRect()\n applyProposal(lastPointer)\n positionResizeOverlay(lastPointer)\n scheduleAutoScroll()\n }\n const scheduleAutoScroll = () => {\n if (!autoScrollRaf) autoScrollRaf = requestAnimationFrame(autoScrollTick)\n }\n\n // idempotent: pointerup, pointercancel, Escape, blur and the view-level\n // teardown can race; whichever lands first wins and the rest no-op\n let finished = false\n const cleanup = () => {\n if (finished) return\n finished = true\n activeGestureCancels.delete(cancel)\n if (autoScrollRaf) cancelAnimationFrame(autoScrollRaf)\n try {\n origin.releasePointerCapture(pointerId)\n } catch {\n // capture already released (pointer gone or origin detached)\n }\n window.removeEventListener(\"pointermove\", onPointerMove)\n window.removeEventListener(\"pointerup\", onPointerUp)\n window.removeEventListener(\"pointercancel\", onCancel)\n window.removeEventListener(\"blur\", onWindowBlur)\n window.removeEventListener(\"keydown\", onKeyDown, true)\n if (touchTimer) clearTimeout(touchTimer)\n // consumer-rendered overlays are React-owned: they unmount when the drag\n // state clears, so the engine must never removeChild them itself\n if (!customMoveOverlay) overlay?.remove()\n overlay = null\n if (!customResizeOverlay) resizeOverlay?.remove()\n resizeOverlay = null\n setBodyDragging(false)\n }\n\n const cancel = () => {\n cleanup()\n if (active) {\n lastGestureEndedAt = performance.now()\n internals.setDrag(null)\n internals.setSlotDraft(null)\n }\n }\n\n const onKeyDown = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") {\n e.stopPropagation()\n cancel()\n }\n }\n\n // focus loss mid-gesture (alt-tab, OS dialogs) means the release may never\n // be delivered; treat it as a cancel so the gesture cannot get stuck\n const onWindowBlur = () => cancel()\n\n const onPointerMove = (e: PointerEvent) => {\n if (e.pointerId !== pointerId) return\n lastPointer = e\n if (!active) {\n const distance = Math.hypot(e.clientX - startX, e.clientY - startY)\n if (isTouch) {\n // Long-press pending: moving past tolerance means scroll, not drag\n if (distance > activation.touchTolerancePx) cancel()\n return\n }\n if (distance < activationDistance) return\n activate()\n }\n applyProposal(e)\n positionOverlay(e)\n positionResizeOverlay(e)\n scheduleAutoScroll()\n }\n\n const onPointerUp = (e: PointerEvent) => {\n if (e.pointerId !== pointerId) return\n cleanup()\n if (!active) return\n lastGestureEndedAt = performance.now()\n\n const state = instance.getState()\n if (kind === \"create\") {\n const draft = state.slotDraft\n internals.setSlotDraft(null)\n if (draft) {\n api.select({\n slot: { start: draft.start, end: draft.end, allDay: draft.allDay },\n })\n settings.onSelectSlot?.(draft)\n }\n return\n }\n const drag = state.drag\n internals.setDrag(null)\n if (!drag || !occurrence) return\n // the node refuses concurrency: revert instead of committing an overlap\n if (overlapRejected) return\n const unchanged =\n drag.proposedStart.getTime() === occurrence.start.getTime() &&\n drag.proposedEnd.getTime() === occurrence.end.getTime() &&\n (drag.proposedResourceId === undefined ||\n drag.proposedResourceId === occurrence.event.resourceId)\n if (unchanged) return\n // Commit through the one validation funnel; consumer reject = automatic\n // revert because the gantt never mutated during the gesture.\n const accepted = internals.applyProposedUpdate({\n event: occurrence.event,\n occurrence,\n start: drag.proposedStart,\n end: drag.proposedEnd,\n allDay: drag.proposedAllDay,\n resourceId: drag.proposedResourceId,\n source:\n kind === \"move\" ? \"drag\" : (kind as \"resize-start\" | \"resize-end\"),\n })\n if (accepted && announcer) {\n announcer.textContent = `${occurrence.event.title}, ${settings.i18n.functions.formatEventTime(\n toZoned(drag.proposedStart, settings.timeZone),\n toZoned(drag.proposedEnd, settings.timeZone),\n drag.proposedAllDay,\n settings.locale\n )}`\n }\n }\n\n const onCancel = (e: PointerEvent) => {\n if (e.pointerId !== pointerId) return\n cancel()\n }\n\n window.addEventListener(\"pointermove\", onPointerMove)\n window.addEventListener(\"pointerup\", onPointerUp)\n window.addEventListener(\"pointercancel\", onCancel)\n window.addEventListener(\"blur\", onWindowBlur)\n window.addEventListener(\"keydown\", onKeyDown, true)\n activeGestureCancels.add(cancel)\n\n // Capture the pointer so a release OUTSIDE the OS window still delivers\n // pointerup here instead of leaving the gesture stuck. Captured events keep\n // bubbling to the window listeners above, and if the origin node is removed\n // mid-gesture the capture auto-releases - behavior then degrades to plain\n // window listeners, never worse than before. Guarded: the pointer can\n // already be gone by now (fast flicks, synthetic events).\n try {\n origin.setPointerCapture(pointerId)\n } catch {\n // capture is an enhancement, never a requirement\n }\n\n // Touch: long-press activation (movement past tolerance cancels above)\n if (isTouch && !active) {\n touchTimer = setTimeout(() => {\n activate()\n applyProposal(lastPointer)\n }, activation.touchDelayMs)\n }\n}\n\n/** Per-bar / per-row pointer gesture wiring. */\nfunction useGanttGestures() {\n const instance = useGantt()\n const viewConfig = useGanttViewConfig()\n // presence flags only: the engine skips its default overlay DOM and\n // positions the consumer-rendered node instead\n const customMoveOverlay = !!viewConfig.renderDragPreview\n const customResizeOverlay = !!viewConfig.renderResizeIndicator\n\n const canDrag = useCallback(\n (segment: GanttSegment) => {\n const { interactions } = instance.getState()\n const event = segment.occurrence.event\n return interactions.drag && !event.readOnly && event.draggable !== false\n },\n [instance]\n )\n\n const beginMove = useCallback(\n (e: React.PointerEvent, segment: GanttSegment) => {\n if (e.button !== 0 || !canDrag(segment)) return\n beginGesture({\n instance,\n kind: \"move\",\n origin: e.currentTarget as HTMLElement,\n startEvent: e.nativeEvent,\n segment,\n customMoveOverlay,\n scheduleMode: viewConfig.scheduleMode,\n })\n },\n [instance, canDrag, customMoveOverlay, viewConfig.scheduleMode]\n )\n\n const canResize = useCallback(\n (segment: GanttSegment) => {\n const { interactions } = instance.getState()\n const event = segment.occurrence.event\n return interactions.resize && !event.readOnly && event.resizable !== false\n },\n [instance]\n )\n\n const beginResize = useCallback(\n (\n e: React.PointerEvent,\n segment: GanttSegment,\n edge: \"start\" | \"end\"\n ) => {\n if (e.button !== 0 || !canResize(segment)) return\n e.stopPropagation()\n e.preventDefault()\n beginGesture({\n instance,\n kind: edge === \"start\" ? \"resize-start\" : \"resize-end\",\n origin: e.currentTarget as HTMLElement,\n startEvent: e.nativeEvent,\n segment,\n customResizeOverlay,\n scheduleMode: viewConfig.scheduleMode,\n })\n },\n [instance, canResize, customResizeOverlay, viewConfig.scheduleMode]\n )\n\n const beginCreate = useCallback(\n (e: React.PointerEvent) => {\n if (e.button !== 0) return\n if (!instance.getState().interactions.selectSlot) return\n beginGesture({\n instance,\n kind: \"create\",\n origin: e.currentTarget as HTMLElement,\n startEvent: e.nativeEvent,\n })\n },\n [instance]\n )\n\n return { beginMove, beginResize, beginCreate, canDrag, canResize }\n}\n\nexport {\n cancelActiveGanttGestures,\n GANTT_ACTIVATION,\n markGestureEnd,\n useGanttGestures,\n useGanttGestureTeardown,\n wasRecentDrag,\n}","target":"components/neui/gantt/gantt-dnd.tsx"}]}