refactor(web): extract timeline live preview helpers
This commit is contained in:
@@ -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<CSSStyleDeclaration>): 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);
|
||||
});
|
||||
});
|
||||
@@ -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 | undefined>): string {
|
||||
return parts
|
||||
.map((part) => part?.trim())
|
||||
.filter((part): part is string => Boolean(part) && part !== "none")
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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 | 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 {
|
||||
|
||||
Reference in New Issue
Block a user