mirror of
https://github.com/FranP-code/Reflecto.git
synced 2025-10-13 00:43:31 +00:00
feat: improve text card height calculation with auto-resize and font loading support
This commit is contained in:
@@ -7,7 +7,7 @@ import {
|
|||||||
ShapeUtil,
|
ShapeUtil,
|
||||||
T,
|
T,
|
||||||
} from "tldraw";
|
} from "tldraw";
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||||
import { stopEventPropagation } from "tldraw";
|
import { stopEventPropagation } from "tldraw";
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
@@ -355,12 +355,12 @@ export class AITextResultShapeUtil extends ShapeUtil<AITextResultShape> {
|
|||||||
const contentRef = useRef<HTMLDivElement | null>(null);
|
const contentRef = useRef<HTMLDivElement | null>(null);
|
||||||
const rootRef = useRef<HTMLDivElement | null>(null);
|
const rootRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (isEditing && textareaRef.current) textareaRef.current.focus();
|
if (isEditing && textareaRef.current) textareaRef.current.focus();
|
||||||
}, [isEditing]);
|
}, [isEditing]);
|
||||||
|
|
||||||
// Ensure textarea grows with content while editing
|
// Ensure textarea grows with content while editing
|
||||||
useEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!isEditing) return;
|
if (!isEditing) return;
|
||||||
const ta = textareaRef.current;
|
const ta = textareaRef.current;
|
||||||
if (!ta) return;
|
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
|
// Auto-size to content: measure and adjust height once per content/editing change
|
||||||
const autosizeAppliedRef = useRef<{ content: string; editing: boolean } | null>(null);
|
const autosizeAppliedRef = useRef<{ content: string; editing: boolean } | null>(null);
|
||||||
const autosizePassRef = useRef(0);
|
const autosizePassRef = useRef(0);
|
||||||
useEffect(() => {
|
const roRef = useRef<ResizeObserver | null>(null);
|
||||||
|
useLayoutEffect(() => {
|
||||||
const prev = autosizeAppliedRef.current;
|
const prev = autosizeAppliedRef.current;
|
||||||
if (prev && prev.content === shape.props.content && prev.editing === isEditing) return;
|
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 };
|
autosizeAppliedRef.current = { content: shape.props.content, editing: isEditing };
|
||||||
autosizePassRef.current = 0;
|
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 () => {
|
return () => {
|
||||||
cancelAnimationFrame(raf1);
|
cancelAnimationFrame(raf1);
|
||||||
};
|
};
|
||||||
}, [isEditing, shape.id, shape.props.content]);
|
}, [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 =
|
const header =
|
||||||
shape.props.sourceType === "image"
|
shape.props.sourceType === "image"
|
||||||
? "📄 OCR Result"
|
? "📄 OCR Result"
|
||||||
@@ -458,6 +517,7 @@ export class AITextResultShapeUtil extends ShapeUtil<AITextResultShape> {
|
|||||||
<div
|
<div
|
||||||
ref={rootRef}
|
ref={rootRef}
|
||||||
style={{
|
style={{
|
||||||
|
boxSizing: "border-box",
|
||||||
width: "100%",
|
width: "100%",
|
||||||
height: "100%",
|
height: "100%",
|
||||||
border: "2px solid #28a745",
|
border: "2px solid #28a745",
|
||||||
@@ -502,6 +562,7 @@ export class AITextResultShapeUtil extends ShapeUtil<AITextResultShape> {
|
|||||||
}}
|
}}
|
||||||
aria-label="Edit extracted text"
|
aria-label="Edit extracted text"
|
||||||
style={{
|
style={{
|
||||||
|
boxSizing: "border-box",
|
||||||
flex: "0 0 auto",
|
flex: "0 0 auto",
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
height: "auto",
|
height: "auto",
|
||||||
@@ -513,7 +574,9 @@ export class AITextResultShapeUtil extends ShapeUtil<AITextResultShape> {
|
|||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
color: "#212529",
|
color: "#212529",
|
||||||
outline: "none",
|
outline: "none",
|
||||||
overflow: "hidden"
|
overflow: "hidden",
|
||||||
|
overflowWrap: "anywhere",
|
||||||
|
wordBreak: "break-word",
|
||||||
}}
|
}}
|
||||||
placeholder="Edit extracted content..."
|
placeholder="Edit extracted content..."
|
||||||
/>
|
/>
|
||||||
@@ -521,13 +584,16 @@ export class AITextResultShapeUtil extends ShapeUtil<AITextResultShape> {
|
|||||||
<div
|
<div
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
style={{
|
style={{
|
||||||
|
boxSizing: "border-box",
|
||||||
flex: "0 0 auto",
|
flex: "0 0 auto",
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
lineHeight: 1.4,
|
lineHeight: 1.4,
|
||||||
color: "#495057",
|
color: "#495057",
|
||||||
whiteSpace: "pre-wrap",
|
whiteSpace: "pre-wrap",
|
||||||
cursor: "text",
|
cursor: "text",
|
||||||
overflow: "hidden"
|
overflow: "hidden",
|
||||||
|
overflowWrap: "anywhere",
|
||||||
|
wordBreak: "break-word",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{shape.props.content || "No content extracted"}
|
{shape.props.content || "No content extracted"}
|
||||||
@@ -556,8 +622,48 @@ export function createAITextResult(
|
|||||||
const id = createShapeId();
|
const id = createShapeId();
|
||||||
const baseW = 280;
|
const baseW = 280;
|
||||||
const baseH = 100;
|
const baseH = 100;
|
||||||
const dynW = baseW;
|
// Width estimate from longest line length (rough char width ~7px at 12px font)
|
||||||
const dynH = baseH;
|
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({
|
editor.createShape({
|
||||||
id,
|
id,
|
||||||
type: "ai-text-result",
|
type: "ai-text-result",
|
||||||
|
|||||||
@@ -2,6 +2,49 @@ import { generateText } from "./processing";
|
|||||||
import { KnowledgeGraphManager } from "./knowledge-graph";
|
import { KnowledgeGraphManager } from "./knowledge-graph";
|
||||||
import { createAITextResult } from "./ai-shapes";
|
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 {
|
export function extractTextContentFromShape(editor: any, shape: any): string {
|
||||||
if (!shape) return "";
|
if (!shape) return "";
|
||||||
if (shape.type === "ai-text-result") return shape.props?.content ?? "";
|
if (shape.type === "ai-text-result") return shape.props?.content ?? "";
|
||||||
@@ -77,6 +120,9 @@ export async function insertRelationBetweenShapes(
|
|||||||
y: pos.y,
|
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)
|
// Nudge relation to avoid overlap with A or B (up to 2 passes)
|
||||||
for (let i = 0; i < 2; i++) {
|
for (let i = 0; i < 2; i++) {
|
||||||
const relBounds = editor.getShapePageBounds(relationId);
|
const relBounds = editor.getShapePageBounds(relationId);
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ import { KnowledgeGraphManager } from "@/lib/tldraw/knowledge-graph";
|
|||||||
import { Camera, Sparkles, Link2 } from "lucide-react";
|
import { Camera, Sparkles, Link2 } from "lucide-react";
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
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 { toast } from "sonner";
|
||||||
import { Toaster } from "@/components/ui/sonner";
|
import { Toaster } from "@/components/ui/sonner";
|
||||||
|
|
||||||
@@ -209,6 +209,7 @@ function SpaceRoute() {
|
|||||||
x: cx,
|
x: cx,
|
||||||
y: cy,
|
y: cy,
|
||||||
});
|
});
|
||||||
|
normalizeShapeHeight(editor, textShapeId);
|
||||||
// Analyze connections
|
// Analyze connections
|
||||||
const kg = new KnowledgeGraphManager(editor);
|
const kg = new KnowledgeGraphManager(editor);
|
||||||
await kg.analyzeConnections(textShapeId);
|
await kg.analyzeConnections(textShapeId);
|
||||||
@@ -361,4 +362,3 @@ function SpaceRoute() {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// Removed absolute-positioned overlay; actions are now inside the bottom toolbar via components.Toolbar
|
|
||||||
|
|||||||
Reference in New Issue
Block a user