From 1db8e58da1aedb38f4b5e07070e388d748b0c317 Mon Sep 17 00:00:00 2001 From: Ruslan Bakiev Date: Tue, 24 Feb 2026 00:52:58 +0700 Subject: [PATCH] =?UTF-8?q?fix(calendar-lab):=20rewrite=20tldraw=20zoom=20?= =?UTF-8?q?as=20LOD=20=E2=80=94=20render=20only=20current=20level=20shapes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../lab/CrmCalendarZoomTldrawLab.client.vue | 655 ++++++++++++------ 1 file changed, 452 insertions(+), 203 deletions(-) diff --git a/frontend/app/components/workspace/calendar/lab/CrmCalendarZoomTldrawLab.client.vue b/frontend/app/components/workspace/calendar/lab/CrmCalendarZoomTldrawLab.client.vue index 70a2340..c11b82c 100644 --- a/frontend/app/components/workspace/calendar/lab/CrmCalendarZoomTldrawLab.client.vue +++ b/frontend/app/components/workspace/calendar/lab/CrmCalendarZoomTldrawLab.client.vue @@ -5,27 +5,25 @@ 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; -}; +/* ------------------------------------------------------------------ */ +/* Constants */ +/* ------------------------------------------------------------------ */ -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 LEVEL_LABELS = ["year", "month", "week", "day"] as const; const MONTH_LABELS = [ "January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December", ]; -const ZOOM_MS = 1100; +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(null); const status = ref("Loading tldraw engine..."); @@ -33,177 +31,353 @@ 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}`); +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(); - const children = new Map(); +/* ------------------------------------------------------------------ */ +/* Shape builders — one function per zoom level */ +/* ------------------------------------------------------------------ */ - 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); - }; +type ShapeDef = { + id: string; + type: "geo"; + x: number; + y: number; + isLocked: boolean; + props: Record; + meta: Record; +}; - 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 }, +/** 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 monthCols = 4; - const monthRows = 3; - const monthGap = 34; - const monthW = 730; - const monthH = 650; - const monthStartX = 70; - const monthStartY = 90; + const cols = 4; + const gap = 34; + const cardW = 730; + const cardH = 650; + const startX = 70; + const startY = 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); + 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); - 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 }, + 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 }, }); + } - 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; + return { shapes, containerId, childIds }; +} - for (let week = 0; week < weekRows; week += 1) { - const wy = weekY + week * (weekH + weekGap); - const weekId = api.createShapeId(`month-${month + 1}-week-${week + 1}`); +/** 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[] = []; - attachNode({ - id: weekId, - level: 2, - parentId: monthId, - label: `Week ${week + 1}`, - bounds: { x: weekX, y: wy, w: weekW, h: weekH }, + 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 | 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, }); - 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 }, - }); + if (!immediate) { + setTimeout(() => { + if (localToken === token) animating = false; + }, duration + 50); } } } - return { yearId, nodes, children }; -} + /** Resolve which child the mouse is hovering over */ + function resolveHoveredChild(): string | null { + if (childIds.length === 0) return null; -function createShapesFromScene(api: TldrawApi, nodes: Map) { - const list: Array> = []; + const pointer = editor.inputs.currentPagePoint; + if (!pointer) return childIds[0] ?? null; - 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, @@ -212,73 +386,148 @@ function setupZoomFlow(editor: any, api: TldrawApi, host: HTMLDivElement) { 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 (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; } - if (cursor && nextIds.includes(cursor)) return cursor; - return firstChild(shapeId); - }; + renderLevel(currentLevel + 1, false); + } - const zoomIn = () => { - const node = nodes.get(activeId); - if (!node || node.level >= 3) return; - const target = resolveChildFromHover(activeId); - if (!target) return; - moveTo(target, false); - }; + function zoomOut() { + if (currentLevel <= 0) return; + renderLevel(currentLevel - 1, false); + } - const zoomOut = () => { - const parent = nodes.get(activeId)?.parentId; - if (!parent) return; - moveTo(parent, false); - }; - - const onWheel = (event: WheelEvent) => { + function onWheel(event: WheelEvent) { event.preventDefault(); if (animating) return; - if (event.deltaY < 0) { - zoomIn(); + + 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; } - zoomOut(); - }; - const onDoubleClick = () => { + // Enough ticks — execute zoom + resetPrime(); + + if (direction === "in") { + zoomIn(); + } else { + zoomOut(); + } + } + + function onDoubleClick() { if (animating) return; - moveTo(yearId, false); - }; + 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); - moveTo(yearId, true); - return () => { + resetPrime(); host.removeEventListener("wheel", onWheel); host.removeEventListener("dblclick", onDoubleClick); }; } +/* ------------------------------------------------------------------ */ +/* Lifecycle */ +/* ------------------------------------------------------------------ */ + onMounted(async () => { try { if (!hostRef.value) return; - const api = { - Tldraw, - createShapeId, - toRichText, - } satisfies TldrawApi; - reactRoot = createRoot(hostRef.value); reactRoot.render( - createElement(api.Tldraw, { + createElement(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); + status.value = "Wheel up = zoom in · wheel down = zoom out · double click = reset"; + teardown = setupZoomFlow(editor, hostRef.value as HTMLDivElement); }, }), );