feat(platform): checkpoint current implementation state
This commit is contained in:
@@ -14,6 +14,8 @@ interface UseAnchoredOverlayOptions<TTrigger extends HTMLElement> {
|
||||
crossAlign?: VerticalAlign;
|
||||
matchTriggerWidth?: boolean;
|
||||
triggerRef?: RefObject<TTrigger | null>;
|
||||
ignoreElements?: Array<HTMLElement | null>;
|
||||
ignoreSelectors?: string[];
|
||||
}
|
||||
|
||||
interface OverlayPosition {
|
||||
@@ -32,15 +34,19 @@ export function useAnchoredOverlay<TTrigger extends HTMLElement = HTMLElement>({
|
||||
crossAlign = "start",
|
||||
matchTriggerWidth = false,
|
||||
triggerRef: externalTriggerRef,
|
||||
ignoreElements = [],
|
||||
ignoreSelectors = [],
|
||||
}: UseAnchoredOverlayOptions<TTrigger>) {
|
||||
const internalTriggerRef = useRef<TTrigger | null>(null);
|
||||
const triggerRef = externalTriggerRef ?? internalTriggerRef;
|
||||
const panelRef = useRef<HTMLDivElement | null>(null);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
const [position, setPosition] = useState<OverlayPosition>({ top: 0, left: 0 });
|
||||
|
||||
const updatePosition = useCallback(() => {
|
||||
const trigger = triggerRef.current;
|
||||
if (!trigger) {
|
||||
if (!trigger || !trigger.isConnected) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -89,18 +95,30 @@ export function useAnchoredOverlay<TTrigger extends HTMLElement = HTMLElement>({
|
||||
left: boundedLeft,
|
||||
...(matchTriggerWidth ? { minWidth: rect.width } : {}),
|
||||
});
|
||||
}, [align, crossAlign, matchTriggerWidth, offset, side, triggerRef, viewportPadding]);
|
||||
}, [align, crossAlign, matchTriggerWidth, offset, onClose, side, triggerRef, viewportPadding]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
const target = event.target as Node;
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
if (triggerRef.current?.contains(target) || panelRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
if (ignoreElements.some((element) => element?.contains(target))) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
target instanceof Element
|
||||
&& ignoreSelectors.some((selector) => target.closest(selector) !== null)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
@@ -110,30 +128,52 @@ export function useAnchoredOverlay<TTrigger extends HTMLElement = HTMLElement>({
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handlePointerDown);
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
window.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [onClose, open]);
|
||||
}, [ignoreElements, ignoreSelectors, onClose, open, triggerRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
updatePosition();
|
||||
const rafId = window.requestAnimationFrame(updatePosition);
|
||||
function cancelScheduledFrame() {
|
||||
if (frameRef.current === null) {
|
||||
return;
|
||||
}
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
|
||||
window.addEventListener("resize", updatePosition);
|
||||
window.addEventListener("scroll", updatePosition, true);
|
||||
function scheduleUpdate() {
|
||||
if (frameRef.current !== null) {
|
||||
return;
|
||||
}
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
frameRef.current = null;
|
||||
updatePosition();
|
||||
});
|
||||
}
|
||||
|
||||
updatePosition();
|
||||
scheduleUpdate();
|
||||
|
||||
window.addEventListener("resize", scheduleUpdate, { passive: true });
|
||||
window.addEventListener("scroll", scheduleUpdate, true);
|
||||
window.visualViewport?.addEventListener("resize", scheduleUpdate, { passive: true });
|
||||
window.visualViewport?.addEventListener("scroll", scheduleUpdate, { passive: true });
|
||||
|
||||
return () => {
|
||||
window.cancelAnimationFrame(rafId);
|
||||
window.removeEventListener("resize", updatePosition);
|
||||
window.removeEventListener("scroll", updatePosition, true);
|
||||
cancelScheduledFrame();
|
||||
window.removeEventListener("resize", scheduleUpdate);
|
||||
window.removeEventListener("scroll", scheduleUpdate, true);
|
||||
window.visualViewport?.removeEventListener("resize", scheduleUpdate);
|
||||
window.visualViewport?.removeEventListener("scroll", scheduleUpdate);
|
||||
};
|
||||
}, [open, updatePosition]);
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ export function useInvalidateTimeline() {
|
||||
void utils.timeline.getMyEntriesView.invalidate();
|
||||
void utils.timeline.getHolidayOverlays.invalidate();
|
||||
void utils.timeline.getMyHolidayOverlays.invalidate();
|
||||
void utils.vacation.list.invalidate();
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
};
|
||||
@@ -31,6 +32,7 @@ export function useInvalidatePlanningViews() {
|
||||
void utils.timeline.getMyEntriesView.invalidate();
|
||||
void utils.timeline.getHolidayOverlays.invalidate();
|
||||
void utils.timeline.getMyHolidayOverlays.invalidate();
|
||||
void utils.vacation.list.invalidate();
|
||||
void utils.timeline.getProjectContext.invalidate();
|
||||
void utils.timeline.getBudgetStatus.invalidate();
|
||||
};
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
|
||||
import { trpc } from "~/lib/trpc/client.js";
|
||||
import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js";
|
||||
import { pixelsToDays, computeDragDates } from "~/components/timeline/dragMath.js";
|
||||
|
||||
const DRAG_CLICK_THRESHOLD_PX = 5;
|
||||
|
||||
// ─── Project-shift drag state ───────────────────────────────────────────────
|
||||
|
||||
export interface DragState {
|
||||
@@ -17,6 +19,7 @@ export interface DragState {
|
||||
currentStartDate: Date | null;
|
||||
currentEndDate: Date | null;
|
||||
startMouseX: number;
|
||||
pointerDeltaX: number;
|
||||
originalLeft: number;
|
||||
blockWidth: number;
|
||||
daysDelta: number;
|
||||
@@ -50,6 +53,7 @@ const INITIAL_DRAG_STATE: DragState = {
|
||||
currentStartDate: null,
|
||||
currentEndDate: null,
|
||||
startMouseX: 0,
|
||||
pointerDeltaX: 0,
|
||||
originalLeft: 0,
|
||||
blockWidth: 0,
|
||||
daysDelta: 0,
|
||||
@@ -58,39 +62,175 @@ const INITIAL_DRAG_STATE: DragState = {
|
||||
// ─── Per-allocation drag state ──────────────────────────────────────────────
|
||||
|
||||
export type AllocDragMode = "move" | "resize-start" | "resize-end";
|
||||
export type AllocDragScope = "allocation" | "segment";
|
||||
|
||||
export interface AllocDragState {
|
||||
isActive: boolean;
|
||||
mode: AllocDragMode;
|
||||
scope: AllocDragScope;
|
||||
allocationId: string | null;
|
||||
mutationAllocationId: string | null;
|
||||
projectId: string | null;
|
||||
projectName: string | null;
|
||||
resourceId: string | null;
|
||||
allocationStartDate: Date | null;
|
||||
allocationEndDate: Date | null;
|
||||
originalStartDate: Date | null;
|
||||
originalEndDate: Date | null;
|
||||
currentStartDate: Date | null;
|
||||
currentEndDate: Date | null;
|
||||
startMouseX: number;
|
||||
pointerDeltaX: number;
|
||||
daysDelta: number;
|
||||
}
|
||||
|
||||
const INITIAL_ALLOC_DRAG: AllocDragState = {
|
||||
isActive: false,
|
||||
mode: "move",
|
||||
scope: "allocation",
|
||||
allocationId: null,
|
||||
mutationAllocationId: null,
|
||||
projectId: null,
|
||||
projectName: null,
|
||||
resourceId: null,
|
||||
allocationStartDate: null,
|
||||
allocationEndDate: null,
|
||||
originalStartDate: null,
|
||||
originalEndDate: null,
|
||||
currentStartDate: null,
|
||||
currentEndDate: null,
|
||||
startMouseX: 0,
|
||||
pointerDeltaX: 0,
|
||||
daysDelta: 0,
|
||||
};
|
||||
|
||||
type LivePreviewMode = AllocDragMode;
|
||||
|
||||
type LivePreviewTarget = {
|
||||
element: HTMLElement;
|
||||
baseLeft: number;
|
||||
baseWidth: number;
|
||||
baseTransform: string;
|
||||
};
|
||||
|
||||
type LivePreviewSession = {
|
||||
mode: LivePreviewMode;
|
||||
cellWidth: number;
|
||||
targets: LivePreviewTarget[];
|
||||
pointerDeltaX: number;
|
||||
daysDelta: number;
|
||||
frame: number | null;
|
||||
};
|
||||
|
||||
function toPxValue(value: string): number {
|
||||
const parsed = Number.parseFloat(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function joinTransforms(...parts: Array<string | undefined>): string {
|
||||
return parts
|
||||
.map((part) => part?.trim())
|
||||
.filter((part): part is string => Boolean(part) && part !== "none")
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
function captureLivePreviewTargets(elements: Iterable<HTMLElement>): LivePreviewTarget[] {
|
||||
const seen = new Set<HTMLElement>();
|
||||
const targets: LivePreviewTarget[] = [];
|
||||
|
||||
for (const element of elements) {
|
||||
if (seen.has(element)) continue;
|
||||
seen.add(element);
|
||||
targets.push({
|
||||
element,
|
||||
baseLeft: toPxValue(element.style.left),
|
||||
baseWidth: toPxValue(element.style.width),
|
||||
baseTransform: element.style.transform,
|
||||
});
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
function renderLivePreview(session: LivePreviewSession) {
|
||||
const pointerOffsetX = session.pointerDeltaX - session.daysDelta * session.cellWidth;
|
||||
|
||||
for (const target of session.targets) {
|
||||
let left = target.baseLeft;
|
||||
let width = target.baseWidth;
|
||||
|
||||
if (session.mode === "move") {
|
||||
left += session.daysDelta * session.cellWidth;
|
||||
} else if (session.mode === "resize-start") {
|
||||
left += session.daysDelta * session.cellWidth;
|
||||
width = Math.max(session.cellWidth, width - session.daysDelta * session.cellWidth);
|
||||
} else {
|
||||
width = Math.max(session.cellWidth, width + session.daysDelta * session.cellWidth);
|
||||
}
|
||||
|
||||
if (session.mode === "resize-start") {
|
||||
const nextWidth = width - pointerOffsetX;
|
||||
if (nextWidth < session.cellWidth) {
|
||||
left += width - session.cellWidth;
|
||||
width = session.cellWidth;
|
||||
} else {
|
||||
left += pointerOffsetX;
|
||||
width = nextWidth;
|
||||
}
|
||||
} else if (session.mode === "resize-end") {
|
||||
width = Math.max(session.cellWidth, width + pointerOffsetX);
|
||||
}
|
||||
|
||||
target.element.style.left = `${left}px`;
|
||||
target.element.style.width = `${Math.max(session.cellWidth, width)}px`;
|
||||
target.element.style.transform = joinTransforms(
|
||||
target.baseTransform,
|
||||
session.mode === "move" && pointerOffsetX !== 0
|
||||
? `translateX(${pointerOffsetX}px)`
|
||||
: undefined,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleLivePreview(session: LivePreviewSession) {
|
||||
if (session.frame !== null) return;
|
||||
session.frame = requestAnimationFrame(() => {
|
||||
session.frame = null;
|
||||
renderLivePreview(session);
|
||||
});
|
||||
}
|
||||
|
||||
function clearLivePreview(session: LivePreviewSession | null) {
|
||||
if (!session) return;
|
||||
if (session.frame !== null) {
|
||||
cancelAnimationFrame(session.frame);
|
||||
}
|
||||
|
||||
for (const target of session.targets) {
|
||||
target.element.style.left = `${target.baseLeft}px`;
|
||||
target.element.style.width = `${target.baseWidth}px`;
|
||||
target.element.style.transform = target.baseTransform;
|
||||
}
|
||||
}
|
||||
|
||||
function datesMatch(a: Date | null, b: Date | null) {
|
||||
return Boolean(a && b) && a!.getTime() === b!.getTime();
|
||||
}
|
||||
|
||||
function preserveLivePreview(session: LivePreviewSession | null) {
|
||||
if (!session) return;
|
||||
if (session.frame !== null) {
|
||||
cancelAnimationFrame(session.frame);
|
||||
session.frame = null;
|
||||
}
|
||||
|
||||
for (const target of session.targets) {
|
||||
target.baseLeft = toPxValue(target.element.style.left);
|
||||
target.baseWidth = toPxValue(target.element.style.width);
|
||||
target.baseTransform = target.element.style.transform;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Range-select state ─────────────────────────────────────────────────────
|
||||
|
||||
export interface RangeState {
|
||||
@@ -163,8 +303,19 @@ export interface AllocationMovedSnapshot {
|
||||
after: { startDate: Date; endDate: Date };
|
||||
}
|
||||
|
||||
export interface OptimisticTimelineEntry {
|
||||
id: string;
|
||||
startDate: Date | string;
|
||||
endDate: Date | string;
|
||||
}
|
||||
|
||||
export interface OptimisticTimelineOverride {
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
}
|
||||
|
||||
export function useTimelineDrag({
|
||||
cellWidth,
|
||||
cellWidthRef,
|
||||
onShiftApplied,
|
||||
onBlockClick,
|
||||
onRangeSelected,
|
||||
@@ -172,7 +323,7 @@ export function useTimelineDrag({
|
||||
onShiftClickAlloc,
|
||||
onMultiDragComplete,
|
||||
}: {
|
||||
cellWidth: number;
|
||||
cellWidthRef: MutableRefObject<number>;
|
||||
onShiftApplied?: (projectId: string) => void;
|
||||
onBlockClick?: (info: BlockClickInfo) => void;
|
||||
onRangeSelected?: (info: RangeSelectedInfo) => void;
|
||||
@@ -189,13 +340,14 @@ export function useTimelineDrag({
|
||||
const allocDragRef = useRef<AllocDragState>(INITIAL_ALLOC_DRAG);
|
||||
const rangeStateRef = useRef<RangeState>(INITIAL_RANGE_STATE);
|
||||
const multiSelectRef = useRef<MultiSelectState>(INITIAL_MULTI_SELECT);
|
||||
const projectPreviewRef = useRef<LivePreviewSession | null>(null);
|
||||
const allocPreviewRef = useRef<LivePreviewSession | null>(null);
|
||||
const projectDragCleanupRef = useRef<(() => void) | null>(null);
|
||||
const allocDragCleanupRef = useRef<(() => void) | null>(null);
|
||||
const multiSelectCleanupRef = useRef<(() => void) | null>(null);
|
||||
// Keep ref in sync with state so document-level handlers read the latest selection
|
||||
multiSelectRef.current = multiSelectState;
|
||||
|
||||
// Keep always-current refs for values used inside document event handlers
|
||||
const cellWidthRef = useRef(cellWidth);
|
||||
cellWidthRef.current = cellWidth;
|
||||
|
||||
// Touch disambiguation: track initial touch position to distinguish horizontal drag from vertical scroll
|
||||
const touchStartRef = useRef<{ x: number; y: number; decided: boolean }>({
|
||||
x: 0,
|
||||
@@ -218,6 +370,148 @@ export function useTimelineDrag({
|
||||
const utils = trpc.useUtils();
|
||||
const invalidateTimeline = useInvalidateTimeline();
|
||||
|
||||
const setProjectPreviewTargets = useCallback((projectId: string, currentTarget?: EventTarget | null) => {
|
||||
clearLivePreview(projectPreviewRef.current);
|
||||
|
||||
const projectTargets = captureLivePreviewTargets(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
`[data-timeline-drag-preview~="project-shift"][data-timeline-project-id="${projectId}"]`,
|
||||
),
|
||||
);
|
||||
|
||||
if (projectTargets.length === 0 && currentTarget instanceof HTMLElement) {
|
||||
projectTargets.push(...captureLivePreviewTargets([currentTarget]));
|
||||
}
|
||||
|
||||
projectPreviewRef.current =
|
||||
projectTargets.length > 0
|
||||
? {
|
||||
mode: "move",
|
||||
cellWidth: cellWidthRef.current,
|
||||
targets: projectTargets,
|
||||
pointerDeltaX: 0,
|
||||
daysDelta: 0,
|
||||
frame: null,
|
||||
}
|
||||
: null;
|
||||
}, []);
|
||||
|
||||
const setAllocationPreviewTarget = useCallback((currentTarget?: EventTarget | null, mode: AllocDragMode = "move") => {
|
||||
clearLivePreview(allocPreviewRef.current);
|
||||
|
||||
const root =
|
||||
currentTarget instanceof HTMLElement
|
||||
? currentTarget.closest<HTMLElement>('[data-timeline-drag-preview~="allocation"]')
|
||||
: null;
|
||||
const targets = root ? captureLivePreviewTargets([root]) : [];
|
||||
|
||||
allocPreviewRef.current =
|
||||
targets.length > 0
|
||||
? {
|
||||
mode,
|
||||
cellWidth: cellWidthRef.current,
|
||||
targets,
|
||||
pointerDeltaX: 0,
|
||||
daysDelta: 0,
|
||||
frame: null,
|
||||
}
|
||||
: null;
|
||||
}, []);
|
||||
|
||||
const updateLivePreview = useCallback(
|
||||
(previewRef: MutableRefObject<LivePreviewSession | null>, pointerDeltaX: number, daysDelta: number) => {
|
||||
const preview = previewRef.current;
|
||||
if (!preview) return;
|
||||
preview.cellWidth = cellWidthRef.current;
|
||||
preview.pointerDeltaX = pointerDeltaX;
|
||||
preview.daysDelta = daysDelta;
|
||||
scheduleLivePreview(preview);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const updateProjectDragPosition = useCallback(
|
||||
(clientX: number) => {
|
||||
const drag = dragStateRef.current;
|
||||
if (!drag.isDragging || !drag.originalStartDate || !drag.originalEndDate) return false;
|
||||
|
||||
const deltaX = clientX - drag.startMouseX;
|
||||
const daysDelta = pixelsToDays(deltaX, cellWidthRef.current);
|
||||
updateLivePreview(projectPreviewRef, deltaX, daysDelta);
|
||||
|
||||
if (daysDelta === drag.daysDelta) {
|
||||
if (deltaX !== drag.pointerDeltaX) {
|
||||
dragStateRef.current = { ...drag, pointerDeltaX: deltaX };
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const { start: newStart, end: newEnd } = computeDragDates(
|
||||
"move",
|
||||
drag.originalStartDate,
|
||||
drag.originalEndDate,
|
||||
daysDelta,
|
||||
);
|
||||
const updated: DragState = {
|
||||
...drag,
|
||||
currentStartDate: newStart,
|
||||
currentEndDate: newEnd,
|
||||
pointerDeltaX: deltaX,
|
||||
daysDelta,
|
||||
};
|
||||
dragStateRef.current = updated;
|
||||
setDragState(updated);
|
||||
return true;
|
||||
},
|
||||
[updateLivePreview],
|
||||
);
|
||||
|
||||
const updateAllocationDragPosition = useCallback(
|
||||
(clientX: number) => {
|
||||
const alloc = allocDragRef.current;
|
||||
if (!alloc.isActive || !alloc.originalStartDate || !alloc.originalEndDate) return false;
|
||||
|
||||
const pointerDeltaX = clientX - alloc.startMouseX;
|
||||
const daysDelta = pixelsToDays(pointerDeltaX, cellWidthRef.current);
|
||||
updateLivePreview(allocPreviewRef, pointerDeltaX, daysDelta);
|
||||
|
||||
if (daysDelta === alloc.daysDelta) {
|
||||
if (pointerDeltaX !== alloc.pointerDeltaX) {
|
||||
allocDragRef.current = { ...alloc, pointerDeltaX };
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const { start: newStart, end: newEnd } = computeDragDates(
|
||||
alloc.mode,
|
||||
alloc.originalStartDate,
|
||||
alloc.originalEndDate,
|
||||
daysDelta,
|
||||
);
|
||||
|
||||
const updated: AllocDragState = {
|
||||
...alloc,
|
||||
currentStartDate: newStart,
|
||||
currentEndDate: newEnd,
|
||||
pointerDeltaX,
|
||||
daysDelta,
|
||||
};
|
||||
allocDragRef.current = updated;
|
||||
setAllocDragState(updated);
|
||||
return true;
|
||||
},
|
||||
[updateLivePreview],
|
||||
);
|
||||
|
||||
const clearProjectDragSession = useCallback(() => {
|
||||
projectDragCleanupRef.current?.();
|
||||
projectDragCleanupRef.current = null;
|
||||
clearLivePreview(projectPreviewRef.current);
|
||||
projectPreviewRef.current = null;
|
||||
dragStateRef.current = INITIAL_DRAG_STATE;
|
||||
setDragState(INITIAL_DRAG_STATE);
|
||||
}, []);
|
||||
|
||||
// Project-shift preview
|
||||
const { data: previewData, isFetching: isPreviewLoading } = trpc.timeline.previewShift.useQuery(
|
||||
{
|
||||
@@ -248,7 +542,46 @@ export function useTimelineDrag({
|
||||
mutateAsync: (...args: unknown[]) => Promise<unknown>;
|
||||
};
|
||||
|
||||
const finalizeProjectDrag = useCallback(
|
||||
(clientX: number, mode: "mutate" | "mutateAsync" = "mutate") => {
|
||||
updateProjectDragPosition(clientX);
|
||||
const finalDrag = dragStateRef.current;
|
||||
if (!finalDrag.isDragging) return null;
|
||||
|
||||
const mutationInput =
|
||||
finalDrag.daysDelta !== 0 &&
|
||||
finalDrag.projectId &&
|
||||
finalDrag.currentStartDate &&
|
||||
finalDrag.currentEndDate
|
||||
? {
|
||||
projectId: finalDrag.projectId,
|
||||
newStartDate: finalDrag.currentStartDate,
|
||||
newEndDate: finalDrag.currentEndDate,
|
||||
}
|
||||
: null;
|
||||
|
||||
if (finalDrag.daysDelta !== 0) {
|
||||
preserveLivePreview(projectPreviewRef.current);
|
||||
}
|
||||
|
||||
clearProjectDragSession();
|
||||
|
||||
if (!mutationInput) return null;
|
||||
if (mode === "mutateAsync") {
|
||||
return applyShiftMutation.mutateAsync(mutationInput);
|
||||
}
|
||||
|
||||
applyShiftMutation.mutate(mutationInput);
|
||||
return null;
|
||||
},
|
||||
[applyShiftMutation, clearProjectDragSession, updateProjectDragPosition],
|
||||
);
|
||||
|
||||
const pendingSnapshotRef = useRef<AllocationMovedSnapshot | null>(null);
|
||||
const pendingOptimisticAllocationIdRef = useRef<string | null>(null);
|
||||
const [optimisticAllocations, setOptimisticAllocations] = useState<Map<string, OptimisticTimelineOverride>>(
|
||||
() => new Map(),
|
||||
);
|
||||
|
||||
const updateAllocMutation = trpc.timeline.updateAllocationInline.useMutation({
|
||||
onSuccess: () => {
|
||||
@@ -259,8 +592,60 @@ export function useTimelineDrag({
|
||||
pendingSnapshotRef.current = null;
|
||||
}
|
||||
},
|
||||
onError: () => {
|
||||
clearPendingOptimisticAllocation();
|
||||
},
|
||||
});
|
||||
|
||||
const extractAllocFragmentMutation = trpc.timeline.extractAllocationFragment.useMutation({
|
||||
onSuccess: () => {
|
||||
invalidateTimeline();
|
||||
},
|
||||
});
|
||||
|
||||
const clearPendingOptimisticAllocation = useCallback((allocationId?: string | null) => {
|
||||
pendingSnapshotRef.current = null;
|
||||
const optimisticAllocationId = allocationId ?? pendingOptimisticAllocationIdRef.current;
|
||||
if (!optimisticAllocationId) {
|
||||
pendingOptimisticAllocationIdRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
setOptimisticAllocations((prev) => {
|
||||
if (!prev.has(optimisticAllocationId)) return prev;
|
||||
const next = new Map(prev);
|
||||
next.delete(optimisticAllocationId);
|
||||
return next;
|
||||
});
|
||||
pendingOptimisticAllocationIdRef.current = null;
|
||||
}, []);
|
||||
|
||||
const reconcileOptimisticAllocations = useCallback((entries: readonly OptimisticTimelineEntry[]) => {
|
||||
setOptimisticAllocations((prev) => {
|
||||
if (prev.size === 0) return prev;
|
||||
|
||||
const next = new Map(prev);
|
||||
for (const entry of entries) {
|
||||
const override = next.get(entry.id);
|
||||
if (!override) continue;
|
||||
|
||||
const startTime = new Date(entry.startDate).getTime();
|
||||
const endTime = new Date(entry.endDate).getTime();
|
||||
if (
|
||||
startTime === override.startDate.getTime() &&
|
||||
endTime === override.endDate.getTime()
|
||||
) {
|
||||
next.delete(entry.id);
|
||||
if (pendingOptimisticAllocationIdRef.current === entry.id) {
|
||||
pendingOptimisticAllocationIdRef.current = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return next.size === prev.size ? prev : next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ── Project-bar drag (shifts all allocations) ──────────────────────────────
|
||||
|
||||
const onProjectBarMouseDown = useCallback(
|
||||
@@ -286,14 +671,36 @@ export function useTimelineDrag({
|
||||
currentStartDate: opts.startDate,
|
||||
currentEndDate: opts.endDate,
|
||||
startMouseX: e.clientX,
|
||||
pointerDeltaX: 0,
|
||||
originalLeft: 0,
|
||||
blockWidth: 0,
|
||||
daysDelta: 0,
|
||||
};
|
||||
dragStateRef.current = state;
|
||||
setDragState(state);
|
||||
|
||||
setProjectPreviewTargets(opts.projectId, e.currentTarget);
|
||||
projectDragCleanupRef.current?.();
|
||||
|
||||
function handleMove(ev: MouseEvent) {
|
||||
updateProjectDragPosition(ev.clientX);
|
||||
}
|
||||
|
||||
function handleUp(ev: MouseEvent) {
|
||||
projectDragCleanupRef.current?.();
|
||||
projectDragCleanupRef.current = null;
|
||||
void finalizeProjectDrag(ev.clientX);
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
document.addEventListener("mousemove", handleMove);
|
||||
document.addEventListener("mouseup", handleUp);
|
||||
projectDragCleanupRef.current = () => {
|
||||
document.removeEventListener("mousemove", handleMove);
|
||||
document.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
},
|
||||
[],
|
||||
[finalizeProjectDrag, setProjectPreviewTargets, updateProjectDragPosition],
|
||||
);
|
||||
|
||||
// Legacy — kept for backward compat (triggers project shift from allocation block)
|
||||
@@ -323,6 +730,7 @@ export function useTimelineDrag({
|
||||
currentStartDate: opts.startDate,
|
||||
currentEndDate: opts.endDate,
|
||||
startMouseX: e.clientX,
|
||||
pointerDeltaX: 0,
|
||||
originalLeft: opts.blockLeft,
|
||||
blockWidth: opts.blockWidth,
|
||||
daysDelta: 0,
|
||||
@@ -351,6 +759,9 @@ export function useTimelineDrag({
|
||||
resourceId: string | null;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
allocationStartDate?: Date;
|
||||
allocationEndDate?: Date;
|
||||
scope?: AllocDragScope;
|
||||
},
|
||||
) => {
|
||||
if (e.button !== 0) return;
|
||||
@@ -373,6 +784,7 @@ export function useTimelineDrag({
|
||||
|
||||
setMultiSelectState((prev) => ({ ...prev, isMultiDragging: true, multiDragDaysDelta: 0, multiDragMode: dragMode }));
|
||||
multiSelectRef.current = { ...ms, isMultiDragging: true, multiDragDaysDelta: 0, multiDragMode: dragMode };
|
||||
multiSelectCleanupRef.current?.();
|
||||
|
||||
function handleMultiMove(ev: MouseEvent) {
|
||||
const deltaX = ev.clientX - startMouseX;
|
||||
@@ -384,11 +796,11 @@ export function useTimelineDrag({
|
||||
multiSelectRef.current = { ...multiSelectRef.current, multiDragDaysDelta: daysDelta };
|
||||
}
|
||||
|
||||
function handleMultiUp() {
|
||||
document.removeEventListener("mousemove", handleMultiMove);
|
||||
document.removeEventListener("mouseup", handleMultiUp);
|
||||
function handleMultiUp(ev: MouseEvent) {
|
||||
multiSelectCleanupRef.current?.();
|
||||
multiSelectCleanupRef.current = null;
|
||||
|
||||
const finalDelta = currentDaysDelta;
|
||||
const finalDelta = pixelsToDays(ev.clientX - startMouseX, cellWidthRef.current);
|
||||
|
||||
setMultiSelectState((prev) => ({ ...prev, isMultiDragging: false, multiDragDaysDelta: 0 }));
|
||||
multiSelectRef.current = { ...multiSelectRef.current, isMultiDragging: false, multiDragDaysDelta: 0 };
|
||||
@@ -402,6 +814,10 @@ export function useTimelineDrag({
|
||||
|
||||
document.addEventListener("mousemove", handleMultiMove);
|
||||
document.addEventListener("mouseup", handleMultiUp);
|
||||
multiSelectCleanupRef.current = () => {
|
||||
document.removeEventListener("mousemove", handleMultiMove);
|
||||
document.removeEventListener("mouseup", handleMultiUp);
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -410,56 +826,57 @@ export function useTimelineDrag({
|
||||
const initial: AllocDragState = {
|
||||
isActive: true,
|
||||
mode: opts.mode,
|
||||
scope: opts.scope ?? "allocation",
|
||||
allocationId: opts.allocationId,
|
||||
mutationAllocationId: opts.mutationAllocationId ?? opts.allocationId,
|
||||
projectId: opts.projectId,
|
||||
projectName: opts.projectName,
|
||||
resourceId: opts.resourceId,
|
||||
allocationStartDate: opts.allocationStartDate ?? opts.startDate,
|
||||
allocationEndDate: opts.allocationEndDate ?? opts.endDate,
|
||||
originalStartDate: opts.startDate,
|
||||
originalEndDate: opts.endDate,
|
||||
currentStartDate: opts.startDate,
|
||||
currentEndDate: opts.endDate,
|
||||
startMouseX: e.clientX,
|
||||
pointerDeltaX: 0,
|
||||
daysDelta: 0,
|
||||
};
|
||||
allocDragRef.current = initial;
|
||||
setAllocDragState(initial);
|
||||
setAllocationPreviewTarget(e.currentTarget, opts.mode);
|
||||
allocDragCleanupRef.current?.();
|
||||
|
||||
// ── document handlers ────────────────────────────────────────────────
|
||||
|
||||
function handleMove(ev: MouseEvent) {
|
||||
const alloc = allocDragRef.current;
|
||||
if (!alloc.isActive || !alloc.originalStartDate || !alloc.originalEndDate) return;
|
||||
|
||||
const deltaX = ev.clientX - alloc.startMouseX;
|
||||
const daysDelta = pixelsToDays(deltaX, cellWidthRef.current);
|
||||
if (daysDelta === alloc.daysDelta) return;
|
||||
|
||||
const { start: newStart, end: newEnd } = computeDragDates(
|
||||
alloc.mode,
|
||||
alloc.originalStartDate,
|
||||
alloc.originalEndDate,
|
||||
daysDelta,
|
||||
);
|
||||
|
||||
const updated: AllocDragState = {
|
||||
...alloc,
|
||||
currentStartDate: newStart,
|
||||
currentEndDate: newEnd,
|
||||
daysDelta,
|
||||
};
|
||||
allocDragRef.current = updated;
|
||||
setAllocDragState(updated);
|
||||
updateAllocationDragPosition(ev.clientX);
|
||||
}
|
||||
|
||||
function handleUp() {
|
||||
document.removeEventListener("mousemove", handleMove);
|
||||
document.removeEventListener("mouseup", handleUp);
|
||||
|
||||
function handleUp(ev: MouseEvent) {
|
||||
allocDragCleanupRef.current?.();
|
||||
allocDragCleanupRef.current = null;
|
||||
updateAllocationDragPosition(ev.clientX);
|
||||
const alloc = allocDragRef.current;
|
||||
if (!alloc.isActive) return;
|
||||
const pointerDelta = Math.abs(alloc.pointerDeltaX);
|
||||
const hasDateChange =
|
||||
Boolean(alloc.originalStartDate && alloc.currentStartDate && alloc.originalEndDate && alloc.currentEndDate) &&
|
||||
(
|
||||
alloc.originalStartDate!.getTime() !== alloc.currentStartDate!.getTime() ||
|
||||
alloc.originalEndDate!.getTime() !== alloc.currentEndDate!.getTime()
|
||||
);
|
||||
|
||||
if (alloc.daysDelta === 0 && alloc.allocationId) {
|
||||
if (hasDateChange) {
|
||||
preserveLivePreview(allocPreviewRef.current);
|
||||
}
|
||||
clearLivePreview(allocPreviewRef.current);
|
||||
allocPreviewRef.current = null;
|
||||
const shouldTreatAsClick =
|
||||
alloc.mode === "move" &&
|
||||
alloc.daysDelta === 0 &&
|
||||
pointerDelta <= DRAG_CLICK_THRESHOLD_PX;
|
||||
|
||||
if (shouldTreatAsClick && alloc.allocationId) {
|
||||
// No movement → treat as click
|
||||
if (wasShift) {
|
||||
// Shift+Click → toggle multi-selection for this allocation
|
||||
@@ -474,19 +891,61 @@ export function useTimelineDrag({
|
||||
endDate: alloc.originalEndDate!,
|
||||
});
|
||||
}
|
||||
} else if (alloc.allocationId && alloc.currentStartDate && alloc.currentEndDate) {
|
||||
} else if (hasDateChange && alloc.allocationId && alloc.currentStartDate && alloc.currentEndDate) {
|
||||
const activeAllocationId = alloc.allocationId;
|
||||
const currentStartDate = alloc.currentStartDate;
|
||||
const currentEndDate = alloc.currentEndDate;
|
||||
const baseMutationAllocationId = alloc.mutationAllocationId ?? activeAllocationId;
|
||||
const requiresExtraction =
|
||||
alloc.scope === "segment" &&
|
||||
(!datesMatch(alloc.originalStartDate, alloc.allocationStartDate) ||
|
||||
!datesMatch(alloc.originalEndDate, alloc.allocationEndDate));
|
||||
|
||||
pendingSnapshotRef.current = {
|
||||
allocationId: alloc.allocationId,
|
||||
mutationAllocationId: alloc.mutationAllocationId ?? alloc.allocationId,
|
||||
allocationId: activeAllocationId,
|
||||
mutationAllocationId: baseMutationAllocationId,
|
||||
projectName: alloc.projectName ?? "",
|
||||
before: { startDate: alloc.originalStartDate!, endDate: alloc.originalEndDate! },
|
||||
after: { startDate: alloc.currentStartDate, endDate: alloc.currentEndDate },
|
||||
after: { startDate: currentStartDate, endDate: currentEndDate },
|
||||
};
|
||||
updateAllocMutation.mutate({
|
||||
allocationId: alloc.mutationAllocationId ?? alloc.allocationId,
|
||||
startDate: alloc.currentStartDate,
|
||||
endDate: alloc.currentEndDate,
|
||||
pendingOptimisticAllocationIdRef.current = activeAllocationId;
|
||||
setOptimisticAllocations((prev) => {
|
||||
const next = new Map(prev);
|
||||
next.set(activeAllocationId, {
|
||||
startDate: currentStartDate,
|
||||
endDate: currentEndDate,
|
||||
});
|
||||
return next;
|
||||
});
|
||||
void (async () => {
|
||||
try {
|
||||
let mutationAllocationId = baseMutationAllocationId;
|
||||
|
||||
if (requiresExtraction) {
|
||||
const extracted = await extractAllocFragmentMutation.mutateAsync({
|
||||
allocationId: mutationAllocationId,
|
||||
startDate: alloc.originalStartDate!,
|
||||
endDate: alloc.originalEndDate!,
|
||||
});
|
||||
mutationAllocationId = extracted.extractedAllocationId;
|
||||
}
|
||||
|
||||
pendingSnapshotRef.current = pendingSnapshotRef.current
|
||||
? {
|
||||
...pendingSnapshotRef.current,
|
||||
mutationAllocationId,
|
||||
}
|
||||
: null;
|
||||
|
||||
updateAllocMutation.mutate({
|
||||
allocationId: mutationAllocationId,
|
||||
startDate: currentStartDate,
|
||||
endDate: currentEndDate,
|
||||
});
|
||||
} catch {
|
||||
clearPendingOptimisticAllocation(activeAllocationId);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
allocDragRef.current = INITIAL_ALLOC_DRAG;
|
||||
@@ -495,8 +954,18 @@ export function useTimelineDrag({
|
||||
|
||||
document.addEventListener("mousemove", handleMove);
|
||||
document.addEventListener("mouseup", handleUp);
|
||||
allocDragCleanupRef.current = () => {
|
||||
document.removeEventListener("mousemove", handleMove);
|
||||
document.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
},
|
||||
[updateAllocMutation.mutate], // mutate is stable across renders (React Query guarantee)
|
||||
[
|
||||
clearPendingOptimisticAllocation,
|
||||
extractAllocFragmentMutation,
|
||||
setAllocationPreviewTarget,
|
||||
updateAllocationDragPosition,
|
||||
updateAllocMutation,
|
||||
],
|
||||
);
|
||||
|
||||
// ── Range-select ────────────────────────────────────────────────────────────
|
||||
@@ -531,27 +1000,7 @@ export function useTimelineDrag({
|
||||
|
||||
const onCanvasMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
// Project shift
|
||||
const drag = dragStateRef.current;
|
||||
if (drag.isDragging && drag.originalStartDate && drag.originalEndDate) {
|
||||
const deltaX = e.clientX - drag.startMouseX;
|
||||
const daysDelta = pixelsToDays(deltaX, cellWidth);
|
||||
if (daysDelta !== drag.daysDelta) {
|
||||
const { start: newStart, end: newEnd } = computeDragDates(
|
||||
"move",
|
||||
drag.originalStartDate,
|
||||
drag.originalEndDate,
|
||||
daysDelta,
|
||||
);
|
||||
const updated: DragState = {
|
||||
...drag,
|
||||
currentStartDate: newStart,
|
||||
currentEndDate: newEnd,
|
||||
daysDelta,
|
||||
};
|
||||
dragStateRef.current = updated;
|
||||
setDragState(updated);
|
||||
}
|
||||
if (updateProjectDragPosition(e.clientX)) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -559,7 +1008,7 @@ export function useTimelineDrag({
|
||||
const range = rangeStateRef.current;
|
||||
if (range.isSelecting && range.startDate) {
|
||||
const deltaX = e.clientX - range.startClientX;
|
||||
const daysDelta = pixelsToDays(deltaX, cellWidth);
|
||||
const daysDelta = pixelsToDays(deltaX, cellWidthRef.current);
|
||||
const currentDate = new Date(range.startDate);
|
||||
currentDate.setDate(currentDate.getDate() + daysDelta);
|
||||
|
||||
@@ -573,7 +1022,7 @@ export function useTimelineDrag({
|
||||
setRangeState(updated);
|
||||
}
|
||||
},
|
||||
[cellWidth],
|
||||
[updateProjectDragPosition],
|
||||
);
|
||||
|
||||
const onCanvasMouseUp = useCallback(
|
||||
@@ -581,29 +1030,11 @@ export function useTimelineDrag({
|
||||
// Project shift
|
||||
const drag = dragStateRef.current;
|
||||
if (drag.isDragging) {
|
||||
if (drag.daysDelta === 0) {
|
||||
if (drag.projectId && drag.originalStartDate && drag.originalEndDate) {
|
||||
onBlockClick?.({
|
||||
allocationId: drag.allocationId ?? "",
|
||||
projectId: drag.projectId,
|
||||
projectName: drag.projectName ?? "",
|
||||
startDate: drag.originalStartDate,
|
||||
endDate: drag.originalEndDate,
|
||||
});
|
||||
}
|
||||
} else if (drag.projectId && drag.currentStartDate && drag.currentEndDate) {
|
||||
try {
|
||||
await applyShiftMutation.mutateAsync({
|
||||
projectId: drag.projectId,
|
||||
newStartDate: drag.currentStartDate,
|
||||
newEndDate: drag.currentEndDate,
|
||||
});
|
||||
} catch {
|
||||
// Validation error — revert visually
|
||||
}
|
||||
try {
|
||||
await finalizeProjectDrag(e.clientX, "mutateAsync");
|
||||
} catch {
|
||||
// Validation error — revert visually
|
||||
}
|
||||
dragStateRef.current = INITIAL_DRAG_STATE;
|
||||
setDragState(INITIAL_DRAG_STATE);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -627,16 +1058,12 @@ export function useTimelineDrag({
|
||||
setRangeState(INITIAL_RANGE_STATE);
|
||||
}
|
||||
},
|
||||
[applyShiftMutation, onBlockClick, onRangeSelected],
|
||||
[finalizeProjectDrag, onRangeSelected],
|
||||
);
|
||||
|
||||
const onCanvasMouseLeave = useCallback(() => {
|
||||
// Only cancel project-shift and range-select on canvas leave.
|
||||
// Alloc drag is managed by document-level listeners and must NOT be cancelled here.
|
||||
if (dragStateRef.current.isDragging) {
|
||||
dragStateRef.current = INITIAL_DRAG_STATE;
|
||||
setDragState(INITIAL_DRAG_STATE);
|
||||
}
|
||||
if (rangeStateRef.current.isSelecting) {
|
||||
rangeStateRef.current = INITIAL_RANGE_STATE;
|
||||
setRangeState(INITIAL_RANGE_STATE);
|
||||
@@ -664,6 +1091,7 @@ export function useTimelineDrag({
|
||||
};
|
||||
multiSelectRef.current = initial;
|
||||
setMultiSelectState(initial);
|
||||
multiSelectCleanupRef.current?.();
|
||||
|
||||
function handleMove(ev: MouseEvent) {
|
||||
const ms = multiSelectRef.current;
|
||||
@@ -679,8 +1107,8 @@ export function useTimelineDrag({
|
||||
}
|
||||
|
||||
function handleUp(ev: MouseEvent) {
|
||||
document.removeEventListener("mousemove", handleMove);
|
||||
document.removeEventListener("mouseup", handleUp);
|
||||
multiSelectCleanupRef.current?.();
|
||||
multiSelectCleanupRef.current = null;
|
||||
|
||||
const ms = multiSelectRef.current;
|
||||
if (!ms.isSelecting) return;
|
||||
@@ -711,6 +1139,10 @@ export function useTimelineDrag({
|
||||
|
||||
document.addEventListener("mousemove", handleMove);
|
||||
document.addEventListener("mouseup", handleUp);
|
||||
multiSelectCleanupRef.current = () => {
|
||||
document.removeEventListener("mousemove", handleMove);
|
||||
document.removeEventListener("mouseup", handleUp);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const clearMultiSelect = useCallback(() => {
|
||||
@@ -761,6 +1193,9 @@ export function useTimelineDrag({
|
||||
resourceId: string | null;
|
||||
startDate: Date;
|
||||
endDate: Date;
|
||||
allocationStartDate?: Date;
|
||||
allocationEndDate?: Date;
|
||||
scope?: AllocDragScope;
|
||||
},
|
||||
) => {
|
||||
e.preventDefault();
|
||||
@@ -831,6 +1266,25 @@ export function useTimelineDrag({
|
||||
[onCanvasMouseUp],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
projectDragCleanupRef.current?.();
|
||||
allocDragCleanupRef.current?.();
|
||||
multiSelectCleanupRef.current?.();
|
||||
projectDragCleanupRef.current = null;
|
||||
allocDragCleanupRef.current = null;
|
||||
multiSelectCleanupRef.current = null;
|
||||
clearLivePreview(projectPreviewRef.current);
|
||||
clearLivePreview(allocPreviewRef.current);
|
||||
projectPreviewRef.current = null;
|
||||
allocPreviewRef.current = null;
|
||||
dragStateRef.current = INITIAL_DRAG_STATE;
|
||||
allocDragRef.current = INITIAL_ALLOC_DRAG;
|
||||
rangeStateRef.current = INITIAL_RANGE_STATE;
|
||||
multiSelectRef.current = INITIAL_MULTI_SELECT;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// ── Derived ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const shiftPreview: ShiftPreviewData | null =
|
||||
@@ -852,6 +1306,8 @@ export function useTimelineDrag({
|
||||
rangeState,
|
||||
multiSelectState,
|
||||
setMultiSelectState,
|
||||
optimisticAllocations,
|
||||
reconcileOptimisticAllocations,
|
||||
shiftPreview,
|
||||
isPreviewLoading,
|
||||
isApplying: applyShiftMutation.isPending,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useMemo, useRef, type CSSProperties } from "react";
|
||||
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
|
||||
type PopoverAnchor =
|
||||
| { kind: "point"; x: number; y: number }
|
||||
@@ -17,6 +17,8 @@ interface UseViewportPopoverOptions {
|
||||
offset?: number;
|
||||
viewportPadding?: number;
|
||||
ignoreElements?: Array<HTMLElement | null>;
|
||||
ignoreSelectors?: string[];
|
||||
zIndex?: number;
|
||||
}
|
||||
|
||||
export function useViewportPopover({
|
||||
@@ -29,37 +31,23 @@ export function useViewportPopover({
|
||||
offset = 8,
|
||||
viewportPadding = 16,
|
||||
ignoreElements = [],
|
||||
ignoreSelectors = [],
|
||||
zIndex = 9998,
|
||||
}: UseViewportPopoverOptions) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerDown(event: MouseEvent) {
|
||||
const target = event.target as Node;
|
||||
if (ref.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
if (ignoreElements.some((element) => element?.contains(target))) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
const computeStyle = (): CSSProperties => {
|
||||
if (typeof window === "undefined") {
|
||||
return {
|
||||
position: "fixed",
|
||||
left: viewportPadding,
|
||||
top: viewportPadding,
|
||||
width,
|
||||
zIndex,
|
||||
};
|
||||
}
|
||||
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("mousedown", handlePointerDown);
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("mousedown", handlePointerDown);
|
||||
window.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [ignoreElements, onClose]);
|
||||
|
||||
const style = useMemo<CSSProperties>(() => {
|
||||
let left = 0;
|
||||
let top = 0;
|
||||
|
||||
@@ -94,17 +82,134 @@ export function useViewportPopover({
|
||||
}
|
||||
}
|
||||
|
||||
const maxLeft = Math.max(viewportPadding, window.innerWidth - width - viewportPadding);
|
||||
const maxTop = Math.max(viewportPadding, window.innerHeight - estimatedHeight - viewportPadding);
|
||||
const measuredWidth = ref.current?.offsetWidth ?? width;
|
||||
const measuredHeight = ref.current?.offsetHeight ?? estimatedHeight;
|
||||
const maxLeft = Math.max(viewportPadding, window.innerWidth - measuredWidth - viewportPadding);
|
||||
const maxTop = Math.max(viewportPadding, window.innerHeight - measuredHeight - viewportPadding);
|
||||
|
||||
return {
|
||||
position: "fixed",
|
||||
left: Math.min(Math.max(left, viewportPadding), maxLeft),
|
||||
top: Math.min(Math.max(top, viewportPadding), maxTop),
|
||||
width,
|
||||
zIndex: 60,
|
||||
zIndex,
|
||||
};
|
||||
}, [align, anchor, estimatedHeight, offset, side, viewportPadding, width]);
|
||||
};
|
||||
|
||||
const [style, setStyle] = useState<CSSProperties>(() => computeStyle());
|
||||
|
||||
useEffect(() => {
|
||||
function handlePointerDown(event: PointerEvent) {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) {
|
||||
return;
|
||||
}
|
||||
if (ref.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
if (ignoreElements.some((element) => element?.contains(target))) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
target instanceof Element
|
||||
&& ignoreSelectors.some((selector) => target.closest(selector) !== null)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
}
|
||||
|
||||
function handleEscape(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener("pointerdown", handlePointerDown, true);
|
||||
window.addEventListener("keydown", handleEscape);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("pointerdown", handlePointerDown, true);
|
||||
window.removeEventListener("keydown", handleEscape);
|
||||
};
|
||||
}, [ignoreElements, ignoreSelectors, onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
setStyle(computeStyle());
|
||||
}, [align, anchor, estimatedHeight, offset, side, viewportPadding, width, zIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element || typeof ResizeObserver === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new ResizeObserver(() => {
|
||||
setStyle(computeStyle());
|
||||
});
|
||||
observer.observe(element);
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
};
|
||||
}, [align, anchor, estimatedHeight, offset, side, viewportPadding, width, zIndex]);
|
||||
|
||||
useEffect(() => {
|
||||
function cancelScheduledFrame() {
|
||||
if (frameRef.current === null) return;
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
|
||||
function updateOrClose() {
|
||||
if (anchor.kind === "element" && !anchor.element.isConnected) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
setStyle(computeStyle());
|
||||
}
|
||||
|
||||
function scheduleUpdate(reason: "scroll" | "resize") {
|
||||
if (frameRef.current !== null) return;
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
frameRef.current = null;
|
||||
updateOrClose();
|
||||
});
|
||||
}
|
||||
|
||||
updateOrClose();
|
||||
|
||||
const handleScroll = () => {
|
||||
scheduleUpdate("scroll");
|
||||
};
|
||||
const handleResize = () => {
|
||||
scheduleUpdate("resize");
|
||||
};
|
||||
|
||||
window.addEventListener("scroll", handleScroll, true);
|
||||
window.addEventListener("resize", handleResize, { passive: true });
|
||||
window.visualViewport?.addEventListener("resize", handleResize, { passive: true });
|
||||
window.visualViewport?.addEventListener("scroll", handleScroll, { passive: true });
|
||||
|
||||
return () => {
|
||||
cancelScheduledFrame();
|
||||
window.removeEventListener("scroll", handleScroll, true);
|
||||
window.removeEventListener("resize", handleResize);
|
||||
window.visualViewport?.removeEventListener("resize", handleResize);
|
||||
window.visualViewport?.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}, [
|
||||
align,
|
||||
anchor,
|
||||
estimatedHeight,
|
||||
offset,
|
||||
onClose,
|
||||
side,
|
||||
viewportPadding,
|
||||
width,
|
||||
zIndex,
|
||||
]);
|
||||
|
||||
return { ref, style };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user