feat(calendar-lab): use tldraw canvas engine with nested zoom rectangles
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user