mirror of
https://github.com/FranP-code/Reflecto.git
synced 2025-10-13 00:43:31 +00:00
feat: auto-normalize AI text result heights when shapes enter viewport
This commit is contained in:
@@ -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<string>();
|
||||
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}"`,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user