feat: improve text card height calculation with auto-resize and font loading support

This commit is contained in:
2025-09-10 19:35:36 -03:00
parent c2c88b6df0
commit 86f511cd8e
3 changed files with 162 additions and 10 deletions

View File

@@ -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<AITextResultShape> {
const contentRef = useRef<HTMLDivElement | null>(null);
const rootRef = useRef<HTMLDivElement | null>(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<AITextResultShape> {
// 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<ResizeObserver | null>(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<AITextResultShape> {
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<AITextResultShape> {
<div
ref={rootRef}
style={{
boxSizing: "border-box",
width: "100%",
height: "100%",
border: "2px solid #28a745",
@@ -502,6 +562,7 @@ export class AITextResultShapeUtil extends ShapeUtil<AITextResultShape> {
}}
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<AITextResultShape> {
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<AITextResultShape> {
<div
ref={contentRef}
style={{
boxSizing: "border-box",
flex: "0 0 auto",
minHeight: 0,
lineHeight: 1.4,
color: "#495057",
whiteSpace: "pre-wrap",
cursor: "text",
overflow: "hidden"
overflow: "hidden",
overflowWrap: "anywhere",
wordBreak: "break-word",
}}
>
{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",

View File

@@ -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);

View File

@@ -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() {
</div>
);
}
// Removed absolute-positioned overlay; actions are now inside the bottom toolbar via components.Toolbar