refactor(web): extract timeline live preview helpers

This commit is contained in:
2026-04-01 09:40:07 +02:00
parent 2855567456
commit 5011d071b8
3 changed files with 283 additions and 127 deletions
@@ -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);
});
});
+128
View File
@@ -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;
}
}
+8 -127
View File
@@ -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 {