From c2c88b6df008f29829769d738501b57aec9cf000 Mon Sep 17 00:00:00 2001 From: Francisco Pessano Date: Wed, 10 Sep 2025 19:19:04 -0300 Subject: [PATCH] feat: add magic arrow tool to generate AI-powered relations between text shapes --- apps/web/src/lib/tldraw/relations.ts | 101 +++++++++++++++++++++++++++ apps/web/src/routes/space.tsx | 44 +++++++++++- 2 files changed, 144 insertions(+), 1 deletion(-) create mode 100644 apps/web/src/lib/tldraw/relations.ts diff --git a/apps/web/src/lib/tldraw/relations.ts b/apps/web/src/lib/tldraw/relations.ts new file mode 100644 index 0000000..742ef2c --- /dev/null +++ b/apps/web/src/lib/tldraw/relations.ts @@ -0,0 +1,101 @@ +import { generateText } from "./processing"; +import { KnowledgeGraphManager } from "./knowledge-graph"; +import { createAITextResult } from "./ai-shapes"; + +export function extractTextContentFromShape(editor: any, shape: any): string { + if (!shape) return ""; + if (shape.type === "ai-text-result") return shape.props?.content ?? ""; + if (shape.type === "ai-image") return shape.props?.extractedText ?? ""; + if (shape.type === "text" || shape.type === "note") return shape.props?.text ?? ""; + return ""; +} + +export async function generateRelationText(textA: string, textB: string): Promise { + const userPrompt = [ + "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.", + "", + `Text A: "${textA}"`, + `Text B: "${textB}"`, + ].join("\n"); + const text = await generateText(userPrompt, 0.4); + return (text ?? "").trim(); +} + +export function midpointBetweenShapes(editor: any, aId: string, bId: string): { x: number; y: number } { + const aBounds = editor.getShapePageBounds(aId); + const bBounds = editor.getShapePageBounds(bId); + const aShape = editor.getShape(aId); + const bShape = editor.getShape(bId); + const acx = aBounds ? aBounds.x + aBounds.w / 2 : (aShape?.x ?? 0); + const acy = aBounds ? aBounds.y + aBounds.h / 2 : (aShape?.y ?? 0); + const bcx = bBounds ? bBounds.x + bBounds.w / 2 : (bShape?.x ?? 0); + const bcy = bBounds ? bBounds.y + bBounds.h / 2 : (bShape?.y ?? 0); + const midx = (acx + bcx) / 2; + const midy = (acy + bcy) / 2; + const dx = bcx - acx; + const dy = bcy - acy; + const len = Math.hypot(dx, dy) || 1; + // Perpendicular unit vector + const px = -dy / len; + const py = dx / len; + const offset = Math.min(120, Math.max(40, len * 0.12)); + return { x: midx + px * offset, y: midy + py * offset }; +} + +export async function insertRelationBetweenShapes( + editor: any, + aId: string, + bId: string, + relationText: string +) { + const aBounds = editor.getShapePageBounds(aId); + const bBounds = editor.getShapePageBounds(bId); + const aShape = editor.getShape(aId); + const bShape = editor.getShape(bId); + const acx = aBounds ? aBounds.x + aBounds.w / 2 : (aShape?.x ?? 0); + const acy = aBounds ? aBounds.y + aBounds.h / 2 : (aShape?.y ?? 0); + const bcx = bBounds ? bBounds.x + bBounds.w / 2 : (bShape?.x ?? 0); + const bcy = bBounds ? bBounds.y + bBounds.h / 2 : (bShape?.y ?? 0); + const dx = bcx - acx; + const dy = bcy - acy; + const len = Math.hypot(dx, dy) || 1; + const px = -dy / len; + const py = dx / len; + + let baseOffset = Math.min(120, Math.max(40, len * 0.12)); + let pos = { x: (acx + bcx) / 2 + px * baseOffset, y: (acy + bcy) / 2 + py * baseOffset }; + + let relationId = ""; + editor.batch?.(() => { + relationId = createAITextResult(editor, { + fromShapeId: null, + sourceType: "analysis", + content: relationText.trim(), + x: pos.x, + y: pos.y, + }); + + // 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); + if (!relBounds) break; + const overlapsA = aBounds && !(relBounds.x > aBounds.x + aBounds.w || relBounds.x + relBounds.w < aBounds.x || relBounds.y > aBounds.y + aBounds.h || relBounds.y + relBounds.h < aBounds.y); + const overlapsB = bBounds && !(relBounds.x > bBounds.x + bBounds.w || relBounds.x + relBounds.w < bBounds.x || relBounds.y > bBounds.y + bBounds.h || relBounds.y + relBounds.h < bBounds.y); + if (overlapsA || overlapsB) { + baseOffset = Math.min(200, Math.floor(baseOffset * 1.5)); + pos = { x: (acx + bcx) / 2 + px * baseOffset, y: (acy + bcy) / 2 + py * baseOffset }; + editor.updateShape({ id: relationId, type: "ai-text-result", x: pos.x, y: pos.y }); + } else { + break; + } + } + + const kg = new KnowledgeGraphManager(editor); + // Both arrows point into the relation shape: a -> relation, b -> relation + kg.createConnection(aId, relationId, "relates_to"); + kg.createConnection(bId, relationId, "relates_to"); + }); + return relationId; +} diff --git a/apps/web/src/routes/space.tsx b/apps/web/src/routes/space.tsx index f684b9b..79f01da 100644 --- a/apps/web/src/routes/space.tsx +++ b/apps/web/src/routes/space.tsx @@ -26,9 +26,12 @@ import { AIImageShapeUtil, AITextResultShapeUtil } from "@/lib/tldraw/ai-shapes" import { createImageShapeFromFile, setupFileDropHandler, generateText } from "@/lib/tldraw/processing"; import { createAITextResult } from "@/lib/tldraw/ai-shapes"; import { KnowledgeGraphManager } from "@/lib/tldraw/knowledge-graph"; -import { Camera, Sparkles } from "lucide-react"; +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 { toast } from "sonner"; +import { Toaster } from "@/components/ui/sonner"; export const Route = createFileRoute("/space")({ validateSearch: z.object({ @@ -152,6 +155,35 @@ function SpaceRoute() { await createImageShapeFromFile(editor, file, { x: x + w / 2, y: y + h / 2 }); }; + const handleMagicArrow = async () => { + try { + const ids: string[] = editor.getSelectedShapeIds?.() ?? []; + if (ids.length !== 2) { + toast.error("Select exactly two text shapes to use Magic Arrow."); + return; + } + const [aId, bId] = ids; + const a = editor.getShape(aId as any); + const b = editor.getShape(bId as any); + const textA = extractTextContentFromShape(editor, a); + const textB = extractTextContentFromShape(editor, b); + if (!(textA && textB)) { + toast.error("Both selected shapes must contain text."); + return; + } + const toastId = toast.loading("Generating relation..."); + const rel = await generateRelationText(textA, textB); + if (!rel) { + toast.error("Failed to generate relation.", { id: toastId }); + return; + } + await insertRelationBetweenShapes(editor, aId, bId, rel); + toast.success("Relation added", { id: toastId }); + } catch (e) { + toast.error("Magic Arrow failed. Try again."); + } + }; + const handleOpenPrompt = () => { setPromptText(""); setGenError(null); @@ -201,6 +233,15 @@ function SpaceRoute() { Upload Image (OCR) + + + + + Magic Arrow (select two text shapes) + + @@ -263,6 +304,7 @@ function SpaceRoute() { return (
+ { editor.user.updateUserPreferences({ colorScheme: "dark" });