diff --git a/apps/web/src/hooks/timelineLivePreview.test.ts b/apps/web/src/hooks/timelineLivePreview.test.ts new file mode 100644 index 0000000..35b32b1 --- /dev/null +++ b/apps/web/src/hooks/timelineLivePreview.test.ts @@ -0,0 +1,147 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + captureLivePreviewTargets, + clearLivePreview, + datesMatch, + joinTransforms, + preserveLivePreview, + scheduleLivePreview, + toPxValue, + type LivePreviewSession, +} from "./timelineLivePreview.js"; + +function createElement(style?: Partial): HTMLElement { + return { + style: { + left: "", + width: "", + transform: "", + ...style, + }, + } as HTMLElement; +} + +function createSession(element: HTMLElement): LivePreviewSession { + return { + mode: "move", + cellWidth: 32, + targets: [ + { + element, + baseLeft: 12, + baseWidth: 48, + baseTransform: "scale(1)", + }, + ], + pointerDeltaX: 0, + daysDelta: 0, + frame: null, + }; +} + +describe("timelineLivePreview", () => { + beforeEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("parses invalid pixel values as zero", () => { + expect(toPxValue("")).toBe(0); + expect(toPxValue("auto")).toBe(0); + expect(toPxValue("12.5px")).toBe(12.5); + }); + + it("joins transforms while ignoring empty and none entries", () => { + expect(joinTransforms(" translateX(2px) ", "", "none", undefined, "scale(1)")).toBe( + "translateX(2px) scale(1)", + ); + }); + + it("deduplicates live preview targets while preserving base styles", () => { + const element = createElement({ + left: "20px", + width: "64px", + transform: "translateX(4px)", + }); + + const targets = captureLivePreviewTargets([element, element]); + + expect(targets).toHaveLength(1); + expect(targets[0]).toMatchObject({ + baseLeft: 20, + baseWidth: 64, + baseTransform: "translateX(4px)", + }); + }); + + it("cancels a pending frame and restores base preview styles when clearing", () => { + const element = createElement({ + left: "40px", + width: "80px", + transform: "translateX(10px)", + }); + + const session = createSession(element); + const cancelAnimationFrameSpy = vi.fn(); + vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrameSpy); + + session.frame = 23; + session.targets[0]!.element.style.left = "99px"; + session.targets[0]!.element.style.width = "120px"; + session.targets[0]!.element.style.transform = "translateX(99px)"; + + clearLivePreview(session); + + expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(23); + expect(session.frame).toBeNull(); + expect(element.style.left).toBe("12px"); + expect(element.style.width).toBe("48px"); + expect(element.style.transform).toBe("scale(1)"); + }); + + it("snapshots current styles and clears pending frames when preserving", () => { + const element = createElement({ + left: "75px", + width: "112px", + transform: "translateX(6px)", + }); + + const session = createSession(element); + const cancelAnimationFrameSpy = vi.fn(); + vi.stubGlobal("cancelAnimationFrame", cancelAnimationFrameSpy); + + session.frame = 11; + + preserveLivePreview(session); + + expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(11); + expect(session.frame).toBeNull(); + expect(session.targets[0]).toMatchObject({ + baseLeft: 75, + baseWidth: 112, + baseTransform: "translateX(6px)", + }); + }); + + it("matches dates only when both timestamps are present and equal", () => { + const date = new Date("2025-05-10T00:00:00.000Z"); + + expect(datesMatch(date, new Date(date))).toBe(true); + expect(datesMatch(date, new Date("2025-05-11T00:00:00.000Z"))).toBe(false); + expect(datesMatch(date, null)).toBe(false); + expect(datesMatch(null, null)).toBe(false); + }); + + it("schedules at most one animation frame for a preview session", () => { + const element = createElement(); + const session = createSession(element); + const requestAnimationFrameSpy = vi.fn(() => 7); + vi.stubGlobal("requestAnimationFrame", requestAnimationFrameSpy); + + scheduleLivePreview(session); + scheduleLivePreview(session); + + expect(requestAnimationFrameSpy).toHaveBeenCalledTimes(1); + expect(session.frame).toBe(7); + }); +}); diff --git a/apps/web/src/hooks/timelineLivePreview.ts b/apps/web/src/hooks/timelineLivePreview.ts new file mode 100644 index 0000000..110fb37 --- /dev/null +++ b/apps/web/src/hooks/timelineLivePreview.ts @@ -0,0 +1,128 @@ +export type LivePreviewMode = "move" | "resize-start" | "resize-end"; + +export type LivePreviewTarget = { + element: HTMLElement; + baseLeft: number; + baseWidth: number; + baseTransform: string; +}; + +export type LivePreviewSession = { + mode: LivePreviewMode; + cellWidth: number; + targets: LivePreviewTarget[]; + pointerDeltaX: number; + daysDelta: number; + frame: number | null; +}; + +export function toPxValue(value: string): number { + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : 0; +} + +export function joinTransforms(...parts: Array): string { + return parts + .map((part) => part?.trim()) + .filter((part): part is string => Boolean(part) && part !== "none") + .join(" "); +} + +export function captureLivePreviewTargets(elements: Iterable): LivePreviewTarget[] { + const seen = new Set(); + 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, + ); + } +} + +export function scheduleLivePreview(session: LivePreviewSession) { + if (session.frame !== null) return; + session.frame = requestAnimationFrame(() => { + session.frame = null; + renderLivePreview(session); + }); +} + +export function clearLivePreview(session: LivePreviewSession | null) { + if (!session) return; + if (session.frame !== null) { + cancelAnimationFrame(session.frame); + session.frame = null; + } + + 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; + } +} + +export function datesMatch(a: Date | null, b: Date | null) { + if (!a || !b) return false; + return a.getTime() === b.getTime(); +} + +export 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; + } +} diff --git a/apps/web/src/hooks/useTimelineDrag.ts b/apps/web/src/hooks/useTimelineDrag.ts index 4c4cb00..73f239e 100644 --- a/apps/web/src/hooks/useTimelineDrag.ts +++ b/apps/web/src/hooks/useTimelineDrag.ts @@ -4,6 +4,14 @@ import { useCallback, useEffect, useRef, useState, type MutableRefObject } from import { trpc } from "~/lib/trpc/client.js"; import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js"; import { pixelsToDays, computeDragDates } from "~/components/timeline/dragMath.js"; +import { + captureLivePreviewTargets, + clearLivePreview, + datesMatch, + preserveLivePreview, + scheduleLivePreview, + type LivePreviewSession, +} from "./timelineLivePreview.js"; const DRAG_CLICK_THRESHOLD_PX = 5; @@ -104,133 +112,6 @@ const INITIAL_ALLOC_DRAG: AllocDragState = { 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 { - return parts - .map((part) => part?.trim()) - .filter((part): part is string => Boolean(part) && part !== "none") - .join(" "); -} - -function captureLivePreviewTargets(elements: Iterable): LivePreviewTarget[] { - const seen = new Set(); - 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 {