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
+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 {