From 86f511cd8e6232b6d6a1ff3d59f459aa822e874c Mon Sep 17 00:00:00 2001 From: Francisco Pessano Date: Wed, 10 Sep 2025 19:35:36 -0300 Subject: [PATCH] feat: improve text card height calculation with auto-resize and font loading support --- apps/web/src/lib/tldraw/ai-shapes.tsx | 122 ++++++++++++++++++++++++-- apps/web/src/lib/tldraw/relations.ts | 46 ++++++++++ apps/web/src/routes/space.tsx | 4 +- 3 files changed, 162 insertions(+), 10 deletions(-) diff --git a/apps/web/src/lib/tldraw/ai-shapes.tsx b/apps/web/src/lib/tldraw/ai-shapes.tsx index d9c32ee..0f9e77a 100644 --- a/apps/web/src/lib/tldraw/ai-shapes.tsx +++ b/apps/web/src/lib/tldraw/ai-shapes.tsx @@ -7,7 +7,7 @@ import { ShapeUtil, T, } from "tldraw"; -import { useEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState } from "react"; import { stopEventPropagation } from "tldraw"; // Types @@ -355,12 +355,12 @@ export class AITextResultShapeUtil extends ShapeUtil { const contentRef = useRef(null); const rootRef = useRef(null); - useEffect(() => { + useLayoutEffect(() => { if (isEditing && textareaRef.current) textareaRef.current.focus(); }, [isEditing]); // Ensure textarea grows with content while editing - useEffect(() => { + useLayoutEffect(() => { if (!isEditing) return; const ta = textareaRef.current; if (!ta) return; @@ -372,7 +372,8 @@ export class AITextResultShapeUtil extends ShapeUtil { // Auto-size to content: measure and adjust height once per content/editing change const autosizeAppliedRef = useRef<{ content: string; editing: boolean } | null>(null); const autosizePassRef = useRef(0); - useEffect(() => { + const roRef = useRef(null); + useLayoutEffect(() => { const prev = autosizeAppliedRef.current; if (prev && prev.content === shape.props.content && prev.editing === isEditing) return; @@ -436,12 +437,70 @@ export class AITextResultShapeUtil extends ShapeUtil { autosizeAppliedRef.current = { content: shape.props.content, editing: isEditing }; autosizePassRef.current = 0; } + + // Safety: for very fresh shapes, fonts/layout may settle a bit later. + // If the shape was created recently, schedule a delayed re-measure. + const now = Date.now(); + const created = (shape.props as any)?.createdDate ?? now; + if (now - created < 1500) { + setTimeout(() => { + measureAndUpdate(); + }, 120); + setTimeout(() => { + measureAndUpdate(); + }, 300); + } }); return () => { cancelAnimationFrame(raf1); }; }, [isEditing, shape.id, shape.props.content]); + // Observe late layout changes and re-measure once if needed (e.g., fonts settling) + useLayoutEffect(() => { + const rootEl = rootRef.current; + if (!rootEl) return; + // Clean up any previous observer + roRef.current?.disconnect(); + let fired = 0; + const ro = new ResizeObserver(() => { + if (fired > 1) return; // at most two extra passes + fired += 1; + requestAnimationFrame(() => { + // trigger a measure without changing the content/editing token + const minH = 100; + if (isEditing && textareaRef.current && headerRef.current) { + const ta = textareaRef.current as HTMLTextAreaElement; + const headerH = Math.ceil(headerRef.current.getBoundingClientRect().height); + const prevTaHeight = ta.style.height; + ta.style.height = 'auto'; + const taH = Math.ceil(ta.scrollHeight); + ta.style.height = prevTaHeight || `${taH}px`; + const padding = 24, marginBetween = 8, headerDivider = 1, borderY = 4; + const desiredH = Math.max(minH, headerH + marginBetween + taH + padding + headerDivider + borderY); + const dh = Math.abs(desiredH - shape.props.h); + if (dh > 2) this.editor.updateShape({ id: shape.id, type: 'ai-text-result', props: { h: desiredH } }); + } else { + const prevWidth = rootEl.style.width; + const prevHeight = rootEl.style.height; + rootEl.style.width = `${Math.max(120, Math.floor(shape.props.w))}px`; + rootEl.style.height = 'auto'; + const cardH = Math.ceil(rootEl.scrollHeight); + rootEl.style.width = prevWidth; + rootEl.style.height = prevHeight; + const finalH = Math.max(minH, cardH); + const dh = Math.abs(finalH - shape.props.h); + if (dh > 2) this.editor.updateShape({ id: shape.id, type: 'ai-text-result', props: { h: finalH } }); + } + }); + }); + ro.observe(rootEl); + roRef.current = ro; + return () => { + ro.disconnect(); + }; + }, [isEditing, shape.id, shape.props.content]); + const header = shape.props.sourceType === "image" ? "📄 OCR Result" @@ -458,6 +517,7 @@ export class AITextResultShapeUtil extends ShapeUtil {
{ }} aria-label="Edit extracted text" style={{ + boxSizing: "border-box", flex: "0 0 auto", minHeight: 0, height: "auto", @@ -513,7 +574,9 @@ export class AITextResultShapeUtil extends ShapeUtil { lineHeight: 1.4, color: "#212529", outline: "none", - overflow: "hidden" + overflow: "hidden", + overflowWrap: "anywhere", + wordBreak: "break-word", }} placeholder="Edit extracted content..." /> @@ -521,13 +584,16 @@ export class AITextResultShapeUtil extends ShapeUtil {
{shape.props.content || "No content extracted"} @@ -556,8 +622,48 @@ export function createAITextResult( const id = createShapeId(); const baseW = 280; const baseH = 100; - const dynW = baseW; - const dynH = baseH; + // Width estimate from longest line length (rough char width ~7px at 12px font) + const lines = (opts.content || "").split(/\r?\n/); + const longest = lines.reduce((m, l) => Math.max(m, l.length), 0); + const approxInner = Math.max(baseW - 24 - 4, Math.min(520, Math.floor(longest * 7))); + const dynW = Math.max(240, Math.min(640, approxInner + 24 + 4)); + // Rough estimate: assume ~42 chars per line at this width & font, 18px line-height + const charsPerLine = 42; + const estLines = Math.max(2, Math.ceil((opts.content?.length ?? 0) / charsPerLine)); + const headerAndChrome = 60; // header + divider + margins + const padding = 24; // vertical padding + const estH = headerAndChrome + estLines * 18 + padding; + // Precise pre-measure using offscreen DOM (when available) + let dynH = Math.max(baseH, Math.min(480, estH)); + try { + const innerW = Math.max(120, Math.floor(dynW - 24 - 4)); + const clone = document.createElement("div"); + clone.style.position = "absolute"; + clone.style.left = "-10000px"; + clone.style.top = "-10000px"; + clone.style.visibility = "hidden"; + clone.style.pointerEvents = "none"; + clone.style.whiteSpace = "pre-wrap"; + clone.style.lineHeight = "1.4"; + clone.style.fontSize = "12px"; // matches card body + clone.style.fontFamily = "inherit"; + clone.style.width = `${innerW}px`; + clone.textContent = opts.content || ""; + document.body.appendChild(clone); + const contentH = Math.ceil(clone.getBoundingClientRect().height); + clone.remove(); + if (contentH) { + const borderY = 4; // 2px + 2px + const marginBetween = 8; // header bottom margin + const headerApprox = 28; // header line height approx + const divider = 1; + const chrome = headerApprox + divider + marginBetween; + const precise = chrome + padding + contentH + borderY; + dynH = Math.max(baseH, Math.min(640, precise)); + } + } catch { + // ignore and use heuristic + } editor.createShape({ id, type: "ai-text-result", diff --git a/apps/web/src/lib/tldraw/relations.ts b/apps/web/src/lib/tldraw/relations.ts index 742ef2c..14a26a3 100644 --- a/apps/web/src/lib/tldraw/relations.ts +++ b/apps/web/src/lib/tldraw/relations.ts @@ -2,6 +2,49 @@ import { generateText } from "./processing"; import { KnowledgeGraphManager } from "./knowledge-graph"; import { createAITextResult } from "./ai-shapes"; +export function normalizeShapeHeight(editor: any, shapeId: string) { + try { + const measure = () => { + const el = document.getElementById(shapeId); + if (!el) return false; + const card = el.firstElementChild as HTMLElement | null; + if (!card) return false; + const width = Math.ceil(card.clientWidth); + if (!width) return false; // wait until it has layout width + const prevH = card.style.height; + card.style.height = "auto"; + const measured = Math.ceil(card.scrollHeight); + card.style.height = prevH; + if (measured && Number.isFinite(measured)) { + const shape = editor.getShape(shapeId); + const minH = 100; + const finalH = Math.max(minH, measured); + if (Math.abs((shape?.props?.h ?? 0) - finalH) > 2) { + editor.updateShape({ id: shapeId, type: "ai-text-result", props: { h: finalH } }); + } + return true; + } + return false; + }; + + // Try up to ~8 frames for a stable width & layout + let tries = 0; + const tick = () => { + if (measure()) return; + if (tries++ > 8) return; + requestAnimationFrame(() => setTimeout(tick, 0)); + }; + setTimeout(() => requestAnimationFrame(tick), 0); + + // Extra late fallback in case fonts/style settle late + setTimeout(() => { + measure(); + }, 50); + } catch { + // ignore measurement errors + } +} + export function extractTextContentFromShape(editor: any, shape: any): string { if (!shape) return ""; if (shape.type === "ai-text-result") return shape.props?.content ?? ""; @@ -77,6 +120,9 @@ export async function insertRelationBetweenShapes( y: pos.y, }); + // Force a first-paint normalize for the relation card height + normalizeShapeHeight(editor, relationId); + // Nudge relation to avoid overlap with A or B (up to 2 passes) for (let i = 0; i < 2; i++) { const relBounds = editor.getShapePageBounds(relationId); diff --git a/apps/web/src/routes/space.tsx b/apps/web/src/routes/space.tsx index 79f01da..9712c3f 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 } from "@/lib/tldraw/relations"; +import { extractTextContentFromShape, generateRelationText, insertRelationBetweenShapes, normalizeShapeHeight } from "@/lib/tldraw/relations"; import { toast } from "sonner"; import { Toaster } from "@/components/ui/sonner"; @@ -209,6 +209,7 @@ function SpaceRoute() { x: cx, y: cy, }); + normalizeShapeHeight(editor, textShapeId); // Analyze connections const kg = new KnowledgeGraphManager(editor); await kg.analyzeConnections(textShapeId); @@ -361,4 +362,3 @@ function SpaceRoute() {
); } -// Removed absolute-positioned overlay; actions are now inside the bottom toolbar via components.Toolbar