feat(calendar-lab): use tldraw canvas engine with nested zoom rectangles

This commit is contained in:
Ruslan Bakiev
2026-02-23 19:29:52 +07:00
parent 94d8d46693
commit ed78532260
2 changed files with 319 additions and 25 deletions

View File

@@ -1,29 +1,319 @@
<script setup lang="ts">
const tldrawUrl = "https://www.tldraw.com";
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
type TldrawApi = {
Tldraw: unknown;
createShapeId: (seed?: string) => string;
toRichText: (text: string) => unknown;
};
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 MONTH_LABELS = [
"January", "February", "March", "April",
"May", "June", "July", "August",
"September", "October", "November", "December",
];
const ZOOM_MS = 1100;
const hostRef = ref<HTMLDivElement | null>(null);
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}`);
let reactRoot: { unmount: () => void } | null = null;
let teardown: (() => void) | null = null;
function ensureTldrawCss() {
if (document.querySelector("link[data-tldraw-css='1']")) return;
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "https://esm.sh/tldraw@4.4.0/tldraw.css";
link.setAttribute("data-tldraw-css", "1");
document.head.append(link);
}
function buildScene(api: TldrawApi) {
const nodes = new Map<string, ShapeNode>();
const children = new Map<string, string[]>();
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);
};
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 },
});
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,
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, {
margin: 0,
hitInside: true,
hitLocked: true,
hitLabels: true,
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 (cursor && nextIds.includes(cursor)) return cursor;
return firstChild(shapeId);
};
const zoomIn = () => {
const node = nodes.get(activeId);
if (!node || node.level >= 3) return;
const target = resolveChildFromHover(activeId);
if (!target) return;
moveTo(target, false);
};
const zoomOut = () => {
const parent = nodes.get(activeId)?.parentId;
if (!parent) return;
moveTo(parent, false);
};
const onWheel = (event: WheelEvent) => {
event.preventDefault();
if (animating) return;
if (event.deltaY < 0) {
zoomIn();
return;
}
zoomOut();
};
const onDoubleClick = () => {
if (animating) return;
moveTo(yearId, false);
};
host.addEventListener("wheel", onWheel, { passive: false });
host.addEventListener("dblclick", onDoubleClick);
moveTo(yearId, true);
return () => {
host.removeEventListener("wheel", onWheel);
host.removeEventListener("dblclick", onDoubleClick);
};
}
onMounted(async () => {
try {
if (!hostRef.value) return;
ensureTldrawCss();
const [react, reactDomClient, tldraw] = await Promise.all([
import(/* @vite-ignore */ "https://esm.sh/react@18.3.1"),
import(/* @vite-ignore */ "https://esm.sh/react-dom@18.3.1/client"),
import(/* @vite-ignore */ "https://esm.sh/tldraw@4.4.0?bundle"),
]);
const createElement = (react as any).createElement as (...args: unknown[]) => unknown;
const createRoot = (reactDomClient as any).createRoot as (el: Element) => { render: (node: unknown) => void; unmount: () => void };
const api = tldraw as unknown as TldrawApi;
reactRoot = createRoot(hostRef.value);
reactRoot.render(
createElement(api.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);
},
}),
);
} catch (error) {
status.value = "Failed to load tldraw engine from CDN";
console.error(error);
}
});
onBeforeUnmount(() => {
teardown?.();
teardown = null;
reactRoot?.unmount();
reactRoot = null;
});
</script>
<template>
<section class="tldraw-lab-root">
<div class="tldraw-lab-toolbar">
<p class="tldraw-lab-title">tldraw demo (infinite canvas)</p>
<a
class="btn btn-xs btn-outline"
:href="tldrawUrl"
target="_blank"
rel="noopener noreferrer"
>
Open in new tab
</a>
</div>
<iframe
class="tldraw-lab-frame"
:src="tldrawUrl"
title="tldraw demo"
loading="lazy"
referrerpolicy="no-referrer"
allowfullscreen
/>
<header class="tldraw-lab-toolbar">
<p class="tldraw-lab-title">{{ status }}</p>
<p class="tldraw-lab-subtitle">{{ debugInfo }}</p>
</header>
<div ref="hostRef" class="tldraw-lab-canvas" />
</section>
</template>
@@ -55,9 +345,13 @@ const tldrawUrl = "https://www.tldraw.com";
color: color-mix(in oklab, var(--color-base-content) 72%, transparent);
}
.tldraw-lab-frame {
.tldraw-lab-subtitle {
font-size: 0.74rem;
color: color-mix(in oklab, var(--color-base-content) 60%, transparent);
}
.tldraw-lab-canvas {
width: 100%;
height: calc(100% - 42px);
border: 0;
}
</style>

View File

@@ -1,9 +1,9 @@
<script setup lang="ts">
import CrmCalendarZoomLab from "~~/app/components/workspace/calendar/lab/CrmCalendarZoomLab.vue";
import CrmCalendarZoomTldrawLab from "~~/app/components/workspace/calendar/lab/CrmCalendarZoomTldrawLab.client.vue";
</script>
<template>
<main class="h-dvh w-full bg-base-200/40 p-2">
<CrmCalendarZoomLab />
<CrmCalendarZoomTldrawLab />
</main>
</template>