347 lines
8.7 KiB
Vue
347 lines
8.7 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
|
import { createElement } from "react";
|
|
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;
|
|
};
|
|
|
|
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 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;
|
|
|
|
const api = {
|
|
Tldraw,
|
|
createShapeId,
|
|
toRichText,
|
|
} satisfies 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 initialize local tldraw engine";
|
|
console.error(error);
|
|
}
|
|
});
|
|
|
|
onBeforeUnmount(() => {
|
|
teardown?.();
|
|
teardown = null;
|
|
reactRoot?.unmount();
|
|
reactRoot = null;
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<section class="tldraw-lab-root">
|
|
<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>
|
|
|
|
<style scoped>
|
|
.tldraw-lab-root {
|
|
height: calc(100dvh - 1rem);
|
|
min-height: 640px;
|
|
border-radius: 14px;
|
|
border: 1px solid color-mix(in oklab, var(--color-base-content) 20%, transparent);
|
|
overflow: hidden;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: color-mix(in oklab, var(--color-base-100) 95%, transparent);
|
|
}
|
|
|
|
.tldraw-lab-toolbar {
|
|
height: 42px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 10px;
|
|
padding: 0 10px;
|
|
border-bottom: 1px solid color-mix(in oklab, var(--color-base-content) 16%, transparent);
|
|
background: color-mix(in oklab, var(--color-base-100) 94%, transparent);
|
|
}
|
|
|
|
.tldraw-lab-title {
|
|
font-size: 0.78rem;
|
|
color: color-mix(in oklab, var(--color-base-content) 72%, transparent);
|
|
}
|
|
|
|
.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);
|
|
}
|
|
</style>
|