diff --git a/apps/web/src/lib/tldraw/relations.ts b/apps/web/src/lib/tldraw/relations.ts index 14a26a3..6fb1599 100644 --- a/apps/web/src/lib/tldraw/relations.ts +++ b/apps/web/src/lib/tldraw/relations.ts @@ -45,6 +45,96 @@ export function normalizeShapeHeight(editor: any, shapeId: string) { } } +/** + * Sets up observers to automatically re-run height normalization for + * `ai-text-result` shapes whenever their DOM nodes appear and intersect + * the viewport. This fixes the issue where offscreen virtualized shapes + * come back into view with stale heights. + * + * Returns a teardown function to disconnect observers on unmount. + */ +export function setupHeightAutoNormalize(editor: any): () => void { + try { + // Track current ai-text-result ids to avoid observing unrelated nodes + const isTextResultId = (id: string) => { + const s = editor.getShape(id); + return !!s && s.type === "ai-text-result"; + }; + + const observed = new Set(); + const pendingScan = { v: false }; + + const io = new IntersectionObserver((entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) continue; + const target = entry.target as HTMLElement; + const id = target.id; + if (!id || !isTextResultId(id)) continue; + // Defer a tick so layout fully stabilizes + requestAnimationFrame(() => normalizeShapeHeight(editor, id)); + } + }, { root: null, threshold: 0.01 }); + + const tryObserve = (id: string) => { + if (!id || observed.has(id)) return; + if (!isTextResultId(id)) return; + const el = document.getElementById(id); + if (!el) return; + try { + io.observe(el); + observed.add(id); + } catch { /* ignore */ } + }; + + const scanAll = () => { + pendingScan.v = false; + try { + const ids: string[] = editor.getCurrentPageShapeIds?.() ?? []; + for (const id of ids) tryObserve(id); + } catch { /* ignore */ } + }; + + // Mutation observer to catch when TLDraw mounts/unmounts DOM nodes + const mo = new MutationObserver((mutations) => { + for (const m of mutations) { + if (m.type === "childList") { + // Schedule a batched scan soon after DOM changes + if (!pendingScan.v) { + pendingScan.v = true; + setTimeout(scanAll, 0); + } + } + } + }); + + try { + mo.observe(document.body, { childList: true, subtree: true }); + } catch { /* ignore */ } + + // Initial scan and a delayed rescan for late fonts/styles + scanAll(); + setTimeout(scanAll, 300); + + // Also rescan when the document changes (e.g., shapes added/removed) + const unlisten = editor.store?.listen?.(() => { + if (!pendingScan.v) { + pendingScan.v = true; + setTimeout(scanAll, 0); + } + }, { scope: "document" }); + + return () => { + try { io.disconnect(); } catch { /* ignore */ } + try { mo.disconnect(); } catch { /* ignore */ } + try { unlisten?.(); } catch { /* ignore */ } + observed.clear(); + }; + } catch { + // Fallback no-op teardown + return () => {}; + } +} + export function extractTextContentFromShape(editor: any, shape: any): string { if (!shape) return ""; if (shape.type === "ai-text-result") return shape.props?.content ?? ""; @@ -58,6 +148,8 @@ export async function generateRelationText(textA: string, textB: string): Promis "Given two short texts, write a brief plain-text relation between them.", "One line only. No markdown, no lists, no headings.", "Return only the relation phrase/sentence.", + "Without making a mention of Text A and Text B.", + "Just a relation phrase/sentence between the two texts and an explanation.", "", `Text A: "${textA}"`, `Text B: "${textB}"`, diff --git a/apps/web/src/routes/space.tsx b/apps/web/src/routes/space.tsx index 9712c3f..c9ba688 100644 --- a/apps/web/src/routes/space.tsx +++ b/apps/web/src/routes/space.tsx @@ -29,7 +29,7 @@ import { KnowledgeGraphManager } from "@/lib/tldraw/knowledge-graph"; import { Camera, Sparkles, Link2 } from "lucide-react"; import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; -import { extractTextContentFromShape, generateRelationText, insertRelationBetweenShapes, normalizeShapeHeight } from "@/lib/tldraw/relations"; +import { extractTextContentFromShape, generateRelationText, insertRelationBetweenShapes, normalizeShapeHeight, setupHeightAutoNormalize } from "@/lib/tldraw/relations"; import { toast } from "sonner"; import { Toaster } from "@/components/ui/sonner"; @@ -346,12 +346,16 @@ function SpaceRoute() { // Setup drag & drop for images/audio -> AI shapes const teardownDrop = setupFileDropHandler(editor); + // Auto-normalize AI text result heights when they come into view + const teardownAutoNormalize = setupHeightAutoNormalize(editor); + // Cleanup listener on unmount return () => { unlisten(); window.clearTimeout(timeout); // Remove DnD handlers if (typeof teardownDrop === "function") teardownDrop(); + if (typeof teardownAutoNormalize === "function") teardownAutoNormalize(); (window as any).editor = undefined; }; }}