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>
596 lines
16 KiB
Vue
596 lines
16 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";
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Constants */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
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 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...");
|
|
|
|
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;
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Shape builders — one function per zoom level */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
type ShapeDef = {
|
|
id: string;
|
|
type: "geo";
|
|
x: number;
|
|
y: number;
|
|
isLocked: boolean;
|
|
props: Record<string, unknown>;
|
|
meta: Record<string, unknown>;
|
|
};
|
|
|
|
/** 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[] = [];
|
|
|
|
// 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 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 { shapes, containerId, childIds };
|
|
}
|
|
|
|
/** 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 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,
|
|
});
|
|
|
|
if (!immediate) {
|
|
setTimeout(() => {
|
|
if (localToken === token) animating = false;
|
|
}, duration + 50);
|
|
}
|
|
}
|
|
}
|
|
|
|
/** Resolve which child the mouse is hovering over */
|
|
function resolveHoveredChild(): string | null {
|
|
if (childIds.length === 0) return null;
|
|
|
|
const pointer = editor.inputs.currentPagePoint;
|
|
if (!pointer) return childIds[0] ?? null;
|
|
|
|
const hit = editor.getShapeAtPoint(pointer, {
|
|
margin: 0,
|
|
hitInside: true,
|
|
hitLocked: true,
|
|
hitLabels: true,
|
|
hitFrameInside: true,
|
|
});
|
|
|
|
if (hit && childIds.includes(hit.id)) return hit.id;
|
|
|
|
// If hit is the container itself, try first child
|
|
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;
|
|
}
|
|
|
|
renderLevel(currentLevel + 1, false);
|
|
}
|
|
|
|
function zoomOut() {
|
|
if (currentLevel <= 0) return;
|
|
renderLevel(currentLevel - 1, false);
|
|
}
|
|
|
|
function onWheel(event: WheelEvent) {
|
|
event.preventDefault();
|
|
if (animating) return;
|
|
|
|
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();
|
|
}
|
|
}
|
|
|
|
function onDoubleClick() {
|
|
if (animating) return;
|
|
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);
|
|
|
|
return () => {
|
|
resetPrime();
|
|
host.removeEventListener("wheel", onWheel);
|
|
host.removeEventListener("dblclick", onDoubleClick);
|
|
};
|
|
}
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* Lifecycle */
|
|
/* ------------------------------------------------------------------ */
|
|
|
|
onMounted(async () => {
|
|
try {
|
|
if (!hostRef.value) return;
|
|
|
|
reactRoot = createRoot(hostRef.value);
|
|
reactRoot.render(
|
|
createElement(Tldraw, {
|
|
hideUi: true,
|
|
onMount: (editor: any) => {
|
|
status.value = "Wheel up = zoom in · wheel down = zoom out · double click = reset";
|
|
teardown = setupZoomFlow(editor, 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>
|