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 { trpc } from "~/lib/trpc/client.js";
|
||||||
import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js";
|
import { useInvalidateTimeline } from "./useInvalidatePlanningViews.js";
|
||||||
import { pixelsToDays, computeDragDates } from "~/components/timeline/dragMath.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;
|
const DRAG_CLICK_THRESHOLD_PX = 5;
|
||||||
|
|
||||||
@@ -104,133 +112,6 @@ const INITIAL_ALLOC_DRAG: AllocDragState = {
|
|||||||
daysDelta: 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 ─────────────────────────────────────────────────────
|
// ─── Range-select state ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
export interface RangeState {
|
export interface RangeState {
|
||||||
|
|||||||
Reference in New Issue
Block a user