Files
house-plan-maker/apps/client/src/components/editor/layers/AnnotationLayer.tsx
T
alexei.dolgolyov d8a914bf2a feat: editor improvements and collapsible sidebars
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.
2026-04-08 12:27:57 +03:00

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>
);
});