d8a914bf2a
Add collapse/expand toggle for the AppShell navigation sidebar and the editor properties panel (both persisted to localStorage). Bundles other in-progress editor work including position anchors, outlet sizing, PBR textures, window slope/frame depth, curtain metadata, and various 2D/3D rendering tweaks.
186 lines
6.5 KiB
TypeScript
186 lines
6.5 KiB
TypeScript
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<string>;
|
|
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<string, { x: number; y: number }>();
|
|
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 (
|
|
<Group visible={visible}>
|
|
{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 (
|
|
<Group key={annotation.id}>
|
|
{/* Leader line from item to annotation */}
|
|
{parentScreen && (
|
|
<Line
|
|
points={[parentScreen.x, parentScreen.y, screen.x, screen.y]}
|
|
stroke={annotation.color ?? '#94a3b8'}
|
|
strokeWidth={1}
|
|
dash={[3, 3]}
|
|
listening={false}
|
|
/>
|
|
)}
|
|
<Group
|
|
x={screen.x}
|
|
y={screen.y}
|
|
draggable
|
|
onDragEnd={(e) => {
|
|
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 */}
|
|
<Rect
|
|
x={-2}
|
|
y={-1}
|
|
width={annotation.text.length * fontSize * 0.6 + 4}
|
|
height={fontSize + 2}
|
|
fill="rgba(255,255,255,0.85)"
|
|
cornerRadius={2}
|
|
listening={false}
|
|
/>
|
|
{isSelected && (
|
|
<Rect
|
|
x={-SELECTION_PADDING}
|
|
y={-SELECTION_PADDING}
|
|
width={annotation.text.length * fontSize * 0.6 + SELECTION_PADDING * 2}
|
|
height={fontSize + SELECTION_PADDING * 2}
|
|
stroke={SELECTED_COLOR}
|
|
strokeWidth={1}
|
|
dash={[4, 2]}
|
|
fill="rgba(76, 110, 245, 0.05)"
|
|
/>
|
|
)}
|
|
<Text
|
|
text={annotation.text}
|
|
fontSize={fontSize}
|
|
fill={color}
|
|
fontFamily="sans-serif"
|
|
/>
|
|
</Group>
|
|
</Group>
|
|
);
|
|
})}
|
|
</Group>
|
|
);
|
|
});
|