fix(calendar-lab): rewrite tldraw zoom as LOD — render only current level shapes
Previous implementation created all ~589 shapes (year + months + weeks + days) at once, causing visual chaos on load and broken zoom. New approach dynamically creates/destroys shapes per level: year shows 12 months, month shows 6 weeks, week shows 7 days, day shows time slots. Wheel prime pattern (2 ticks) prevents accidental zooms. Double-click resets to year view. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -5,27 +5,25 @@ import { createRoot } from "react-dom/client";
|
||||
import { Tldraw, createShapeId, toRichText } from "tldraw";
|
||||
import "tldraw/tldraw.css";
|
||||
|
||||
type TldrawApi = {
|
||||
Tldraw: unknown;
|
||||
createShapeId: (seed?: string) => string;
|
||||
toRichText: (text: string) => unknown;
|
||||
};
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Constants */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
type ShapeNode = {
|
||||
id: string;
|
||||
level: number;
|
||||
parentId: string | null;
|
||||
label: string;
|
||||
bounds: { x: number; y: number; w: number; h: number };
|
||||
};
|
||||
|
||||
const LEVEL_LABELS = ["year", "month", "week", "day"];
|
||||
const LEVEL_LABELS = ["year", "month", "week", "day"] as const;
|
||||
const MONTH_LABELS = [
|
||||
"January", "February", "March", "April",
|
||||
"May", "June", "July", "August",
|
||||
"September", "October", "November", "December",
|
||||
];
|
||||
const ZOOM_MS = 1100;
|
||||
const DAY_LABELS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
const ZOOM_MS = 800;
|
||||
const PRIME_TICKS_REQUIRED = 2;
|
||||
const PRIME_RESET_MS = 600;
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Reactive state */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const hostRef = ref<HTMLDivElement | null>(null);
|
||||
const status = ref("Loading tldraw engine...");
|
||||
@@ -33,177 +31,353 @@ const status = ref("Loading tldraw engine...");
|
||||
const activeLevel = ref(0);
|
||||
const activeLabel = ref("Year 2026");
|
||||
|
||||
const debugInfo = computed(() => `${LEVEL_LABELS[activeLevel.value] ?? "year"}: ${activeLabel.value}`);
|
||||
const debugInfo = computed(
|
||||
() => `${LEVEL_LABELS[activeLevel.value] ?? "year"}: ${activeLabel.value}`,
|
||||
);
|
||||
|
||||
let reactRoot: { unmount: () => void } | null = null;
|
||||
let teardown: (() => void) | null = null;
|
||||
|
||||
function buildScene(api: TldrawApi) {
|
||||
const nodes = new Map<string, ShapeNode>();
|
||||
const children = new Map<string, string[]>();
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Shape builders — one function per zoom level */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
const attachNode = (node: ShapeNode) => {
|
||||
nodes.set(node.id, node);
|
||||
if (!node.parentId) return;
|
||||
if (!children.has(node.parentId)) children.set(node.parentId, []);
|
||||
children.get(node.parentId)?.push(node.id);
|
||||
};
|
||||
type ShapeDef = {
|
||||
id: string;
|
||||
type: "geo";
|
||||
x: number;
|
||||
y: number;
|
||||
isLocked: boolean;
|
||||
props: Record<string, unknown>;
|
||||
meta: Record<string, unknown>;
|
||||
};
|
||||
|
||||
const yearId = api.createShapeId("year-2026");
|
||||
attachNode({
|
||||
id: yearId,
|
||||
level: 0,
|
||||
parentId: null,
|
||||
label: "Year 2026",
|
||||
bounds: { x: 0, y: 0, w: 3200, h: 2200 },
|
||||
});
|
||||
/** Level 0 — year view: 1 container + 12 month cards */
|
||||
function buildYearShapes(): { shapes: ShapeDef[]; containerId: string; childIds: string[] } {
|
||||
const containerId = createShapeId("year-2026");
|
||||
const shapes: ShapeDef[] = [];
|
||||
const childIds: string[] = [];
|
||||
|
||||
const monthCols = 4;
|
||||
const monthRows = 3;
|
||||
const monthGap = 34;
|
||||
const monthW = 730;
|
||||
const monthH = 650;
|
||||
const monthStartX = 70;
|
||||
const monthStartY = 90;
|
||||
|
||||
for (let month = 0; month < 12; month += 1) {
|
||||
const col = month % monthCols;
|
||||
const row = Math.floor(month / monthCols) % monthRows;
|
||||
const x = monthStartX + col * (monthW + monthGap);
|
||||
const y = monthStartY + row * (monthH + monthGap);
|
||||
|
||||
const monthId = api.createShapeId(`month-${month + 1}`);
|
||||
const monthLabel = MONTH_LABELS[month] ?? `Month ${month + 1}`;
|
||||
|
||||
attachNode({
|
||||
id: monthId,
|
||||
level: 1,
|
||||
parentId: yearId,
|
||||
label: monthLabel,
|
||||
bounds: { x, y, w: monthW, h: monthH },
|
||||
});
|
||||
|
||||
const weekGap = 10;
|
||||
const weekRows = 6;
|
||||
const weekX = x + 14;
|
||||
const weekY = y + 54;
|
||||
const weekW = monthW - 28;
|
||||
const weekH = (monthH - 68 - weekGap * (weekRows - 1)) / weekRows;
|
||||
|
||||
for (let week = 0; week < weekRows; week += 1) {
|
||||
const wy = weekY + week * (weekH + weekGap);
|
||||
const weekId = api.createShapeId(`month-${month + 1}-week-${week + 1}`);
|
||||
|
||||
attachNode({
|
||||
id: weekId,
|
||||
level: 2,
|
||||
parentId: monthId,
|
||||
label: `Week ${week + 1}`,
|
||||
bounds: { x: weekX, y: wy, w: weekW, h: weekH },
|
||||
});
|
||||
|
||||
const dayGap = 8;
|
||||
const dayCols = 7;
|
||||
const dayX = weekX + 10;
|
||||
const dayY = wy + 28;
|
||||
const dayW = (weekW - 20 - dayGap * (dayCols - 1)) / dayCols;
|
||||
const dayH = weekH - 36;
|
||||
|
||||
for (let day = 0; day < dayCols; day += 1) {
|
||||
const dx = dayX + day * (dayW + dayGap);
|
||||
const dayId = api.createShapeId(`month-${month + 1}-week-${week + 1}-day-${day + 1}`);
|
||||
attachNode({
|
||||
id: dayId,
|
||||
level: 3,
|
||||
parentId: weekId,
|
||||
label: `Day ${day + 1}`,
|
||||
bounds: { x: dx, y: dayY, w: dayW, h: dayH },
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { yearId, nodes, children };
|
||||
}
|
||||
|
||||
function createShapesFromScene(api: TldrawApi, nodes: Map<string, ShapeNode>) {
|
||||
const list: Array<Record<string, unknown>> = [];
|
||||
|
||||
for (const node of nodes.values()) {
|
||||
list.push({
|
||||
id: node.id,
|
||||
// Year container
|
||||
shapes.push({
|
||||
id: containerId,
|
||||
type: "geo",
|
||||
x: node.bounds.x,
|
||||
y: node.bounds.y,
|
||||
x: 0,
|
||||
y: 0,
|
||||
isLocked: true,
|
||||
props: {
|
||||
geo: "rectangle",
|
||||
w: node.bounds.w,
|
||||
h: node.bounds.h,
|
||||
richText: api.toRichText(node.label),
|
||||
w: 3200,
|
||||
h: 2200,
|
||||
richText: toRichText("2026"),
|
||||
color: "black",
|
||||
fill: "none",
|
||||
dash: "draw",
|
||||
},
|
||||
meta: {
|
||||
level: node.level,
|
||||
label: node.label,
|
||||
meta: { level: 0, label: "Year 2026", role: "container" },
|
||||
});
|
||||
|
||||
const cols = 4;
|
||||
const gap = 34;
|
||||
const cardW = 730;
|
||||
const cardH = 650;
|
||||
const startX = 70;
|
||||
const startY = 90;
|
||||
|
||||
for (let i = 0; i < 12; i++) {
|
||||
const col = i % cols;
|
||||
const row = Math.floor(i / cols);
|
||||
const x = startX + col * (cardW + gap);
|
||||
const y = startY + row * (cardH + gap);
|
||||
const id = createShapeId(`month-${i}`);
|
||||
childIds.push(id);
|
||||
|
||||
shapes.push({
|
||||
id,
|
||||
type: "geo",
|
||||
x,
|
||||
y,
|
||||
isLocked: true,
|
||||
props: {
|
||||
geo: "rectangle",
|
||||
w: cardW,
|
||||
h: cardH,
|
||||
richText: toRichText(MONTH_LABELS[i]!),
|
||||
color: "black",
|
||||
fill: "semi",
|
||||
dash: "draw",
|
||||
},
|
||||
meta: { level: 1, label: MONTH_LABELS[i], role: "child", index: i },
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
return { shapes, containerId, childIds };
|
||||
}
|
||||
|
||||
function setupZoomFlow(editor: any, api: TldrawApi, host: HTMLDivElement) {
|
||||
const { yearId, nodes, children } = buildScene(api);
|
||||
const shapes = createShapesFromScene(api, nodes);
|
||||
editor.createShapes(shapes);
|
||||
/** Level 1 — month view: 1 month container + 6 week rows */
|
||||
function buildMonthShapes(monthIndex: number): { shapes: ShapeDef[]; containerId: string; childIds: string[] } {
|
||||
const containerId = createShapeId(`month-${monthIndex}-expanded`);
|
||||
const shapes: ShapeDef[] = [];
|
||||
const childIds: string[] = [];
|
||||
|
||||
shapes.push({
|
||||
id: containerId,
|
||||
type: "geo",
|
||||
x: 0,
|
||||
y: 0,
|
||||
isLocked: true,
|
||||
props: {
|
||||
geo: "rectangle",
|
||||
w: 2400,
|
||||
h: 1800,
|
||||
richText: toRichText(MONTH_LABELS[monthIndex]!),
|
||||
color: "black",
|
||||
fill: "none",
|
||||
dash: "draw",
|
||||
},
|
||||
meta: { level: 1, label: MONTH_LABELS[monthIndex], role: "container", index: monthIndex },
|
||||
});
|
||||
|
||||
const weekGap = 20;
|
||||
const weekH = (1800 - 100 - weekGap * 5) / 6;
|
||||
const weekW = 2400 - 80;
|
||||
const weekX = 40;
|
||||
const weekStartY = 80;
|
||||
|
||||
for (let w = 0; w < 6; w++) {
|
||||
const y = weekStartY + w * (weekH + weekGap);
|
||||
const id = createShapeId(`month-${monthIndex}-week-${w}`);
|
||||
childIds.push(id);
|
||||
|
||||
shapes.push({
|
||||
id,
|
||||
type: "geo",
|
||||
x: weekX,
|
||||
y,
|
||||
isLocked: true,
|
||||
props: {
|
||||
geo: "rectangle",
|
||||
w: weekW,
|
||||
h: weekH,
|
||||
richText: toRichText(`Week ${w + 1}`),
|
||||
color: "black",
|
||||
fill: "semi",
|
||||
dash: "draw",
|
||||
},
|
||||
meta: { level: 2, label: `Week ${w + 1}`, role: "child", index: w },
|
||||
});
|
||||
}
|
||||
|
||||
return { shapes, containerId, childIds };
|
||||
}
|
||||
|
||||
/** Level 2 — week view: 1 week container + 7 day columns */
|
||||
function buildWeekShapes(monthIndex: number, weekIndex: number): { shapes: ShapeDef[]; containerId: string; childIds: string[] } {
|
||||
const containerId = createShapeId(`week-${monthIndex}-${weekIndex}-expanded`);
|
||||
const shapes: ShapeDef[] = [];
|
||||
const childIds: string[] = [];
|
||||
|
||||
shapes.push({
|
||||
id: containerId,
|
||||
type: "geo",
|
||||
x: 0,
|
||||
y: 0,
|
||||
isLocked: true,
|
||||
props: {
|
||||
geo: "rectangle",
|
||||
w: 2800,
|
||||
h: 1600,
|
||||
richText: toRichText(`${MONTH_LABELS[monthIndex]} · Week ${weekIndex + 1}`),
|
||||
color: "black",
|
||||
fill: "none",
|
||||
dash: "draw",
|
||||
},
|
||||
meta: { level: 2, label: `Week ${weekIndex + 1}`, role: "container", monthIndex, weekIndex },
|
||||
});
|
||||
|
||||
const dayGap = 16;
|
||||
const dayW = (2800 - 80 - dayGap * 6) / 7;
|
||||
const dayH = 1600 - 120;
|
||||
const dayX = 40;
|
||||
const dayY = 80;
|
||||
|
||||
for (let d = 0; d < 7; d++) {
|
||||
const x = dayX + d * (dayW + dayGap);
|
||||
const id = createShapeId(`week-${monthIndex}-${weekIndex}-day-${d}`);
|
||||
childIds.push(id);
|
||||
|
||||
shapes.push({
|
||||
id,
|
||||
type: "geo",
|
||||
x,
|
||||
y: dayY,
|
||||
isLocked: true,
|
||||
props: {
|
||||
geo: "rectangle",
|
||||
w: dayW,
|
||||
h: dayH,
|
||||
richText: toRichText(DAY_LABELS[d]!),
|
||||
color: "black",
|
||||
fill: "semi",
|
||||
dash: "draw",
|
||||
},
|
||||
meta: { level: 3, label: DAY_LABELS[d], role: "child", index: d },
|
||||
});
|
||||
}
|
||||
|
||||
return { shapes, containerId, childIds };
|
||||
}
|
||||
|
||||
/** Level 3 — day view: 1 day container + time slot blocks */
|
||||
function buildDayShapes(monthIndex: number, weekIndex: number, dayIndex: number): { shapes: ShapeDef[]; containerId: string; childIds: string[] } {
|
||||
const containerId = createShapeId(`day-${monthIndex}-${weekIndex}-${dayIndex}-expanded`);
|
||||
const shapes: ShapeDef[] = [];
|
||||
const childIds: string[] = [];
|
||||
|
||||
const dayLabel = DAY_LABELS[dayIndex] ?? `Day ${dayIndex + 1}`;
|
||||
|
||||
shapes.push({
|
||||
id: containerId,
|
||||
type: "geo",
|
||||
x: 0,
|
||||
y: 0,
|
||||
isLocked: true,
|
||||
props: {
|
||||
geo: "rectangle",
|
||||
w: 1200,
|
||||
h: 2400,
|
||||
richText: toRichText(`${MONTH_LABELS[monthIndex]} · Week ${weekIndex + 1} · ${dayLabel}`),
|
||||
color: "black",
|
||||
fill: "none",
|
||||
dash: "draw",
|
||||
},
|
||||
meta: { level: 3, label: dayLabel, role: "container", monthIndex, weekIndex, dayIndex },
|
||||
});
|
||||
|
||||
// Time slots from 8:00 to 20:00 (12 hour-slots)
|
||||
const slotGap = 12;
|
||||
const slotH = (2400 - 100 - slotGap * 11) / 12;
|
||||
const slotW = 1200 - 80;
|
||||
const slotX = 40;
|
||||
const slotStartY = 80;
|
||||
|
||||
for (let s = 0; s < 12; s++) {
|
||||
const y = slotStartY + s * (slotH + slotGap);
|
||||
const hour = 8 + s;
|
||||
const id = createShapeId(`day-${monthIndex}-${weekIndex}-${dayIndex}-slot-${s}`);
|
||||
childIds.push(id);
|
||||
|
||||
shapes.push({
|
||||
id,
|
||||
type: "geo",
|
||||
x: slotX,
|
||||
y,
|
||||
isLocked: true,
|
||||
props: {
|
||||
geo: "rectangle",
|
||||
w: slotW,
|
||||
h: slotH,
|
||||
richText: toRichText(`${hour}:00`),
|
||||
color: "light-violet",
|
||||
fill: "semi",
|
||||
dash: "draw",
|
||||
},
|
||||
meta: { level: 4, label: `${hour}:00`, role: "slot", index: s },
|
||||
});
|
||||
}
|
||||
|
||||
return { shapes, containerId, childIds };
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Main zoom flow controller */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
function setupZoomFlow(editor: any, host: HTMLDivElement) {
|
||||
let currentLevel = 0;
|
||||
let selectedMonth = 0;
|
||||
let selectedWeek = 0;
|
||||
let selectedDay = 0;
|
||||
|
||||
let containerId = "";
|
||||
let childIds: string[] = [];
|
||||
|
||||
let activeId = yearId;
|
||||
let animating = false;
|
||||
let token = 0;
|
||||
|
||||
const moveTo = (shapeId: string, immediate = false) => {
|
||||
const bounds = editor.getShapePageBounds(shapeId);
|
||||
if (!bounds) return;
|
||||
// Wheel prime state
|
||||
let primeDirection: "in" | "out" | "" = "";
|
||||
let primeTicks = 0;
|
||||
let primeTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
/** Wipe canvas and draw shapes for a given level */
|
||||
function renderLevel(level: number, immediate: boolean) {
|
||||
// Delete all existing shapes
|
||||
const allShapeIds = editor.getCurrentPageShapeIds();
|
||||
if (allShapeIds.size > 0) {
|
||||
editor.deleteShapes([...allShapeIds]);
|
||||
}
|
||||
|
||||
let result: { shapes: ShapeDef[]; containerId: string; childIds: string[] };
|
||||
|
||||
switch (level) {
|
||||
case 0:
|
||||
result = buildYearShapes();
|
||||
break;
|
||||
case 1:
|
||||
result = buildMonthShapes(selectedMonth);
|
||||
break;
|
||||
case 2:
|
||||
result = buildWeekShapes(selectedMonth, selectedWeek);
|
||||
break;
|
||||
case 3:
|
||||
result = buildDayShapes(selectedMonth, selectedWeek, selectedDay);
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
editor.createShapes(result.shapes);
|
||||
containerId = result.containerId;
|
||||
childIds = result.childIds;
|
||||
currentLevel = level;
|
||||
|
||||
// Update reactive debug state
|
||||
activeLevel.value = level;
|
||||
const containerMeta = result.shapes.find((s) => s.id === containerId)?.meta;
|
||||
activeLabel.value = (containerMeta?.label as string) ?? LEVEL_LABELS[level] ?? "";
|
||||
|
||||
// Zoom camera to fit container
|
||||
const bounds = editor.getShapePageBounds(containerId);
|
||||
if (bounds) {
|
||||
token += 1;
|
||||
const localToken = token;
|
||||
const duration = immediate ? 0 : ZOOM_MS;
|
||||
|
||||
animating = true;
|
||||
animating = !immediate;
|
||||
editor.stopCameraAnimation();
|
||||
editor.zoomToBounds(bounds, {
|
||||
inset: 20,
|
||||
inset: 40,
|
||||
animation: { duration },
|
||||
immediate,
|
||||
force: true,
|
||||
});
|
||||
|
||||
activeId = shapeId;
|
||||
const node = nodes.get(shapeId);
|
||||
if (node) {
|
||||
activeLevel.value = node.level;
|
||||
activeLabel.value = node.label;
|
||||
if (!immediate) {
|
||||
setTimeout(() => {
|
||||
if (localToken === token) animating = false;
|
||||
}, duration + 50);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (localToken !== token) return;
|
||||
animating = false;
|
||||
}, duration + 40);
|
||||
};
|
||||
/** Resolve which child the mouse is hovering over */
|
||||
function resolveHoveredChild(): string | null {
|
||||
if (childIds.length === 0) return null;
|
||||
|
||||
const firstChild = (shapeId: string) => {
|
||||
const next = children.get(shapeId);
|
||||
return next?.[0] ?? null;
|
||||
};
|
||||
const pointer = editor.inputs.currentPagePoint;
|
||||
if (!pointer) return childIds[0] ?? null;
|
||||
|
||||
const resolveChildFromHover = (shapeId: string) => {
|
||||
const nextIds = children.get(shapeId) ?? [];
|
||||
if (!nextIds.length) return null;
|
||||
|
||||
const pointer = editor.inputs.getCurrentPagePoint();
|
||||
const hit = editor.getShapeAtPoint(pointer, {
|
||||
margin: 0,
|
||||
hitInside: true,
|
||||
@@ -212,73 +386,148 @@ function setupZoomFlow(editor: any, api: TldrawApi, host: HTMLDivElement) {
|
||||
hitFrameInside: true,
|
||||
});
|
||||
|
||||
if (!hit) return firstChild(shapeId);
|
||||
let cursor: string | null = hit.id ?? null;
|
||||
while (cursor && nodes.get(cursor)?.parentId !== shapeId) {
|
||||
cursor = nodes.get(cursor)?.parentId ?? null;
|
||||
if (hit && childIds.includes(hit.id)) return hit.id;
|
||||
|
||||
// If hit is the container itself, try first child
|
||||
return childIds[0] ?? null;
|
||||
}
|
||||
|
||||
if (cursor && nextIds.includes(cursor)) return cursor;
|
||||
return firstChild(shapeId);
|
||||
};
|
||||
function resetPrime() {
|
||||
primeDirection = "";
|
||||
primeTicks = 0;
|
||||
if (primeTimer) {
|
||||
clearTimeout(primeTimer);
|
||||
primeTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
const zoomIn = () => {
|
||||
const node = nodes.get(activeId);
|
||||
if (!node || node.level >= 3) return;
|
||||
const target = resolveChildFromHover(activeId);
|
||||
if (!target) return;
|
||||
moveTo(target, false);
|
||||
};
|
||||
function zoomIn() {
|
||||
if (currentLevel >= 3) return;
|
||||
|
||||
const zoomOut = () => {
|
||||
const parent = nodes.get(activeId)?.parentId;
|
||||
if (!parent) return;
|
||||
moveTo(parent, false);
|
||||
};
|
||||
const targetId = resolveHoveredChild();
|
||||
if (!targetId) return;
|
||||
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
// Find the index of hovered child from its meta
|
||||
const shape = editor.getShape(targetId);
|
||||
const idx = (shape?.meta?.index as number) ?? 0;
|
||||
|
||||
switch (currentLevel) {
|
||||
case 0:
|
||||
selectedMonth = idx;
|
||||
break;
|
||||
case 1:
|
||||
selectedWeek = idx;
|
||||
break;
|
||||
case 2:
|
||||
selectedDay = idx;
|
||||
break;
|
||||
}
|
||||
|
||||
renderLevel(currentLevel + 1, false);
|
||||
}
|
||||
|
||||
function zoomOut() {
|
||||
if (currentLevel <= 0) return;
|
||||
renderLevel(currentLevel - 1, false);
|
||||
}
|
||||
|
||||
function onWheel(event: WheelEvent) {
|
||||
event.preventDefault();
|
||||
if (animating) return;
|
||||
if (event.deltaY < 0) {
|
||||
zoomIn();
|
||||
|
||||
const direction: "in" | "out" = event.deltaY < 0 ? "in" : "out";
|
||||
|
||||
// Check if can go in this direction
|
||||
if (direction === "in" && currentLevel >= 3) return;
|
||||
if (direction === "out" && currentLevel <= 0) return;
|
||||
|
||||
// Reset prime if direction changed
|
||||
if (primeDirection !== direction) {
|
||||
resetPrime();
|
||||
primeDirection = direction;
|
||||
}
|
||||
|
||||
primeTicks += 1;
|
||||
|
||||
// Reset prime timer
|
||||
if (primeTimer) clearTimeout(primeTimer);
|
||||
primeTimer = setTimeout(resetPrime, PRIME_RESET_MS);
|
||||
|
||||
// Highlight target on pre-ticks
|
||||
if (primeTicks < PRIME_TICKS_REQUIRED) {
|
||||
// Visual hint: briefly scale hovered child
|
||||
if (direction === "in") {
|
||||
const targetId = resolveHoveredChild();
|
||||
if (targetId) {
|
||||
const shape = editor.getShape(targetId);
|
||||
if (shape) {
|
||||
// Flash the shape by toggling fill
|
||||
editor.updateShape({
|
||||
id: targetId,
|
||||
type: "geo",
|
||||
props: { fill: "solid" },
|
||||
});
|
||||
setTimeout(() => {
|
||||
try {
|
||||
editor.updateShape({
|
||||
id: targetId,
|
||||
type: "geo",
|
||||
props: { fill: "semi" },
|
||||
});
|
||||
} catch (_) { /* shape might be gone */ }
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Enough ticks — execute zoom
|
||||
resetPrime();
|
||||
|
||||
if (direction === "in") {
|
||||
zoomIn();
|
||||
} else {
|
||||
zoomOut();
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const onDoubleClick = () => {
|
||||
function onDoubleClick() {
|
||||
if (animating) return;
|
||||
moveTo(yearId, false);
|
||||
};
|
||||
resetPrime();
|
||||
if (currentLevel === 0) return;
|
||||
renderLevel(0, false);
|
||||
}
|
||||
|
||||
// Initial render
|
||||
renderLevel(0, true);
|
||||
|
||||
// Event listeners on host element (outside tldraw's event system)
|
||||
host.addEventListener("wheel", onWheel, { passive: false });
|
||||
host.addEventListener("dblclick", onDoubleClick);
|
||||
|
||||
moveTo(yearId, true);
|
||||
|
||||
return () => {
|
||||
resetPrime();
|
||||
host.removeEventListener("wheel", onWheel);
|
||||
host.removeEventListener("dblclick", onDoubleClick);
|
||||
};
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ */
|
||||
/* Lifecycle */
|
||||
/* ------------------------------------------------------------------ */
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
if (!hostRef.value) return;
|
||||
|
||||
const api = {
|
||||
Tldraw,
|
||||
createShapeId,
|
||||
toRichText,
|
||||
} satisfies TldrawApi;
|
||||
|
||||
reactRoot = createRoot(hostRef.value);
|
||||
reactRoot.render(
|
||||
createElement(api.Tldraw, {
|
||||
createElement(Tldraw, {
|
||||
hideUi: true,
|
||||
onMount: (editor: any) => {
|
||||
status.value = "Wheel up = zoom in, wheel down = zoom out, double click = reset";
|
||||
teardown = setupZoomFlow(editor, api, hostRef.value as HTMLDivElement);
|
||||
status.value = "Wheel up = zoom in · wheel down = zoom out · double click = reset";
|
||||
teardown = setupZoomFlow(editor, hostRef.value as HTMLDivElement);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user