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, createShapeId, toRichText } from "tldraw";
|
||||||
import "tldraw/tldraw.css";
|
import "tldraw/tldraw.css";
|
||||||
|
|
||||||
type TldrawApi = {
|
/* ------------------------------------------------------------------ */
|
||||||
Tldraw: unknown;
|
/* Constants */
|
||||||
createShapeId: (seed?: string) => string;
|
/* ------------------------------------------------------------------ */
|
||||||
toRichText: (text: string) => unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ShapeNode = {
|
const LEVEL_LABELS = ["year", "month", "week", "day"] as const;
|
||||||
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 MONTH_LABELS = [
|
const MONTH_LABELS = [
|
||||||
"January", "February", "March", "April",
|
"January", "February", "March", "April",
|
||||||
"May", "June", "July", "August",
|
"May", "June", "July", "August",
|
||||||
"September", "October", "November", "December",
|
"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 hostRef = ref<HTMLDivElement | null>(null);
|
||||||
const status = ref("Loading tldraw engine...");
|
const status = ref("Loading tldraw engine...");
|
||||||
@@ -33,177 +31,353 @@ const status = ref("Loading tldraw engine...");
|
|||||||
const activeLevel = ref(0);
|
const activeLevel = ref(0);
|
||||||
const activeLabel = ref("Year 2026");
|
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 reactRoot: { unmount: () => void } | null = null;
|
||||||
let teardown: (() => void) | null = null;
|
let teardown: (() => void) | null = null;
|
||||||
|
|
||||||
function buildScene(api: TldrawApi) {
|
/* ------------------------------------------------------------------ */
|
||||||
const nodes = new Map<string, ShapeNode>();
|
/* Shape builders — one function per zoom level */
|
||||||
const children = new Map<string, string[]>();
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
const attachNode = (node: ShapeNode) => {
|
type ShapeDef = {
|
||||||
nodes.set(node.id, node);
|
id: string;
|
||||||
if (!node.parentId) return;
|
type: "geo";
|
||||||
if (!children.has(node.parentId)) children.set(node.parentId, []);
|
x: number;
|
||||||
children.get(node.parentId)?.push(node.id);
|
y: number;
|
||||||
};
|
isLocked: boolean;
|
||||||
|
props: Record<string, unknown>;
|
||||||
|
meta: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
|
||||||
const yearId = api.createShapeId("year-2026");
|
/** Level 0 — year view: 1 container + 12 month cards */
|
||||||
attachNode({
|
function buildYearShapes(): { shapes: ShapeDef[]; containerId: string; childIds: string[] } {
|
||||||
id: yearId,
|
const containerId = createShapeId("year-2026");
|
||||||
level: 0,
|
const shapes: ShapeDef[] = [];
|
||||||
parentId: null,
|
const childIds: string[] = [];
|
||||||
label: "Year 2026",
|
|
||||||
bounds: { x: 0, y: 0, w: 3200, h: 2200 },
|
// Year container
|
||||||
|
shapes.push({
|
||||||
|
id: containerId,
|
||||||
|
type: "geo",
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
isLocked: true,
|
||||||
|
props: {
|
||||||
|
geo: "rectangle",
|
||||||
|
w: 3200,
|
||||||
|
h: 2200,
|
||||||
|
richText: toRichText("2026"),
|
||||||
|
color: "black",
|
||||||
|
fill: "none",
|
||||||
|
dash: "draw",
|
||||||
|
},
|
||||||
|
meta: { level: 0, label: "Year 2026", role: "container" },
|
||||||
});
|
});
|
||||||
|
|
||||||
const monthCols = 4;
|
const cols = 4;
|
||||||
const monthRows = 3;
|
const gap = 34;
|
||||||
const monthGap = 34;
|
const cardW = 730;
|
||||||
const monthW = 730;
|
const cardH = 650;
|
||||||
const monthH = 650;
|
const startX = 70;
|
||||||
const monthStartX = 70;
|
const startY = 90;
|
||||||
const monthStartY = 90;
|
|
||||||
|
|
||||||
for (let month = 0; month < 12; month += 1) {
|
for (let i = 0; i < 12; i++) {
|
||||||
const col = month % monthCols;
|
const col = i % cols;
|
||||||
const row = Math.floor(month / monthCols) % monthRows;
|
const row = Math.floor(i / cols);
|
||||||
const x = monthStartX + col * (monthW + monthGap);
|
const x = startX + col * (cardW + gap);
|
||||||
const y = monthStartY + row * (monthH + monthGap);
|
const y = startY + row * (cardH + gap);
|
||||||
|
const id = createShapeId(`month-${i}`);
|
||||||
|
childIds.push(id);
|
||||||
|
|
||||||
const monthId = api.createShapeId(`month-${month + 1}`);
|
shapes.push({
|
||||||
const monthLabel = MONTH_LABELS[month] ?? `Month ${month + 1}`;
|
id,
|
||||||
|
type: "geo",
|
||||||
attachNode({
|
x,
|
||||||
id: monthId,
|
y,
|
||||||
level: 1,
|
isLocked: true,
|
||||||
parentId: yearId,
|
props: {
|
||||||
label: monthLabel,
|
geo: "rectangle",
|
||||||
bounds: { x, y, w: monthW, h: monthH },
|
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 },
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const weekGap = 10;
|
return { shapes, containerId, childIds };
|
||||||
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) {
|
/** Level 1 — month view: 1 month container + 6 week rows */
|
||||||
const wy = weekY + week * (weekH + weekGap);
|
function buildMonthShapes(monthIndex: number): { shapes: ShapeDef[]; containerId: string; childIds: string[] } {
|
||||||
const weekId = api.createShapeId(`month-${month + 1}-week-${week + 1}`);
|
const containerId = createShapeId(`month-${monthIndex}-expanded`);
|
||||||
|
const shapes: ShapeDef[] = [];
|
||||||
|
const childIds: string[] = [];
|
||||||
|
|
||||||
attachNode({
|
shapes.push({
|
||||||
id: weekId,
|
id: containerId,
|
||||||
level: 2,
|
type: "geo",
|
||||||
parentId: monthId,
|
x: 0,
|
||||||
label: `Week ${week + 1}`,
|
y: 0,
|
||||||
bounds: { x: weekX, y: wy, w: weekW, h: weekH },
|
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 animating = false;
|
||||||
|
let token = 0;
|
||||||
|
|
||||||
|
// 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 = !immediate;
|
||||||
|
editor.stopCameraAnimation();
|
||||||
|
editor.zoomToBounds(bounds, {
|
||||||
|
inset: 40,
|
||||||
|
animation: { duration },
|
||||||
|
immediate,
|
||||||
|
force: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const dayGap = 8;
|
if (!immediate) {
|
||||||
const dayCols = 7;
|
setTimeout(() => {
|
||||||
const dayX = weekX + 10;
|
if (localToken === token) animating = false;
|
||||||
const dayY = wy + 28;
|
}, duration + 50);
|
||||||
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 };
|
/** Resolve which child the mouse is hovering over */
|
||||||
}
|
function resolveHoveredChild(): string | null {
|
||||||
|
if (childIds.length === 0) return null;
|
||||||
|
|
||||||
function createShapesFromScene(api: TldrawApi, nodes: Map<string, ShapeNode>) {
|
const pointer = editor.inputs.currentPagePoint;
|
||||||
const list: Array<Record<string, unknown>> = [];
|
if (!pointer) return childIds[0] ?? null;
|
||||||
|
|
||||||
for (const node of nodes.values()) {
|
|
||||||
list.push({
|
|
||||||
id: node.id,
|
|
||||||
type: "geo",
|
|
||||||
x: node.bounds.x,
|
|
||||||
y: node.bounds.y,
|
|
||||||
isLocked: true,
|
|
||||||
props: {
|
|
||||||
geo: "rectangle",
|
|
||||||
w: node.bounds.w,
|
|
||||||
h: node.bounds.h,
|
|
||||||
richText: api.toRichText(node.label),
|
|
||||||
color: "black",
|
|
||||||
fill: "none",
|
|
||||||
dash: "draw",
|
|
||||||
},
|
|
||||||
meta: {
|
|
||||||
level: node.level,
|
|
||||||
label: node.label,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setupZoomFlow(editor: any, api: TldrawApi, host: HTMLDivElement) {
|
|
||||||
const { yearId, nodes, children } = buildScene(api);
|
|
||||||
const shapes = createShapesFromScene(api, nodes);
|
|
||||||
editor.createShapes(shapes);
|
|
||||||
|
|
||||||
let activeId = yearId;
|
|
||||||
let animating = false;
|
|
||||||
let token = 0;
|
|
||||||
|
|
||||||
const moveTo = (shapeId: string, immediate = false) => {
|
|
||||||
const bounds = editor.getShapePageBounds(shapeId);
|
|
||||||
if (!bounds) return;
|
|
||||||
|
|
||||||
token += 1;
|
|
||||||
const localToken = token;
|
|
||||||
const duration = immediate ? 0 : ZOOM_MS;
|
|
||||||
|
|
||||||
animating = true;
|
|
||||||
editor.stopCameraAnimation();
|
|
||||||
editor.zoomToBounds(bounds, {
|
|
||||||
inset: 20,
|
|
||||||
animation: { duration },
|
|
||||||
immediate,
|
|
||||||
force: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
activeId = shapeId;
|
|
||||||
const node = nodes.get(shapeId);
|
|
||||||
if (node) {
|
|
||||||
activeLevel.value = node.level;
|
|
||||||
activeLabel.value = node.label;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.setTimeout(() => {
|
|
||||||
if (localToken !== token) return;
|
|
||||||
animating = false;
|
|
||||||
}, duration + 40);
|
|
||||||
};
|
|
||||||
|
|
||||||
const firstChild = (shapeId: string) => {
|
|
||||||
const next = children.get(shapeId);
|
|
||||||
return next?.[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, {
|
const hit = editor.getShapeAtPoint(pointer, {
|
||||||
margin: 0,
|
margin: 0,
|
||||||
hitInside: true,
|
hitInside: true,
|
||||||
@@ -212,73 +386,148 @@ function setupZoomFlow(editor: any, api: TldrawApi, host: HTMLDivElement) {
|
|||||||
hitFrameInside: true,
|
hitFrameInside: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!hit) return firstChild(shapeId);
|
if (hit && childIds.includes(hit.id)) return hit.id;
|
||||||
let cursor: string | null = hit.id ?? null;
|
|
||||||
while (cursor && nodes.get(cursor)?.parentId !== shapeId) {
|
// If hit is the container itself, try first child
|
||||||
cursor = nodes.get(cursor)?.parentId ?? null;
|
return childIds[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetPrime() {
|
||||||
|
primeDirection = "";
|
||||||
|
primeTicks = 0;
|
||||||
|
if (primeTimer) {
|
||||||
|
clearTimeout(primeTimer);
|
||||||
|
primeTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function zoomIn() {
|
||||||
|
if (currentLevel >= 3) return;
|
||||||
|
|
||||||
|
const targetId = resolveHoveredChild();
|
||||||
|
if (!targetId) return;
|
||||||
|
|
||||||
|
// 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cursor && nextIds.includes(cursor)) return cursor;
|
renderLevel(currentLevel + 1, false);
|
||||||
return firstChild(shapeId);
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const zoomIn = () => {
|
function zoomOut() {
|
||||||
const node = nodes.get(activeId);
|
if (currentLevel <= 0) return;
|
||||||
if (!node || node.level >= 3) return;
|
renderLevel(currentLevel - 1, false);
|
||||||
const target = resolveChildFromHover(activeId);
|
}
|
||||||
if (!target) return;
|
|
||||||
moveTo(target, false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const zoomOut = () => {
|
function onWheel(event: WheelEvent) {
|
||||||
const parent = nodes.get(activeId)?.parentId;
|
|
||||||
if (!parent) return;
|
|
||||||
moveTo(parent, false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const onWheel = (event: WheelEvent) => {
|
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (animating) return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
zoomOut();
|
|
||||||
};
|
|
||||||
|
|
||||||
const onDoubleClick = () => {
|
// Enough ticks — execute zoom
|
||||||
|
resetPrime();
|
||||||
|
|
||||||
|
if (direction === "in") {
|
||||||
|
zoomIn();
|
||||||
|
} else {
|
||||||
|
zoomOut();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDoubleClick() {
|
||||||
if (animating) return;
|
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("wheel", onWheel, { passive: false });
|
||||||
host.addEventListener("dblclick", onDoubleClick);
|
host.addEventListener("dblclick", onDoubleClick);
|
||||||
|
|
||||||
moveTo(yearId, true);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
resetPrime();
|
||||||
host.removeEventListener("wheel", onWheel);
|
host.removeEventListener("wheel", onWheel);
|
||||||
host.removeEventListener("dblclick", onDoubleClick);
|
host.removeEventListener("dblclick", onDoubleClick);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
/* Lifecycle */
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
try {
|
try {
|
||||||
if (!hostRef.value) return;
|
if (!hostRef.value) return;
|
||||||
|
|
||||||
const api = {
|
|
||||||
Tldraw,
|
|
||||||
createShapeId,
|
|
||||||
toRichText,
|
|
||||||
} satisfies TldrawApi;
|
|
||||||
|
|
||||||
reactRoot = createRoot(hostRef.value);
|
reactRoot = createRoot(hostRef.value);
|
||||||
reactRoot.render(
|
reactRoot.render(
|
||||||
createElement(api.Tldraw, {
|
createElement(Tldraw, {
|
||||||
hideUi: true,
|
hideUi: true,
|
||||||
onMount: (editor: any) => {
|
onMount: (editor: any) => {
|
||||||
status.value = "Wheel up = zoom in, wheel down = zoom out, double click = reset";
|
status.value = "Wheel up = zoom in · wheel down = zoom out · double click = reset";
|
||||||
teardown = setupZoomFlow(editor, api, hostRef.value as HTMLDivElement);
|
teardown = setupZoomFlow(editor, hostRef.value as HTMLDivElement);
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user