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,21 +1,70 @@
import { useMemo, useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type { Wall, WallOpening, ElectricalItem, FurnitureItem, DoorOpenDirection, FloorType } from '@house-plan-maker/shared';
import { DOOR_OPEN_DIRECTIONS, FLOOR_TYPES } from '@house-plan-maker/shared';
import type { Wall, WallOpening, ElectricalItem, FurnitureItem, DoorOpenDirection, FloorType, WallFinish, Annotation, PositionAnchor, HorizontalAnchor, VerticalAnchor } from '@house-plan-maker/shared';
import { TextPromptModal } from '../ui/TextPromptModal';
import { DOOR_OPEN_DIRECTIONS, FLOOR_TYPES, WALL_FINISHES, HORIZONTAL_ANCHORS, VERTICAL_ANCHORS } from '@house-plan-maker/shared';
import { useEditor } from './context/EditorContext';
import { useUndoRedo } from './context/UndoRedoContext';
import { wallLength } from './utils/wallUtils';
import { polygonArea, polygonPerimeter, generateLocalId } from './utils/geometry';
import { normalizeAngleDegrees } from './utils/angle';
import { getElectricalVariant, ELECTRICAL_SYMBOL_DEFS } from './symbols/electrical';
import {
getCurtainLeftOpen,
getCurtainRightOpen,
getCurtainFabricColor,
} from './utils/curtainMetadata';
import type { EditorCommand } from './types';
import styles from './properties-panel.module.css';
const PROPERTIES_COLLAPSED_KEY = 'editor.propertiesPanel.collapsed';
function readCollapsed(): boolean {
try {
return localStorage.getItem(PROPERTIES_COLLAPSED_KEY) === 'true';
} catch {
return false;
}
}
function writeCollapsed(value: boolean): void {
try {
localStorage.setItem(PROPERTIES_COLLAPSED_KEY, String(value));
} catch {
/* ignore quota / disabled storage */
}
}
export function PropertiesPanel() {
const { t } = useTranslation();
const { state, dispatch, updateOpening, updateElectrical, updateFurniture, updateWall, addAnnotation } = useEditor();
const { state, dispatch, updateOpening, updateElectrical, updateFurniture, updateWall } = useEditor();
const { execute } = useUndoRedo();
const { selectedIds, walls, openings, electricalItems, furnitureItems, room } = state;
const [collapsed, setCollapsed] = useState<boolean>(() => readCollapsed());
const toggleCollapsed = useCallback(() => {
setCollapsed((prev) => {
const next = !prev;
writeCollapsed(next);
return next;
});
}, []);
const header = (
<div className={styles.header}>
<span>{t('properties.title')}</span>
<button
type="button"
className={styles.collapseBtn}
onClick={toggleCollapsed}
title={t('properties.collapse')}
aria-label={t('properties.collapse')}
>
{'\u25B6'}
</button>
</div>
);
const roomArea = useMemo(
() => room.shape.length >= 3 ? polygonArea(room.shape) : 0,
[room.shape],
@@ -57,10 +106,26 @@ export function PropertiesPanel() {
return items;
}, [selectedIds, walls, openings, electricalItems, furnitureItems]);
if (collapsed) {
return (
<div className={styles.panelCollapsed}>
<button
type="button"
className={styles.collapseBtn}
onClick={toggleCollapsed}
title={t('properties.expand')}
aria-label={t('properties.expand')}
>
{'\u25C0'}
</button>
</div>
);
}
if (selected.length === 0) {
return (
<div className={styles.panel}>
<div className={styles.header}>{t('properties.title')}</div>
{header}
<div className={styles.empty}>
<p className={styles.emptyText}>{t('properties.noSelection')}</p>
<p className={styles.emptyHint}>{t('properties.selectHint')}</p>
@@ -85,6 +150,15 @@ export function PropertiesPanel() {
}))}
onChange={(v) => dispatch({ type: 'UPDATE_ROOM_PROPS', props: { floorType: v } })}
/>
<SelectPropertyRow<WallFinish>
label={t('properties.wallFinish')}
value={room.wallFinish ?? 'PAINT'}
options={WALL_FINISHES.map((wf) => ({
value: wf,
label: t(`wallFinish.${wf}`),
}))}
onChange={(v) => dispatch({ type: 'UPDATE_ROOM_PROPS', props: { wallFinish: v } })}
/>
<div className={styles.row}>
<span className={styles.rowLabel}>{t('properties.wallColor')}</span>
<input
@@ -92,10 +166,39 @@ export function PropertiesPanel() {
value={room.wallColor ?? '#f5f0eb'}
onChange={(e) => dispatch({ type: 'UPDATE_ROOM_PROPS', props: { wallColor: e.target.value } })}
style={{ width: 32, height: 24, border: '1px solid var(--color-border)', borderRadius: 4, cursor: 'pointer', padding: 0 }}
// Wall color only renders on the PAINT finish — when a textured
// finish is selected the value is still editable so the user
// can pre-pick a colour for when they switch back. Tooltip
// explains when it won't be visible.
title={(room.wallFinish ?? 'PAINT') !== 'PAINT' ? t('properties.wallColorPaintOnly') : t('properties.wallColor')}
/>
</div>
<PropertyRow label={t('properties.walls')} value={String(walls.length)} />
<PropertyRow label={t('properties.openings')} value={String(openings.length)} />
{/* Room-level outlet dimensions — used to draw outlet boundaries in
all views (2D/3D/projection). Stored in meters; edited in cm. */}
<EditablePropertyRow
label={t('properties.outletWidth')}
value={String(Math.round(room.outletWidth * 1000) / 10)}
unit="cm"
onCommit={(v) => {
const cm = parseFloat(v);
if (!isNaN(cm) && cm > 0 && cm <= 100) {
dispatch({ type: 'UPDATE_ROOM_PROPS', props: { outletWidth: cm / 100 } });
}
}}
/>
<EditablePropertyRow
label={t('properties.outletHeight')}
value={String(Math.round(room.outletHeight * 1000) / 10)}
unit="cm"
onCommit={(v) => {
const cm = parseFloat(v);
if (!isNaN(cm) && cm > 0 && cm <= 100) {
dispatch({ type: 'UPDATE_ROOM_PROPS', props: { outletHeight: cm / 100 } });
}
}}
/>
</div>
</div>
);
@@ -104,7 +207,7 @@ export function PropertiesPanel() {
if (selected.length > 1) {
return (
<div className={styles.panel}>
<div className={styles.header}>{t('properties.title')}</div>
{header}
<div className={styles.empty}>
<p className={styles.emptyText}>{t('properties.multipleSelected', { count: selected.length })}</p>
</div>
@@ -116,7 +219,7 @@ export function PropertiesPanel() {
return (
<div className={styles.panel}>
<div className={styles.header}>{t('properties.title')}</div>
{header}
{item.type === 'wall' && (
<WallProperties
wall={item.data as Wall}
@@ -174,39 +277,110 @@ export function PropertiesPanel() {
}}
/>
)}
{/* Add note button for any item */}
{/* Add note / edit attached annotations for any item */}
{(item.type === 'electrical' || item.type === 'furniture') && (
<div style={{ padding: '4px 8px' }}>
<ItemAnnotationManager itemId={item.data.id} roomId={room.id} />
)}
</div>
);
}
// ── Attached annotation manager ──
interface ItemAnnotationManagerProps {
readonly itemId: string;
readonly roomId: string;
}
function ItemAnnotationManager({ itemId, roomId }: ItemAnnotationManagerProps) {
const { t } = useTranslation();
const { state, addAnnotation, updateAnnotation, removeAnnotation } = useEditor();
const attached = useMemo(
() => state.annotations.filter((a) => a.attachedToId === itemId),
[state.annotations, itemId],
);
const [editing, setEditing] = useState<{ kind: 'add' } | { kind: 'edit'; annotation: Annotation } | null>(null);
return (
<div style={{ padding: '4px 8px', display: 'flex', flexDirection: 'column', gap: 4 }}>
{attached.map((ann) => (
<div
key={ann.id}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: 12,
padding: '2px 6px',
border: '1px solid var(--color-border)',
borderRadius: 4,
background: 'var(--color-bg)',
}}
>
<span style={{ flex: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{ann.text}
</span>
<button
style={{
width: '100%',
padding: '4px 8px',
fontSize: '12px',
border: '1px solid var(--color-border)',
borderRadius: '4px',
background: 'var(--color-bg)',
color: 'var(--color-text-secondary)',
cursor: 'pointer',
}}
onClick={() => {
const text = window.prompt(t('annotation.editPrompt'), '');
if (text) {
addAnnotation({
id: generateLocalId(),
roomId: room.id,
x: 0.3,
y: -0.2,
text,
fontSize: 12,
attachedToId: item.data.id,
});
}
}}
type="button"
style={{ fontSize: 11, padding: '0 4px' }}
onClick={() => setEditing({ kind: 'edit', annotation: ann })}
aria-label={t('annotation.edit') ?? 'Edit'}
>
{t('properties.addNote')}
</button>
<button
type="button"
style={{ fontSize: 11, padding: '0 4px' }}
onClick={() => removeAnnotation(ann.id)}
aria-label={t('annotation.delete') ?? 'Delete'}
>
</button>
</div>
)}
))}
<button
type="button"
style={{
width: '100%',
padding: '4px 8px',
fontSize: 12,
border: '1px solid var(--color-border)',
borderRadius: 4,
background: 'var(--color-bg)',
color: 'var(--color-text-secondary)',
cursor: 'pointer',
}}
onClick={() => setEditing({ kind: 'add' })}
>
{t('properties.addNote')}
</button>
<TextPromptModal
open={editing != null}
title={t('annotation.editPrompt')}
initialValue={editing?.kind === 'edit' ? editing.annotation.text : ''}
onConfirm={(value) => {
const trimmed = value.trim();
if (!editing) return;
if (editing.kind === 'add') {
if (trimmed) {
addAnnotation({
id: generateLocalId(),
roomId,
x: 0.3,
y: -0.2,
text: trimmed,
fontSize: 12,
attachedToId: itemId,
});
}
} else if (trimmed && trimmed !== editing.annotation.text) {
updateAnnotation({ ...editing.annotation, text: trimmed });
}
setEditing(null);
}}
onCancel={() => setEditing(null)}
/>
</div>
);
}
@@ -260,9 +434,35 @@ interface OpeningPropertiesProps {
}
function OpeningProperties({ opening, walls, onUpdate }: OpeningPropertiesProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const wall = walls.find((w) => w.id === opening.wallId);
const wLen = wall ? wallLength(wall) : 0;
const gridColsLabel = i18n.exists('properties.windowGridCols')
? t('properties.windowGridCols')
: 'Grid columns';
const gridRowsLabel = i18n.exists('properties.windowGridRows')
? t('properties.windowGridRows')
: 'Grid rows';
const slopeDepthLabel = i18n.exists('properties.windowSlopeDepth')
? t('properties.windowSlopeDepth')
: 'Reveal depth (slope)';
const frameThicknessLabel = i18n.exists('properties.openingFrameThickness')
? t('properties.openingFrameThickness')
: 'Frame thickness';
// Slope projects outward from the window into and through the wall
// toward the exterior, so it isn't bounded by wall thickness — only by
// the schema-level cap (2 m), which is plenty for any realistic reveal.
const maxSlopeDepth = 2;
// Frame thickness is bounded by the schema cap (0.5 m); deeper than that
// would dwarf even oversize doors and is almost certainly a typo.
const maxFrameThickness = 0.5;
// Openings always store canonical (positionAlongWall=center, elevationFromFloor=bottom).
// The anchor on an opening is a view-only preference: it controls how the
// numbers are displayed and edited in this panel, but does not move the
// physical opening. Toggling anchor only changes which edge of the opening
// the displayed values refer to.
const anchor = opening.positionAnchor;
const handleWidthChange = useCallback(
(value: string) => {
@@ -284,31 +484,50 @@ function OpeningProperties({ opening, walls, onUpdate }: OpeningPropertiesProps)
[opening, onUpdate],
);
// Position displayed as left edge offset, stored as center
const displayPosition = Math.round((opening.positionAlongWall - opening.width / 2) * 1000) / 1000;
// Convert canonical (center along wall, bottom from floor) into the value
// displayed in the panel based on the user's anchor preference.
const displayPosition = Math.round((() => {
if (anchor.horizontal === 'left') return opening.positionAlongWall - opening.width / 2;
if (anchor.horizontal === 'right') return opening.positionAlongWall + opening.width / 2;
return opening.positionAlongWall;
})() * 1000) / 1000;
const displayElevation = Math.round((() => {
if (anchor.vertical === 'top') return opening.elevationFromFloor + opening.height;
if (anchor.vertical === 'middle') return opening.elevationFromFloor + opening.height / 2;
return opening.elevationFromFloor;
})() * 1000) / 1000;
const handlePositionChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num) && num >= 0) {
// Convert left edge offset back to center position
const centerPos = num + opening.width / 2;
if (centerPos <= wLen) {
if (!isNaN(num)) {
// Convert anchored value back to canonical center position.
let centerPos = num;
if (anchor.horizontal === 'left') centerPos = num + opening.width / 2;
else if (anchor.horizontal === 'right') centerPos = num - opening.width / 2;
if (centerPos >= 0 && centerPos <= wLen) {
onUpdate({ ...opening, positionAlongWall: centerPos });
}
}
},
[opening, onUpdate, wLen],
[opening, onUpdate, wLen, anchor.horizontal],
);
const handleElevationChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num) && num >= 0) {
onUpdate({ ...opening, elevationFromFloor: num });
if (!isNaN(num)) {
// Convert anchored vertical value back to canonical bottom-edge.
let bottom = num;
if (anchor.vertical === 'top') bottom = num - opening.height;
else if (anchor.vertical === 'middle') bottom = num - opening.height / 2;
if (bottom >= 0) {
onUpdate({ ...opening, elevationFromFloor: bottom });
}
}
},
[opening, onUpdate],
[opening, onUpdate, anchor.vertical],
);
const handleOpenDirectionChange = useCallback(
@@ -337,10 +556,27 @@ function OpeningProperties({ opening, walls, onUpdate }: OpeningPropertiesProps)
/>
<EditablePropertyRow
label={t('properties.position')}
value={String(Math.max(0, displayPosition))}
value={String(displayPosition)}
unit="m"
onCommit={handlePositionChange}
/>
<PositionAnchorEditor
anchor={anchor}
onChange={(positionAnchor) => onUpdate({ ...opening, positionAnchor })}
/>
{/* Frame member thickness — applies to both doors and windows. Clamped
to the schema cap so a typo can't produce a wall-sized frame. */}
<EditablePropertyRow
label={frameThicknessLabel}
value={String(opening.frameThickness)}
unit="m"
onCommit={(v) => {
const n = parseFloat(v);
if (!isNaN(n) && n >= 0) {
onUpdate({ ...opening, frameThickness: Math.min(n, maxFrameThickness) });
}
}}
/>
{opening.type === 'DOOR' && (
<SelectPropertyRow
label={t('properties.openDirection')}
@@ -353,12 +589,54 @@ function OpeningProperties({ opening, walls, onUpdate }: OpeningPropertiesProps)
/>
)}
{opening.type === 'WINDOW' && (
<EditablePropertyRow
label={t('properties.elevation')}
value={String(opening.elevationFromFloor)}
unit="m"
onCommit={handleElevationChange}
/>
<>
<EditablePropertyRow
label={t('properties.elevation')}
value={String(displayElevation)}
unit="m"
onCommit={handleElevationChange}
/>
{/* Grid subdivision: N columns × M rows of panes. The 3D and
projection views render (cols-1) vertical mullions and
(rows-1) horizontal mullions. Clamp to [1, 10] so a user
typo can't produce a 1000-mullion window. */}
<EditablePropertyRow
label={gridColsLabel}
value={String(opening.gridCols)}
onCommit={(v) => {
const n = parseInt(v, 10);
if (!isNaN(n) && n >= 1 && n <= 10) {
onUpdate({ ...opening, gridCols: n });
}
}}
/>
<EditablePropertyRow
label={gridRowsLabel}
value={String(opening.gridRows)}
onCommit={(v) => {
const n = parseInt(v, 10);
if (!isNaN(n) && n >= 1 && n <= 10) {
onUpdate({ ...opening, gridRows: n });
}
}}
/>
{/* Reveal (откос) depth — how far the angled jamb panels protrude
from the window frame into the room. 0 = flush, no slope. The
renderer clamps the value so it cannot exceed half the wall
thickness; we mirror that clamp in the input handler so a
typo can't push the window out the back of the wall. */}
<EditablePropertyRow
label={slopeDepthLabel}
value={String(opening.slopeDepth)}
unit="m"
onCommit={(v) => {
const n = parseFloat(v);
if (!isNaN(n) && n >= 0) {
onUpdate({ ...opening, slopeDepth: Math.min(n, maxSlopeDepth) });
}
}}
/>
</>
)}
{wall && (
<PropertyRow label={t('properties.wallLength')} value={formatM(wLen)} />
@@ -449,6 +727,52 @@ function EditablePropertyRow({ label, value, unit, onCommit }: EditablePropertyR
);
}
// ── Position Anchor Editor ──
//
// Renders two side-by-side select boxes that edit `positionAnchor`. Used for
// every placeable item (electrical, furniture, openings).
interface PositionAnchorEditorProps {
readonly anchor: PositionAnchor;
readonly onChange: (anchor: PositionAnchor) => void;
}
function PositionAnchorEditor({ anchor, onChange }: PositionAnchorEditorProps) {
const { t, i18n } = useTranslation();
const label = i18n.exists('properties.anchor') ? t('properties.anchor') : 'Anchor';
const labelFor = (v: HorizontalAnchor | VerticalAnchor): string => {
const key = `anchor.${v}`;
return i18n.exists(key) ? t(key) : v;
};
return (
<div className={styles.row}>
<span className={styles.rowLabel}>{label}</span>
<span style={{ display: 'flex', gap: 4, flex: 1, justifyContent: 'flex-end' }}>
<select
className={styles.selectInput}
value={anchor.horizontal}
onChange={(e) => onChange({ ...anchor, horizontal: e.target.value as HorizontalAnchor })}
style={{ flex: 1, minWidth: 0 }}
>
{HORIZONTAL_ANCHORS.map((h) => (
<option key={h} value={h}>{labelFor(h)}</option>
))}
</select>
<select
className={styles.selectInput}
value={anchor.vertical}
onChange={(e) => onChange({ ...anchor, vertical: e.target.value as VerticalAnchor })}
style={{ flex: 1, minWidth: 0 }}
>
{VERTICAL_ANCHORS.map((v) => (
<option key={v} value={v}>{labelFor(v)}</option>
))}
</select>
</span>
</div>
);
}
// ── Select Property Row ──
interface SelectPropertyRowProps<T extends string> {
@@ -458,6 +782,65 @@ interface SelectPropertyRowProps<T extends string> {
readonly onChange: (value: T) => void;
}
// ── Label override row ──
//
// Editable text input that overrides the default symbol/furniture label.
// Empty string clears the override (stored as `null`); the placeholder
// shows the default the item would fall back to.
interface LabelOverrideRowProps {
readonly value: string | null;
readonly placeholder: string;
readonly onChange: (value: string | null) => void;
}
function LabelOverrideRow({ value, placeholder, onChange }: LabelOverrideRowProps) {
const { t, i18n } = useTranslation();
const [draft, setDraft] = useState(value ?? '');
const [editing, setEditing] = useState(false);
// Sync external changes (selecting a different item) into the draft when
// the input is not currently being edited.
if (!editing && draft !== (value ?? '')) {
setDraft(value ?? '');
}
const commit = useCallback(() => {
setEditing(false);
const trimmed = draft.trim();
const next = trimmed.length > 0 ? trimmed : null;
if (next !== value) {
onChange(next);
}
}, [draft, value, onChange]);
const label = i18n.exists('properties.customLabel') ? t('properties.customLabel') : 'Title';
return (
<div className={styles.row}>
<span className={styles.rowLabel}>{label}</span>
<input
type="text"
className={styles.editInput}
value={draft}
placeholder={placeholder}
onFocus={() => setEditing(true)}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
(e.target as HTMLInputElement).blur();
} else if (e.key === 'Escape') {
setDraft(value ?? '');
setEditing(false);
}
}}
style={{ flex: 1, minWidth: 0 }}
/>
</div>
);
}
function SelectPropertyRow<T extends string>({ label, value, options, onChange }: SelectPropertyRowProps<T>) {
return (
<div className={styles.row}>
@@ -485,13 +868,15 @@ interface ElectricalPropertiesProps {
}
function ElectricalProperties({ item, onUpdate }: ElectricalPropertiesProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const variant = getElectricalVariant(item.metadata);
const def = ELECTRICAL_SYMBOL_DEFS.find(
(d) => d.type === item.type && (d.variant ?? 'single') === variant,
(d) => d.type === item.type && (d.variant ?? 'single') === (item.type === 'OUTLET' ? undefined : variant),
);
const isWallMounted = item.wallId !== null;
const isOutlet = item.type === 'OUTLET';
const countLabel = i18n.exists('properties.outletCount') ? t('properties.outletCount') : 'Count';
const handleXChange = useCallback(
(value: string) => {
@@ -512,7 +897,7 @@ function ElectricalProperties({ item, onUpdate }: ElectricalPropertiesProps) {
const handleRotationChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num)) onUpdate({ ...item, rotation: num % 360 });
if (!isNaN(num)) onUpdate({ ...item, rotation: normalizeAngleDegrees(num) });
},
[item, onUpdate],
);
@@ -527,16 +912,38 @@ function ElectricalProperties({ item, onUpdate }: ElectricalPropertiesProps) {
[item, onUpdate],
);
// Section title shows the user's custom label if set, otherwise the
// default symbol-def label or raw type code as fallback.
const displayTitle = item.label ?? def?.label ?? item.type;
const labelPlaceholder = def?.label ?? item.type;
return (
<div className={styles.section}>
<div className={styles.sectionTitle}>
{def?.label ?? item.type}
</div>
<div className={styles.sectionTitle}>{displayTitle}</div>
<PropertyRow label={t('properties.type')} value={item.type} />
{variant !== 'single' && <PropertyRow label={t('properties.variant')} value={variant} />}
{!isOutlet && variant !== 'single' && <PropertyRow label={t('properties.variant')} value={variant} />}
<LabelOverrideRow
value={item.label}
placeholder={labelPlaceholder}
onChange={(label) => onUpdate({ ...item, label })}
/>
{isOutlet && (
<EditablePropertyRow
label={countLabel}
value={String(item.count)}
onCommit={(v) => {
const n = parseInt(v, 10);
if (!isNaN(n) && n >= 1 && n <= 20) onUpdate({ ...item, count: n });
}}
/>
)}
<EditablePropertyRow label={t('properties.x')} value={String(Math.round(item.x * 1000) / 1000)} unit="m" onCommit={handleXChange} />
<EditablePropertyRow label={t('properties.y')} value={String(Math.round(item.y * 1000) / 1000)} unit="m" onCommit={handleYChange} />
<EditablePropertyRow label={t('properties.rotation')} value={String(Math.round(item.rotation))} unit={"\u00b0"} onCommit={handleRotationChange} />
<PositionAnchorEditor
anchor={item.positionAnchor}
onChange={(positionAnchor) => onUpdate({ ...item, positionAnchor })}
/>
{isWallMounted && (
<>
<PropertyRow label={t('properties.wallMounted')} value={t('properties.yes')} />
@@ -559,8 +966,34 @@ interface FurniturePropertiesProps {
readonly onUpdate: (item: FurnitureItem) => void;
}
/**
* Strip the legacy `[no-stand]` marker from a furniture label so the input
* field shows only the user-visible name. The marker is a single-purpose
* boolean stored in the label string for TVs to disable the stand mesh.
*/
function stripFurnitureMarkers(label: string | null): string | null {
if (!label) return null;
const cleaned = label.replace('[no-stand]', '').trim();
return cleaned.length > 0 ? cleaned : null;
}
/**
* Re-attach any markers that the original label carried after the user
* edited the visible portion. Currently only `[no-stand]` is preserved.
*/
function preserveFurnitureMarkers(originalLabel: string | null, newDisplay: string | null): string | null {
const hadNoStand = originalLabel?.includes('[no-stand]') ?? false;
if (!hadNoStand) return newDisplay;
const trimmed = (newDisplay ?? '').trim();
return trimmed.length > 0 ? `${trimmed} [no-stand]` : '[no-stand]';
}
function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) {
const { t } = useTranslation();
const displayLabel = stripFurnitureMarkers(item.label);
// Furniture's "default" label for the placeholder is the type code; we
// don't have access to the original FurnitureDef from a placed item.
const labelPlaceholder = item.type;
const handleXChange = useCallback(
(value: string) => {
@@ -613,7 +1046,7 @@ function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) {
const handleRotationChange = useCallback(
(value: string) => {
const num = parseFloat(value);
if (!isNaN(num)) onUpdate({ ...item, rotation: num % 360 });
if (!isNaN(num)) onUpdate({ ...item, rotation: normalizeAngleDegrees(num) });
},
[item, onUpdate],
);
@@ -621,9 +1054,16 @@ function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) {
return (
<div className={styles.section}>
<div className={styles.sectionTitle}>
{item.label ?? item.type}
{displayLabel ?? item.type}
</div>
<PropertyRow label={t('properties.type')} value={item.type} />
<LabelOverrideRow
value={displayLabel}
placeholder={labelPlaceholder}
onChange={(newDisplay) =>
onUpdate({ ...item, label: preserveFurnitureMarkers(item.label, newDisplay) })
}
/>
<EditablePropertyRow label={t('properties.x')} value={String(Math.round(item.x * 1000) / 1000)} unit="m" onCommit={handleXChange} />
<EditablePropertyRow label={t('properties.y')} value={String(Math.round(item.y * 1000) / 1000)} unit="m" onCommit={handleYChange} />
<EditablePropertyRow label={t('properties.width')} value={String(item.width)} unit="m" onCommit={handleWidthChange} />
@@ -631,6 +1071,14 @@ function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) {
<EditablePropertyRow label={t('properties.height')} value={String(item.height)} unit="m" onCommit={handleHeightChange} />
<EditablePropertyRow label={t('properties.elevation')} value={String(Math.round(item.elevationFromFloor * 1000) / 1000)} unit="m" onCommit={handleElevationChange} />
<EditablePropertyRow label={t('properties.rotation')} value={String(Math.round(item.rotation))} unit={"\u00b0"} onCommit={handleRotationChange} />
<PositionAnchorEditor
anchor={item.positionAnchor}
onChange={(positionAnchor) => onUpdate({ ...item, positionAnchor })}
/>
<FurnitureOpacitySlider item={item} />
<FurnitureProjectionToggle item={item} />
{item.type === 'CURTAIN' && <CurtainControls item={item} onUpdate={onUpdate} />}
{item.type === 'BOOKCASE' && <BookcaseControls item={item} onUpdate={onUpdate} />}
{item.type === 'TV' && (
<div className={styles.row}>
<span className={styles.rowLabel}>{t('properties.stand')}</span>
@@ -651,6 +1099,247 @@ function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) {
</label>
</div>
)}
{item.type === 'DIGITAL_PIANO' && (
<div className={styles.row}>
<span className={styles.rowLabel}>{t('properties.stand')}</span>
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer' }}>
<input
type="checkbox"
// Missing value means "default on" — matches the preset default.
checked={(item.metadata?.['hasStand'] as boolean | undefined) ?? true}
onChange={(e) => {
const next = { ...(item.metadata ?? {}), hasStand: e.target.checked };
onUpdate({ ...item, metadata: next });
}}
/>
{t('properties.yes')}
</label>
</div>
)}
</div>
);
}
function FurnitureOpacitySlider({ item }: { readonly item: FurnitureItem }) {
const { t, i18n } = useTranslation();
const { updateFurniture } = useEditor();
const value = item.opacity ?? 1;
const label = i18n.exists('properties.opacity') ? t('properties.opacity') : 'Opacity';
return (
<div className={styles.row}>
<span className={styles.rowLabel}>{label}</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 6, flex: 1, justifyContent: 'flex-end' }}>
<input
type="range"
min={0}
max={1}
step={0.05}
value={value}
onChange={(e) => {
const next = parseFloat(e.target.value);
updateFurniture({ ...item, opacity: Number.isFinite(next) ? next : 1 });
}}
style={{ width: 100 }}
/>
<span style={{ minWidth: 32, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
{Math.round(value * 100)}%
</span>
</span>
</div>
);
}
function FurnitureProjectionToggle({ item }: { readonly item: FurnitureItem }) {
const { t, i18n } = useTranslation();
const { updateFurniture } = useEditor();
const enabled = item.showProjection ?? false;
const label = i18n.exists('properties.showProjection')
? t('properties.showProjection')
: 'Show on wall projection';
return (
<div className={styles.row}>
<span className={styles.rowLabel}>{label}</span>
<label style={{ display: 'flex', alignItems: 'center', gap: 4, cursor: 'pointer' }}>
<input
type="checkbox"
checked={enabled}
onChange={() => updateFurniture({ ...item, showProjection: !enabled })}
/>
</label>
</div>
);
}
// ── Curtain-specific controls ──
//
// Curtains store their state in the item's metadata bag:
// - `leftOpen`, `rightOpen` (0..1) — per-side retraction
// - `openAmount` (legacy symmetric) — still honoured by the helpers as a
// fallback for either side when a per-side key is missing
// - `fabricColor` (hex)
//
// Editing either side writes the explicit per-side key. We also clear the
// legacy `openAmount` on first edit so the new per-side values don't get
// shadowed by a stale symmetric value on subsequent reads.
interface CurtainControlsProps {
readonly item: FurnitureItem;
readonly onUpdate: (item: FurnitureItem) => void;
}
function CurtainControls({ item, onUpdate }: CurtainControlsProps) {
const { t, i18n } = useTranslation();
const leftOpen = getCurtainLeftOpen(item.metadata);
const rightOpen = getCurtainRightOpen(item.metadata);
const fabricColor = getCurtainFabricColor(item.metadata);
const updateMetadata = useCallback(
(patch: Record<string, unknown>) => {
// When a per-side key is being written, drop the legacy symmetric
// `openAmount` so it doesn't keep overriding the new per-side values
// on subsequent reads via the fallback in `curtainMetadata.ts`.
const base = { ...(item.metadata ?? {}) };
if ('leftOpen' in patch || 'rightOpen' in patch) {
delete base['openAmount'];
}
const next = { ...base, ...patch };
onUpdate({ ...item, metadata: next });
},
[item, onUpdate],
);
const leftLabel = i18n.exists('properties.curtainLeftOpen')
? t('properties.curtainLeftOpen')
: 'Left open';
const rightLabel = i18n.exists('properties.curtainRightOpen')
? t('properties.curtainRightOpen')
: 'Right open';
const colorLabel = i18n.exists('properties.curtainFabricColor')
? t('properties.curtainFabricColor')
: 'Fabric color';
return (
<>
<CurtainOpenSlider
label={leftLabel}
value={leftOpen}
onChange={(v) => updateMetadata({ leftOpen: v })}
/>
<CurtainOpenSlider
label={rightLabel}
value={rightOpen}
onChange={(v) => updateMetadata({ rightOpen: v })}
/>
<div className={styles.row}>
<span className={styles.rowLabel}>{colorLabel}</span>
<input
type="color"
value={fabricColor}
onChange={(e) => updateMetadata({ fabricColor: e.target.value })}
style={{ width: 32, height: 24, border: '1px solid var(--color-border)', borderRadius: 4, cursor: 'pointer', padding: 0 }}
/>
</div>
</>
);
}
// ── Bookcase controls ──
//
// A bookcase has two editable per-item properties stored in its
// metadata bag:
// - `shelfRows`: number of storage compartments (integer, 112).
// The 3D mesh draws one more horizontal board than this number
// (top + bottom + internal dividers).
// - `hasBackPanel`: whether the unit has a solid back panel. An
// "open bookshelf" that can double as a room divider omits it.
// Missing values fall back to the legacy behaviour: auto-derive the
// row count from the item's height and always draw the back panel.
interface BookcaseControlsProps {
readonly item: FurnitureItem;
readonly onUpdate: (item: FurnitureItem) => void;
}
function BookcaseControls({ item, onUpdate }: BookcaseControlsProps) {
const { t, i18n } = useTranslation();
const rowsLabel = i18n.exists('properties.shelfRows')
? t('properties.shelfRows')
: 'Shelf rows';
const backPanelLabel = i18n.exists('properties.hasBackPanel')
? t('properties.hasBackPanel')
: 'Back panel';
const metadataRows = item.metadata?.['shelfRows'];
const currentRows =
typeof metadataRows === 'number' && metadataRows >= 1
? Math.round(metadataRows)
: Math.max(2, Math.round(item.height / 0.35));
const metadataHasBack = item.metadata?.['hasBackPanel'];
const hasBack = typeof metadataHasBack === 'boolean' ? metadataHasBack : true;
const updateMetadata = useCallback(
(patch: Record<string, unknown>) => {
const next = { ...(item.metadata ?? {}), ...patch };
onUpdate({ ...item, metadata: next });
},
[item, onUpdate],
);
return (
<>
<EditablePropertyRow
label={rowsLabel}
value={String(currentRows)}
onCommit={(v) => {
const n = parseInt(v, 10);
if (!isNaN(n) && n >= 1 && n <= 12) {
updateMetadata({ shelfRows: n });
}
}}
/>
<div className={styles.row}>
<span className={styles.rowLabel}>{backPanelLabel}</span>
<label style={{ display: 'flex', alignItems: 'center', gap: '4px', cursor: 'pointer' }}>
<input
type="checkbox"
checked={hasBack}
onChange={(e) => updateMetadata({ hasBackPanel: e.target.checked })}
/>
{t('properties.yes')}
</label>
</div>
</>
);
}
interface CurtainOpenSliderProps {
readonly label: string;
readonly value: number;
readonly onChange: (value: number) => void;
}
function CurtainOpenSlider({ label, value, onChange }: CurtainOpenSliderProps) {
return (
<div className={styles.row}>
<span className={styles.rowLabel}>{label}</span>
<span style={{ display: 'flex', alignItems: 'center', gap: 6, flex: 1, justifyContent: 'flex-end' }}>
<input
type="range"
min={0}
max={1}
step={0.05}
value={value}
onChange={(e) => {
const next = parseFloat(e.target.value);
if (Number.isFinite(next)) onChange(next);
}}
style={{ width: 100 }}
/>
<span style={{ minWidth: 32, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
{Math.round(value * 100)}%
</span>
</span>
</div>
);
}