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.
This commit is contained in:
2026-04-08 12:27:57 +03:00
parent aa8a874348
commit d8a914bf2a
116 changed files with 7324 additions and 1114 deletions
@@ -1,6 +1,7 @@
import { memo, useMemo } from 'react';
import { Layer, Text, Rect, Group, Line } from 'react-konva';
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[];
@@ -17,9 +18,19 @@ interface AnnotationLayerProps {
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,
@@ -46,7 +57,13 @@ export const AnnotationLayer = memo(function AnnotationLayer({
map.set(item.id, { x: item.x, y: item.y });
}
for (const item of furnitureItems) {
map.set(item.id, { x: item.x + item.width / 2, y: item.y + item.depth / 2 });
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]);
@@ -57,7 +74,7 @@ export const AnnotationLayer = memo(function AnnotationLayer({
}, [annotations, visible]);
return (
<Layer visible={visible}>
<Group visible={visible}>
{renderedAnnotations.map((annotation) => {
// Resolve position: if attached, offset from parent item
let worldX = annotation.x;
@@ -76,7 +93,15 @@ export const AnnotationLayer = memo(function AnnotationLayer({
const screen = toScreen({ x: worldX, y: worldY }, zoom, panOffset);
const isSelected = selectedIds.has(annotation.id);
const fontSize = annotation.fontSize ?? DEFAULT_FONT_SIZE;
const color = isSelected ? SELECTED_COLOR : (annotation.color ?? DEFAULT_COLOR);
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}>
@@ -108,7 +133,19 @@ export const AnnotationLayer = memo(function AnnotationLayer({
}
onDragEnd?.(annotation.id, newX, newY);
}}
onClick={() => onSelect?.(annotation.id)}
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 */}
@@ -143,6 +180,6 @@ export const AnnotationLayer = memo(function AnnotationLayer({
</Group>
);
})}
</Layer>
</Group>
);
});