mirror of
https://github.com/FranP-code/Reflecto.git
synced 2025-10-13 00:43:31 +00:00
feat: add magic arrow tool to generate AI-powered relations between text shapes
This commit is contained in:
101
apps/web/src/lib/tldraw/relations.ts
Normal file
101
apps/web/src/lib/tldraw/relations.ts
Normal file
@@ -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<string> {
|
||||
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;
|
||||
}
|
||||
@@ -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() {
|
||||
<TooltipContent>Upload Image (OCR)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
<Button type="button" onClick={handleMagicArrow}>
|
||||
<Link2 />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Magic Arrow (select two text shapes)</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
<Dialog open={promptOpen} onOpenChange={setPromptOpen}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger>
|
||||
@@ -263,6 +304,7 @@ function SpaceRoute() {
|
||||
|
||||
return (
|
||||
<div className="mx-4 mt-4" style={{ position: "relative", inset: 0 }}>
|
||||
<Toaster />
|
||||
<Tldraw
|
||||
onMount={(editor) => {
|
||||
editor.user.updateUserPreferences({ colorScheme: "dark" });
|
||||
|
||||
Reference in New Issue
Block a user