feat: enhance canvas and node components with improved edge handling and new node features

- Refactored canvas toolbar to utilize new canvas placement context for node creation.
- Updated node components (compare, group, image, note, prompt, text) to include source and target handles for better edge management.
- Improved edge intersection handling during node drag operations for enhanced user experience.
- Added utility functions for edge identification and node positioning to streamline interactions.
This commit is contained in:
Matthias
2026-03-26 18:22:57 +01:00
parent a5cde14573
commit 8daa4a91fb
10 changed files with 562 additions and 82 deletions

View File

@@ -0,0 +1,204 @@
"use client";
import {
createContext,
useCallback,
useContext,
useMemo,
type ReactNode,
} from "react";
import { useMutation } from "convex/react";
import { useReactFlow, useStore, type Edge as RFEdge } from "@xyflow/react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import { NODE_DEFAULTS, NODE_HANDLE_MAP } from "@/lib/canvas-utils";
type FlowPoint = { x: number; y: number };
type CreateNodeWithIntersectionInput = {
type: string;
position: FlowPoint;
width?: number;
height?: number;
data?: Record<string, unknown>;
clientPosition?: FlowPoint;
};
type CanvasPlacementContextValue = {
createNodeWithIntersection: (
input: CreateNodeWithIntersectionInput,
) => Promise<Id<"nodes">>;
};
const CanvasPlacementContext = createContext<CanvasPlacementContextValue | null>(
null,
);
function getEdgeIdFromInteractionElement(element: Element): string | null {
const edgeContainer = element.closest(".react-flow__edge");
if (!edgeContainer) return null;
const dataId = edgeContainer.getAttribute("data-id");
if (dataId) return dataId;
const domId = edgeContainer.getAttribute("id");
if (domId?.startsWith("reactflow__edge-")) {
return domId.slice("reactflow__edge-".length);
}
return null;
}
function getIntersectedPersistedEdge(
point: FlowPoint,
edges: RFEdge[],
): RFEdge | undefined {
const elements = document.elementsFromPoint(point.x, point.y);
const interactionElement = elements.find(
(element) => element.classList.contains("react-flow__edge-interaction"),
);
if (!interactionElement) {
return undefined;
}
const edgeId = getEdgeIdFromInteractionElement(interactionElement);
if (!edgeId) return undefined;
const edge = edges.find((candidate) => candidate.id === edgeId);
if (!edge || edge.className === "temp") return undefined;
return edge;
}
function hasHandleKey(
handles: { source?: string; target?: string } | undefined,
key: "source" | "target",
): boolean {
if (!handles) return false;
return Object.prototype.hasOwnProperty.call(handles, key);
}
function normalizeHandle(handle: string | null | undefined): string | undefined {
return handle ?? undefined;
}
interface CanvasPlacementProviderProps {
canvasId: Id<"canvases">;
children: ReactNode;
}
export function CanvasPlacementProvider({
canvasId,
children,
}: CanvasPlacementProviderProps) {
const { flowToScreenPosition } = useReactFlow();
const edges = useStore((store) => store.edges);
const createNode = useMutation(api.nodes.create);
const createEdge = useMutation(api.edges.create);
const removeEdge = useMutation(api.edges.remove);
const createNodeWithIntersection = useCallback(
async ({
type,
position,
width,
height,
data,
clientPosition,
}: CreateNodeWithIntersectionInput) => {
const defaults = NODE_DEFAULTS[type] ?? {
width: 200,
height: 100,
data: {},
};
const effectiveWidth = width ?? defaults.width;
const effectiveHeight = height ?? defaults.height;
const centerClientPosition = flowToScreenPosition({
x: position.x + effectiveWidth / 2,
y: position.y + effectiveHeight / 2,
});
const hitEdgeFromClientPosition = clientPosition
? getIntersectedPersistedEdge(clientPosition, edges)
: undefined;
const hitEdge =
hitEdgeFromClientPosition ??
getIntersectedPersistedEdge(centerClientPosition, edges);
const nodeId = await createNode({
canvasId,
type,
positionX: position.x,
positionY: position.y,
width: effectiveWidth,
height: effectiveHeight,
data: {
...defaults.data,
...(data ?? {}),
canvasId,
},
});
if (!hitEdge) {
return nodeId;
}
const handles = NODE_HANDLE_MAP[type];
if (!hasHandleKey(handles, "source") || !hasHandleKey(handles, "target")) {
return nodeId;
}
try {
await createEdge({
canvasId,
sourceNodeId: hitEdge.source as Id<"nodes">,
targetNodeId: nodeId,
sourceHandle: normalizeHandle(hitEdge.sourceHandle),
targetHandle: normalizeHandle(handles.target),
});
await createEdge({
canvasId,
sourceNodeId: nodeId,
targetNodeId: hitEdge.target as Id<"nodes">,
sourceHandle: normalizeHandle(handles.source),
targetHandle: normalizeHandle(hitEdge.targetHandle),
});
await removeEdge({ edgeId: hitEdge.id as Id<"edges"> });
} catch (error) {
console.error("[Canvas placement] edge split failed", {
edgeId: hitEdge.id,
nodeId,
type,
error: String(error),
});
}
return nodeId;
},
[canvasId, createEdge, createNode, edges, flowToScreenPosition, removeEdge],
);
const value = useMemo(
() => ({ createNodeWithIntersection }),
[createNodeWithIntersection],
);
return (
<CanvasPlacementContext.Provider value={value}>
{children}
</CanvasPlacementContext.Provider>
);
}
export function useCanvasPlacement() {
const context = useContext(CanvasPlacementContext);
if (!context) {
throw new Error("useCanvasPlacement must be used within CanvasPlacementProvider");
}
return context;
}

View File

@@ -1,11 +1,9 @@
"use client";
import { useMutation } from "convex/react";
import { useRef } from "react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import { ExportButton } from "@/components/canvas/export-button";
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
const nodeTemplates = [
{
@@ -53,15 +51,13 @@ const nodeTemplates = [
] as const;
interface CanvasToolbarProps {
canvasId: Id<"canvases">;
canvasName?: string;
}
export default function CanvasToolbar({
canvasId,
canvasName,
}: CanvasToolbarProps) {
const createNode = useMutation(api.nodes.create);
const { createNodeWithIntersection } = useCanvasPlacement();
const nodeCountRef = useRef(0);
const handleAddNode = async (
@@ -72,14 +68,12 @@ export default function CanvasToolbar({
) => {
const offset = (nodeCountRef.current % 8) * 24;
nodeCountRef.current += 1;
await createNode({
canvasId,
await createNodeWithIntersection({
type,
positionX: 100 + offset,
positionY: 100 + offset,
position: { x: 100 + offset, y: 100 + offset },
width,
height,
data: { ...data, canvasId },
data,
});
};

View File

@@ -17,6 +17,7 @@ import {
type NodeChange,
type EdgeChange,
type Connection,
type DefaultEdgeOptions,
BackgroundVariant,
} from "@xyflow/react";
import "@xyflow/react/dist/style.css";
@@ -27,8 +28,14 @@ import type { Id } from "@/convex/_generated/dataModel";
import { authClient } from "@/lib/auth-client";
import { nodeTypes } from "./node-types";
import { convexNodeToRF, convexEdgeToRF, NODE_DEFAULTS } from "@/lib/canvas-utils";
import {
convexNodeToRF,
convexEdgeToRF,
NODE_DEFAULTS,
NODE_HANDLE_MAP,
} from "@/lib/canvas-utils";
import CanvasToolbar from "@/components/canvas/canvas-toolbar";
import { CanvasPlacementProvider } from "@/components/canvas/canvas-placement-context";
interface CanvasInnerProps {
canvasId: Id<"canvases">;
@@ -97,6 +104,68 @@ function getMiniMapNodeStrokeColor(node: RFNode): string {
return node.type === "frame" ? "transparent" : "#4f46e5";
}
const DEFAULT_EDGE_OPTIONS: DefaultEdgeOptions = {
interactionWidth: 75,
};
const EDGE_INTERSECTION_HIGHLIGHT_STYLE: NonNullable<RFEdge["style"]> = {
stroke: "hsl(var(--foreground))",
strokeWidth: 2,
};
function getEdgeIdFromInteractionElement(element: Element): string | null {
const edgeContainer = element.closest(".react-flow__edge");
if (!edgeContainer) return null;
const dataId = edgeContainer.getAttribute("data-id");
if (dataId) return dataId;
const domId = edgeContainer.getAttribute("id");
if (domId?.startsWith("reactflow__edge-")) {
return domId.slice("reactflow__edge-".length);
}
return null;
}
function getNodeCenterClientPosition(nodeId: string): { x: number; y: number } | null {
const nodeElement = Array.from(
document.querySelectorAll<HTMLElement>(".react-flow__node"),
).find((element) => element.dataset.id === nodeId);
if (!nodeElement) return null;
const rect = nodeElement.getBoundingClientRect();
return {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
};
}
function getIntersectedEdgeId(point: { x: number; y: number }): string | null {
const interactionElement = document
.elementsFromPoint(point.x, point.y)
.find((element) => element.classList.contains("react-flow__edge-interaction"));
if (!interactionElement) {
return null;
}
return getEdgeIdFromInteractionElement(interactionElement);
}
function hasHandleKey(
handles: { source?: string; target?: string } | undefined,
key: "source" | "target",
): boolean {
if (!handles) return false;
return Object.prototype.hasOwnProperty.call(handles, key);
}
function normalizeHandle(handle: string | null | undefined): string | undefined {
return handle ?? undefined;
}
function CanvasInner({ canvasId }: CanvasInnerProps) {
const { screenToFlowPosition } = useReactFlow();
const { resolvedTheme } = useTheme();
@@ -175,6 +244,11 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
// Delete Edge on Drop
const edgeReconnectSuccessful = useRef(true);
const overlappedEdgeRef = useRef<string | null>(null);
const highlightedEdgeRef = useRef<string | null>(null);
const highlightedEdgeOriginalStyleRef = useRef<RFEdge["style"] | undefined>(
undefined,
);
// ─── Convex → Lokaler State Sync ──────────────────────────────
useEffect(() => {
@@ -269,36 +343,195 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
[removeEdge],
);
const setHighlightedIntersectionEdge = useCallback((edgeId: string | null) => {
const previousHighlightedEdgeId = highlightedEdgeRef.current;
if (previousHighlightedEdgeId === edgeId) {
return;
}
setEdges((currentEdges) => {
let nextEdges = currentEdges;
if (previousHighlightedEdgeId) {
nextEdges = nextEdges.map((edge) =>
edge.id === previousHighlightedEdgeId
? {
...edge,
style: highlightedEdgeOriginalStyleRef.current,
}
: edge,
);
}
if (!edgeId) {
highlightedEdgeOriginalStyleRef.current = undefined;
return nextEdges;
}
const edgeToHighlight = nextEdges.find((edge) => edge.id === edgeId);
if (!edgeToHighlight || edgeToHighlight.className === "temp") {
highlightedEdgeOriginalStyleRef.current = undefined;
return nextEdges;
}
highlightedEdgeOriginalStyleRef.current = edgeToHighlight.style;
return nextEdges.map((edge) =>
edge.id === edgeId
? {
...edge,
style: {
...(edge.style ?? {}),
...EDGE_INTERSECTION_HIGHLIGHT_STYLE,
},
}
: edge,
);
});
highlightedEdgeRef.current = edgeId;
}, []);
const onNodeDrag = useCallback(
(_event: React.MouseEvent, node: RFNode) => {
const nodeCenter = getNodeCenterClientPosition(node.id);
if (!nodeCenter) {
overlappedEdgeRef.current = null;
setHighlightedIntersectionEdge(null);
return;
}
const intersectedEdgeId = getIntersectedEdgeId(nodeCenter);
if (!intersectedEdgeId) {
overlappedEdgeRef.current = null;
setHighlightedIntersectionEdge(null);
return;
}
const intersectedEdge = edges.find(
(edge) => edge.id === intersectedEdgeId && edge.className !== "temp",
);
if (!intersectedEdge) {
overlappedEdgeRef.current = null;
setHighlightedIntersectionEdge(null);
return;
}
if (
intersectedEdge.source === node.id ||
intersectedEdge.target === node.id
) {
overlappedEdgeRef.current = null;
setHighlightedIntersectionEdge(null);
return;
}
const handles = NODE_HANDLE_MAP[node.type ?? ""];
if (!hasHandleKey(handles, "source") || !hasHandleKey(handles, "target")) {
overlappedEdgeRef.current = null;
setHighlightedIntersectionEdge(null);
return;
}
overlappedEdgeRef.current = intersectedEdge.id;
setHighlightedIntersectionEdge(intersectedEdge.id);
},
[edges, setHighlightedIntersectionEdge],
);
// ─── Drag Start → Lock ────────────────────────────────────────
const onNodeDragStart = useCallback(() => {
isDragging.current = true;
}, []);
overlappedEdgeRef.current = null;
setHighlightedIntersectionEdge(null);
}, [setHighlightedIntersectionEdge]);
// ─── Drag Stop → Commit zu Convex ─────────────────────────────
const onNodeDragStop = useCallback(
(_: React.MouseEvent, node: RFNode, draggedNodes: RFNode[]) => {
// isDragging bleibt true bis die Mutation resolved ist → kein Convex-Override möglich
if (draggedNodes.length > 1) {
void batchMoveNodes({
moves: draggedNodes.map((n) => ({
nodeId: n.id as Id<"nodes">,
positionX: n.position.x,
positionY: n.position.y,
})),
}).then(() => {
const intersectedEdgeId = overlappedEdgeRef.current;
void (async () => {
try {
// isDragging bleibt true bis alle Mutations resolved sind
if (draggedNodes.length > 1) {
await batchMoveNodes({
moves: draggedNodes.map((n) => ({
nodeId: n.id as Id<"nodes">,
positionX: n.position.x,
positionY: n.position.y,
})),
});
} else {
await moveNode({
nodeId: node.id as Id<"nodes">,
positionX: node.position.x,
positionY: node.position.y,
});
}
if (!intersectedEdgeId) {
return;
}
const intersectedEdge = edges.find((edge) => edge.id === intersectedEdgeId);
if (!intersectedEdge || intersectedEdge.className === "temp") {
return;
}
if (
intersectedEdge.source === node.id ||
intersectedEdge.target === node.id
) {
return;
}
const handles = NODE_HANDLE_MAP[node.type ?? ""];
if (!hasHandleKey(handles, "source") || !hasHandleKey(handles, "target")) {
return;
}
await createEdge({
canvasId,
sourceNodeId: intersectedEdge.source as Id<"nodes">,
targetNodeId: node.id as Id<"nodes">,
sourceHandle: normalizeHandle(intersectedEdge.sourceHandle),
targetHandle: normalizeHandle(handles.target),
});
await createEdge({
canvasId,
sourceNodeId: node.id as Id<"nodes">,
targetNodeId: intersectedEdge.target as Id<"nodes">,
sourceHandle: normalizeHandle(handles.source),
targetHandle: normalizeHandle(intersectedEdge.targetHandle),
});
await removeEdge({ edgeId: intersectedEdge.id as Id<"edges"> });
} catch (error) {
console.error("[Canvas edge intersection split failed]", {
canvasId,
nodeId: node.id,
nodeType: node.type,
intersectedEdgeId,
error: String(error),
});
} finally {
overlappedEdgeRef.current = null;
setHighlightedIntersectionEdge(null);
isDragging.current = false;
});
} else {
void moveNode({
nodeId: node.id as Id<"nodes">,
positionX: node.position.x,
positionY: node.position.y,
}).then(() => {
isDragging.current = false;
});
}
}
})();
},
[moveNode, batchMoveNodes],
[
batchMoveNodes,
canvasId,
createEdge,
edges,
moveNode,
removeEdge,
setHighlightedIntersectionEdge,
],
);
// ─── Neue Verbindung → Convex Edge ────────────────────────────
@@ -419,43 +652,47 @@ function CanvasInner({ canvasId }: CanvasInnerProps) {
}
return (
<div className="relative h-full w-full">
<CanvasToolbar canvasId={canvasId} canvasName={canvas?.name ?? "canvas"} />
<ReactFlow
nodes={nodes}
edges={edges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeDragStart={onNodeDragStart}
onNodeDragStop={onNodeDragStop}
onConnect={onConnect}
onReconnect={onReconnect}
onReconnectStart={onReconnectStart}
onReconnectEnd={onReconnectEnd}
onNodesDelete={onNodesDelete}
onEdgesDelete={onEdgesDelete}
onDragOver={onDragOver}
onDrop={onDrop}
fitView
snapToGrid
snapGrid={[16, 16]}
deleteKeyCode={["Backspace", "Delete"]}
multiSelectionKeyCode="Shift"
proOptions={{ hideAttribution: true }}
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
className="bg-background"
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<Controls className="bg-card! border! shadow-sm! rounded-lg!" />
<MiniMap
className="bg-card! border! shadow-sm! rounded-lg!"
nodeColor={getMiniMapNodeColor}
nodeStrokeColor={getMiniMapNodeStrokeColor}
maskColor="rgba(0, 0, 0, 0.1)"
/>
</ReactFlow>
</div>
<CanvasPlacementProvider canvasId={canvasId}>
<div className="relative h-full w-full">
<CanvasToolbar canvasName={canvas?.name ?? "canvas"} />
<ReactFlow
nodes={nodes}
edges={edges}
defaultEdgeOptions={DEFAULT_EDGE_OPTIONS}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onNodeDragStart={onNodeDragStart}
onNodeDrag={onNodeDrag}
onNodeDragStop={onNodeDragStop}
onConnect={onConnect}
onReconnect={onReconnect}
onReconnectStart={onReconnectStart}
onReconnectEnd={onReconnectEnd}
onNodesDelete={onNodesDelete}
onEdgesDelete={onEdgesDelete}
onDragOver={onDragOver}
onDrop={onDrop}
fitView
snapToGrid
snapGrid={[16, 16]}
deleteKeyCode={["Backspace", "Delete"]}
multiSelectionKeyCode="Shift"
proOptions={{ hideAttribution: true }}
colorMode={resolvedTheme === "dark" ? "dark" : "light"}
className="bg-background"
>
<Background variant={BackgroundVariant.Dots} gap={16} size={1} />
<Controls className="bg-card! border! shadow-sm! rounded-lg!" />
<MiniMap
className="bg-card! border! shadow-sm! rounded-lg!"
nodeColor={getMiniMapNodeColor}
nodeStrokeColor={getMiniMapNodeStrokeColor}
maskColor="rgba(0, 0, 0, 0.1)"
/>
</ReactFlow>
</div>
</CanvasPlacementProvider>
);
}

View File

@@ -80,6 +80,12 @@ export default function CompareNode({ data, selected }: NodeProps) {
style={{ top: "55%" }}
className="!h-3 !w-3 !border-2 !border-background !bg-emerald-500"
/>
<Handle
type="source"
position={Position.Right}
id="compare-out"
className="!h-3 !w-3 !border-2 !border-background !bg-muted-foreground"
/>
<div
ref={containerRef}

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { type NodeProps, type Node } from "@xyflow/react";
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
import { useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
@@ -22,6 +22,7 @@ export default function GroupNode({ id, data, selected }: NodeProps<GroupNode>)
useEffect(() => {
if (!isEditing) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setLabel(data.label ?? "Gruppe");
}
}, [data.label, isEditing]);
@@ -46,6 +47,12 @@ export default function GroupNode({ id, data, selected }: NodeProps<GroupNode>)
selected={selected}
className="min-w-[200px] min-h-[150px] p-3 border-dashed"
>
<Handle
type="target"
position={Position.Left}
className="!h-3 !w-3 !bg-muted-foreground !border-2 !border-background"
/>
{isEditing ? (
<input
value={label}
@@ -63,6 +70,12 @@ export default function GroupNode({ id, data, selected }: NodeProps<GroupNode>)
📁 {label}
</div>
)}
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !bg-muted-foreground !border-2 !border-background"
/>
</BaseNodeWrapper>
);
}

View File

@@ -117,6 +117,12 @@ export default function ImageNode({ id, data, selected }: NodeProps<ImageNode>)
return (
<BaseNodeWrapper selected={selected} status={data._status}>
<Handle
type="target"
position={Position.Left}
className="h-3! w-3! bg-primary! border-2! border-background!"
/>
<div className="p-2">
<div className="mb-1 flex items-center justify-between">
<div className="text-xs font-medium text-muted-foreground">🖼 Bild</div>

View File

@@ -1,7 +1,7 @@
"use client";
import { useState, useCallback, useEffect } from "react";
import { type NodeProps, type Node } from "@xyflow/react";
import { Handle, Position, type NodeProps, type Node } from "@xyflow/react";
import { useMutation } from "convex/react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
@@ -23,6 +23,7 @@ export default function NoteNode({ id, data, selected }: NodeProps<NoteNode>) {
useEffect(() => {
if (!isEditing) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setContent(data.content ?? "");
}
}, [data.content, isEditing]);
@@ -53,6 +54,12 @@ export default function NoteNode({ id, data, selected }: NodeProps<NoteNode>) {
return (
<BaseNodeWrapper selected={selected} className="w-52 p-3">
<Handle
type="target"
position={Position.Left}
className="!h-3 !w-3 !bg-primary !border-2 !border-background"
/>
<div className="text-xs font-medium text-muted-foreground mb-1">
📌 Notiz
</div>
@@ -78,6 +85,12 @@ export default function NoteNode({ id, data, selected }: NodeProps<NoteNode>) {
)}
</div>
)}
<Handle
type="source"
position={Position.Right}
className="!h-3 !w-3 !bg-primary !border-2 !border-background"
/>
</BaseNodeWrapper>
);
}

View File

@@ -13,6 +13,7 @@ import { useMutation, useAction } from "convex/react";
import { api } from "@/convex/_generated/api";
import type { Id } from "@/convex/_generated/dataModel";
import BaseNodeWrapper from "./base-node-wrapper";
import { useCanvasPlacement } from "@/components/canvas/canvas-placement-context";
import { useDebouncedCallback } from "@/hooks/use-debounced-callback";
import { DEFAULT_MODEL_ID } from "@/lib/ai-models";
import {
@@ -104,9 +105,9 @@ export default function PromptNode({
dataRef.current = data;
const updateData = useMutation(api.nodes.updateData);
const createNode = useMutation(api.nodes.create);
const createEdge = useMutation(api.edges.create);
const generateImage = useAction(api.ai.generateImage);
const { createNodeWithIntersection } = useCanvasPlacement();
const debouncedSave = useDebouncedCallback(() => {
const raw = dataRef.current as Record<string, unknown>;
@@ -181,11 +182,9 @@ export default function PromptNode({
const viewport = getImageViewportSize(aspectRatio);
const outer = getAiImageNodeOuterSize(viewport);
const aiNodeId = await createNode({
canvasId,
const aiNodeId = await createNodeWithIntersection({
type: "ai-image",
positionX: posX,
positionY: posY,
position: { x: posX, y: posY },
width: outer.width,
height: outer.height,
data: {
@@ -229,7 +228,7 @@ export default function PromptNode({
id,
getEdges,
getNode,
createNode,
createNodeWithIntersection,
createEdge,
generateImage,
]);

View File

@@ -75,7 +75,13 @@ export default function TextNode({ id, data, selected }: NodeProps<TextNode>) {
);
return (
<BaseNodeWrapper selected={selected} status={data._status}>
<BaseNodeWrapper selected={selected} status={data._status} className="relative">
<Handle
type="target"
position={Position.Left}
className="!h-3 !w-3 !bg-primary !border-2 !border-background"
/>
<div className="w-64 p-3">
<div className="text-xs font-medium text-muted-foreground mb-1">
📝 Text