import { memo, useMemo } from 'react'; import { Text, Rect, Group, Line } from 'react-konva'; import type { Point, Annotation, ElectricalItem, FurnitureItem } from '@house-plan-maker/shared'; import { rotatedAnchorOffsetToCenter } from '@house-plan-maker/shared'; interface AnnotationLayerProps { readonly annotations: readonly Annotation[]; readonly electricalItems: readonly ElectricalItem[]; readonly furnitureItems: readonly FurnitureItem[]; readonly zoom: number; readonly panOffset: Point; readonly selectedIds: ReadonlySet; readonly visible?: boolean; readonly onDragEnd?: (id: string, x: number, y: number) => void; readonly onDoubleClick?: (id: string) => void; readonly onSelect?: (id: string) => void; } const DEFAULT_FONT_SIZE = 14; const DEFAULT_COLOR = '#333333'; const LINK_COLOR = '#2563eb'; const SELECTED_COLOR = '#4c6ef5'; const SELECTION_PADDING = 4; // Match plain http(s) URLs only — anything else stays a regular annotation. // Anchored to start/end so a label like "see http://x" isn't treated as a // link (we want the whole text to be the URL). const URL_PATTERN = /^https?:\/\/\S+$/i; function isUrlAnnotation(text: string): boolean { return URL_PATTERN.test(text.trim()); } function toScreen(point: Point, zoom: number, panOffset: Point): { x: number; y: number } { return { x: point.x * zoom + panOffset.x, y: point.y * zoom + panOffset.y, }; } export const AnnotationLayer = memo(function AnnotationLayer({ annotations, electricalItems, furnitureItems, zoom, panOffset, selectedIds, visible = true, onDragEnd, onDoubleClick, onSelect, }: AnnotationLayerProps) { // Build item position lookup for attached annotations const itemPositions = useMemo(() => { const map = new Map(); for (const item of electricalItems) { map.set(item.id, { x: item.x, y: item.y }); } for (const item of furnitureItems) { const offset = rotatedAnchorOffsetToCenter( item.positionAnchor, item.width, item.depth, item.rotation, ); map.set(item.id, { x: item.x + offset.dx, y: item.y + offset.dy }); } return map; }, [electricalItems, furnitureItems]); const renderedAnnotations = useMemo(() => { if (!visible) return []; return annotations; }, [annotations, visible]); return ( {renderedAnnotations.map((annotation) => { // Resolve position: if attached, offset from parent item let worldX = annotation.x; let worldY = annotation.y; let parentScreen: { x: number; y: number } | null = null; if (annotation.attachedToId) { const parentPos = itemPositions.get(annotation.attachedToId); if (parentPos) { worldX = parentPos.x + annotation.x; worldY = parentPos.y + annotation.y; parentScreen = toScreen(parentPos, zoom, panOffset); } } const screen = toScreen({ x: worldX, y: worldY }, zoom, panOffset); const isSelected = selectedIds.has(annotation.id); const fontSize = annotation.fontSize ?? DEFAULT_FONT_SIZE; const isLink = isUrlAnnotation(annotation.text); // Link annotations get a distinctive blue tint when not selected so // users can spot them; selection still wins to keep the affordance // consistent with non-link annotations. const color = isSelected ? SELECTED_COLOR : isLink ? (annotation.color ?? LINK_COLOR) : (annotation.color ?? DEFAULT_COLOR); return ( {/* Leader line from item to annotation */} {parentScreen && ( )} { const node = e.target; let newX = (node.x() - panOffset.x) / zoom; let newY = (node.y() - panOffset.y) / zoom; // For attached annotations, store as offset from parent if (annotation.attachedToId) { const parentPos = itemPositions.get(annotation.attachedToId); if (parentPos) { newX -= parentPos.x; newY -= parentPos.y; } } onDragEnd?.(annotation.id, newX, newY); }} onClick={(e) => { // Ctrl/Cmd-click on a URL annotation opens it in a new tab. // We swallow the event so it doesn't also trigger selection // or upstream stage handlers (which would deselect the link // immediately on focus loss). Plain clicks fall through to // the regular select handler. if (isLink && (e.evt.ctrlKey || e.evt.metaKey)) { e.cancelBubble = true; window.open(annotation.text.trim(), '_blank', 'noopener,noreferrer'); return; } onSelect?.(annotation.id); }} onDblClick={() => onDoubleClick?.(annotation.id)} > {/* Background */} {isSelected && ( )} ); })} ); });