Files
clientsflow/frontend/app/components/workspace/calendar/lab/CrmCalendarZoomTldrawLab.client.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>