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:
@@ -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, 1–12).
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user