feat: complete house plan maker application
Full-featured house/apartment floor plan editor with: - Turborepo monorepo (React/Vite client, Fastify/Prisma server, shared Zod schemas) - 2D room editor with walls, doors, windows, furniture, electrical elements - 3D room preview with Three.js (auto-hide nearest walls, bird's eye default) - Wall projection views with interactive drag (elevation, position) - Apartment floor plan view with room positioning - Copy/paste, alignment tools, measurement tool, annotations - Item-attached annotations with leader lines (visible on projections) - Door open direction (LEFT/RIGHT/INWARD/OUTWARD) with swing arc - Floor type textures (wood, tile, concrete, laminate, herringbone) - Wall color picker for 3D view - Furniture: bed, desk, wardrobe, sofa, table, chair, shelf, nightstand, dresser, bookcase, TV (with stand toggle), AC unit - Furniture elevation support (wall-mounted items) - Auto-save with dirty state tracking, batch save API - Rotation-aware collision detection (SAT/OBB) with 3D elevation check - Rotation-aware hit testing - i18n (English/Russian) with locale-aware number formatting - Dark mode with system preference detection - Undo/redo, keyboard shortcuts, scale bar - PDF/PNG/JSON export and JSON import - Focus trap modal, toast notifications, tooltips - Responsive layout with overlay palettes
This commit is contained in:
@@ -0,0 +1,664 @@
|
||||
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 { useEditor } from './context/EditorContext';
|
||||
import { useUndoRedo } from './context/UndoRedoContext';
|
||||
import { wallLength } from './utils/wallUtils';
|
||||
import { polygonArea, polygonPerimeter, generateLocalId } from './utils/geometry';
|
||||
import { getElectricalVariant, ELECTRICAL_SYMBOL_DEFS } from './symbols/electrical';
|
||||
import type { EditorCommand } from './types';
|
||||
import styles from './properties-panel.module.css';
|
||||
|
||||
export function PropertiesPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { state, dispatch, updateOpening, updateElectrical, updateFurniture, updateWall, addAnnotation } = useEditor();
|
||||
const { execute } = useUndoRedo();
|
||||
const { selectedIds, walls, openings, electricalItems, furnitureItems, room } = state;
|
||||
|
||||
const roomArea = useMemo(
|
||||
() => room.shape.length >= 3 ? polygonArea(room.shape) : 0,
|
||||
[room.shape],
|
||||
);
|
||||
const roomPerimeter = useMemo(
|
||||
() => room.shape.length >= 2 ? polygonPerimeter(room.shape) : 0,
|
||||
[room.shape],
|
||||
);
|
||||
|
||||
// Find selected elements
|
||||
const selected = useMemo(() => {
|
||||
const items: {
|
||||
type: 'wall' | 'opening' | 'electrical' | 'furniture';
|
||||
data: Wall | WallOpening | ElectricalItem | FurnitureItem;
|
||||
}[] = [];
|
||||
|
||||
for (const id of selectedIds) {
|
||||
const wall = walls.find((w) => w.id === id);
|
||||
if (wall) {
|
||||
items.push({ type: 'wall', data: wall });
|
||||
continue;
|
||||
}
|
||||
const opening = openings.find((o) => o.id === id);
|
||||
if (opening) {
|
||||
items.push({ type: 'opening', data: opening });
|
||||
continue;
|
||||
}
|
||||
const elec = electricalItems.find((e) => e.id === id);
|
||||
if (elec) {
|
||||
items.push({ type: 'electrical', data: elec });
|
||||
continue;
|
||||
}
|
||||
const furn = furnitureItems.find((f) => f.id === id);
|
||||
if (furn) {
|
||||
items.push({ type: 'furniture', data: furn });
|
||||
}
|
||||
}
|
||||
|
||||
return items;
|
||||
}, [selectedIds, walls, openings, electricalItems, furnitureItems]);
|
||||
|
||||
if (selected.length === 0) {
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.header}>{t('properties.title')}</div>
|
||||
<div className={styles.empty}>
|
||||
<p className={styles.emptyText}>{t('properties.noSelection')}</p>
|
||||
<p className={styles.emptyHint}>{t('properties.selectHint')}</p>
|
||||
</div>
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>{t('properties.roomInfo')}</div>
|
||||
<PropertyRow label={t('properties.name')} value={room.name} />
|
||||
{roomArea > 0 && (
|
||||
<PropertyRow label={t('properties.area')} value={`${(Math.round(roomArea * 100) / 100).toFixed(2)} m\u00B2`} />
|
||||
)}
|
||||
{roomPerimeter > 0 && (
|
||||
<PropertyRow label={t('properties.perimeter')} value={`${(Math.round(roomPerimeter * 100) / 100).toFixed(2)} m`} />
|
||||
)}
|
||||
<PropertyRow label={t('properties.wallHeight')} value={`${room.wallHeight}m`} />
|
||||
<PropertyRow label={t('properties.plinthHeight')} value={`${Math.round(room.plinthHeight * 1000) / 10}cm`} />
|
||||
<SelectPropertyRow<FloorType>
|
||||
label={t('properties.floorType')}
|
||||
value={room.floorType}
|
||||
options={FLOOR_TYPES.map((ft) => ({
|
||||
value: ft,
|
||||
label: t(`floor.${ft}`),
|
||||
}))}
|
||||
onChange={(v) => dispatch({ type: 'UPDATE_ROOM_PROPS', props: { floorType: v } })}
|
||||
/>
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowLabel}>{t('properties.wallColor')}</span>
|
||||
<input
|
||||
type="color"
|
||||
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 }}
|
||||
/>
|
||||
</div>
|
||||
<PropertyRow label={t('properties.walls')} value={String(walls.length)} />
|
||||
<PropertyRow label={t('properties.openings')} value={String(openings.length)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (selected.length > 1) {
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.header}>{t('properties.title')}</div>
|
||||
<div className={styles.empty}>
|
||||
<p className={styles.emptyText}>{t('properties.multipleSelected', { count: selected.length })}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const item = selected[0];
|
||||
|
||||
return (
|
||||
<div className={styles.panel}>
|
||||
<div className={styles.header}>{t('properties.title')}</div>
|
||||
{item.type === 'wall' && (
|
||||
<WallProperties
|
||||
wall={item.data as Wall}
|
||||
onUpdate={(updated) => {
|
||||
const original = item.data as Wall;
|
||||
const cmd: EditorCommand = {
|
||||
description: 'Update wall thickness',
|
||||
execute: () => updateWall(updated),
|
||||
undo: () => updateWall(original),
|
||||
};
|
||||
execute(cmd);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{item.type === 'opening' && (
|
||||
<OpeningProperties
|
||||
opening={item.data as WallOpening}
|
||||
walls={walls}
|
||||
onUpdate={(updated) => {
|
||||
const original = item.data as WallOpening;
|
||||
const cmd: EditorCommand = {
|
||||
description: `Update ${updated.type.toLowerCase()}`,
|
||||
execute: () => updateOpening(updated),
|
||||
undo: () => updateOpening(original),
|
||||
};
|
||||
execute(cmd);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{item.type === 'electrical' && (
|
||||
<ElectricalProperties
|
||||
item={item.data as ElectricalItem}
|
||||
onUpdate={(updated) => {
|
||||
const original = item.data as ElectricalItem;
|
||||
const cmd: EditorCommand = {
|
||||
description: 'Update electrical item',
|
||||
execute: () => updateElectrical(updated),
|
||||
undo: () => updateElectrical(original),
|
||||
};
|
||||
execute(cmd);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{item.type === 'furniture' && (
|
||||
<FurnitureProperties
|
||||
item={item.data as FurnitureItem}
|
||||
onUpdate={(updated) => {
|
||||
const original = item.data as FurnitureItem;
|
||||
const cmd: EditorCommand = {
|
||||
description: 'Update furniture item',
|
||||
execute: () => updateFurniture(updated),
|
||||
undo: () => updateFurniture(original),
|
||||
};
|
||||
execute(cmd);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{/* Add note button for any item */}
|
||||
{(item.type === 'electrical' || item.type === 'furniture') && (
|
||||
<div style={{ padding: '4px 8px' }}>
|
||||
<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,
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t('properties.addNote')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Wall Properties ──
|
||||
|
||||
interface WallPropertiesProps {
|
||||
readonly wall: Wall;
|
||||
readonly onUpdate: (wall: Wall) => void;
|
||||
}
|
||||
|
||||
function WallProperties({ wall, onUpdate }: WallPropertiesProps) {
|
||||
const { t } = useTranslation();
|
||||
const len = wallLength(wall);
|
||||
|
||||
const handleThicknessChange = useCallback(
|
||||
(value: string) => {
|
||||
const cm = parseFloat(value);
|
||||
if (!isNaN(cm) && cm > 0 && cm <= 100) {
|
||||
onUpdate({ ...wall, thickness: cm / 100 });
|
||||
}
|
||||
},
|
||||
[wall, onUpdate],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>{t('properties.wall')}</div>
|
||||
<PropertyRow label={t('properties.length')} value={formatM(len)} />
|
||||
<EditablePropertyRow
|
||||
label={t('properties.thickness')}
|
||||
value={String(Math.round(wall.thickness * 100))}
|
||||
unit="cm"
|
||||
onCommit={handleThicknessChange}
|
||||
/>
|
||||
<PropertyRow label={t('properties.startX')} value={formatM(wall.startX)} />
|
||||
<PropertyRow label={t('properties.startY')} value={formatM(wall.startY)} />
|
||||
<PropertyRow label={t('properties.endX')} value={formatM(wall.endX)} />
|
||||
<PropertyRow label={t('properties.endY')} value={formatM(wall.endY)} />
|
||||
<PropertyRow label={t('properties.direction')} value={wall.direction} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Opening Properties ──
|
||||
|
||||
interface OpeningPropertiesProps {
|
||||
readonly opening: WallOpening;
|
||||
readonly walls: readonly Wall[];
|
||||
readonly onUpdate: (opening: WallOpening) => void;
|
||||
}
|
||||
|
||||
function OpeningProperties({ opening, walls, onUpdate }: OpeningPropertiesProps) {
|
||||
const { t } = useTranslation();
|
||||
const wall = walls.find((w) => w.id === opening.wallId);
|
||||
const wLen = wall ? wallLength(wall) : 0;
|
||||
|
||||
const handleWidthChange = useCallback(
|
||||
(value: string) => {
|
||||
const num = parseFloat(value);
|
||||
if (!isNaN(num) && num > 0 && num < wLen) {
|
||||
onUpdate({ ...opening, width: num });
|
||||
}
|
||||
},
|
||||
[opening, onUpdate, wLen],
|
||||
);
|
||||
|
||||
const handleHeightChange = useCallback(
|
||||
(value: string) => {
|
||||
const num = parseFloat(value);
|
||||
if (!isNaN(num) && num > 0) {
|
||||
onUpdate({ ...opening, height: num });
|
||||
}
|
||||
},
|
||||
[opening, onUpdate],
|
||||
);
|
||||
|
||||
// Position displayed as left edge offset, stored as center
|
||||
const displayPosition = Math.round((opening.positionAlongWall - opening.width / 2) * 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) {
|
||||
onUpdate({ ...opening, positionAlongWall: centerPos });
|
||||
}
|
||||
}
|
||||
},
|
||||
[opening, onUpdate, wLen],
|
||||
);
|
||||
|
||||
const handleElevationChange = useCallback(
|
||||
(value: string) => {
|
||||
const num = parseFloat(value);
|
||||
if (!isNaN(num) && num >= 0) {
|
||||
onUpdate({ ...opening, elevationFromFloor: num });
|
||||
}
|
||||
},
|
||||
[opening, onUpdate],
|
||||
);
|
||||
|
||||
const handleOpenDirectionChange = useCallback(
|
||||
(direction: DoorOpenDirection) => {
|
||||
onUpdate({ ...opening, openDirection: direction });
|
||||
},
|
||||
[opening, onUpdate],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>
|
||||
{opening.type === 'DOOR' ? t('properties.door') : t('properties.window')}
|
||||
</div>
|
||||
<EditablePropertyRow
|
||||
label={t('properties.width')}
|
||||
value={String(opening.width)}
|
||||
unit="m"
|
||||
onCommit={handleWidthChange}
|
||||
/>
|
||||
<EditablePropertyRow
|
||||
label={t('properties.height')}
|
||||
value={String(opening.height)}
|
||||
unit="m"
|
||||
onCommit={handleHeightChange}
|
||||
/>
|
||||
<EditablePropertyRow
|
||||
label={t('properties.position')}
|
||||
value={String(Math.max(0, displayPosition))}
|
||||
unit="m"
|
||||
onCommit={handlePositionChange}
|
||||
/>
|
||||
{opening.type === 'DOOR' && (
|
||||
<SelectPropertyRow
|
||||
label={t('properties.openDirection')}
|
||||
value={opening.openDirection}
|
||||
options={DOOR_OPEN_DIRECTIONS.map((dir) => ({
|
||||
value: dir,
|
||||
label: t(`properties.openDir.${dir}`),
|
||||
}))}
|
||||
onChange={handleOpenDirectionChange}
|
||||
/>
|
||||
)}
|
||||
{opening.type === 'WINDOW' && (
|
||||
<EditablePropertyRow
|
||||
label={t('properties.elevation')}
|
||||
value={String(opening.elevationFromFloor)}
|
||||
unit="m"
|
||||
onCommit={handleElevationChange}
|
||||
/>
|
||||
)}
|
||||
{wall && (
|
||||
<PropertyRow label={t('properties.wallLength')} value={formatM(wLen)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Property Row Components ──
|
||||
|
||||
interface PropertyRowProps {
|
||||
readonly label: string;
|
||||
readonly value: string;
|
||||
}
|
||||
|
||||
function PropertyRow({ label, value }: PropertyRowProps) {
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowLabel}>{label}</span>
|
||||
<span className={styles.rowValue}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface EditablePropertyRowProps {
|
||||
readonly label: string;
|
||||
readonly value: string;
|
||||
readonly unit?: string;
|
||||
readonly onCommit: (value: string) => void;
|
||||
}
|
||||
|
||||
function EditablePropertyRow({ label, value, unit, onCommit }: EditablePropertyRowProps) {
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [draft, setDraft] = useState(value);
|
||||
|
||||
const handleBlur = useCallback(() => {
|
||||
setEditing(false);
|
||||
if (draft !== value) {
|
||||
onCommit(draft);
|
||||
}
|
||||
}, [draft, value, onCommit]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter') {
|
||||
handleBlur();
|
||||
} else if (e.key === 'Escape') {
|
||||
setDraft(value);
|
||||
setEditing(false);
|
||||
}
|
||||
},
|
||||
[handleBlur, value],
|
||||
);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowLabel}>{label}</span>
|
||||
<div className={styles.editRow}>
|
||||
<input
|
||||
className={styles.editInput}
|
||||
type="text"
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onBlur={handleBlur}
|
||||
onKeyDown={handleKeyDown}
|
||||
autoFocus
|
||||
/>
|
||||
{unit && <span className={styles.editUnit}>{unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowLabel}>{label}</span>
|
||||
<button
|
||||
className={styles.editableValue}
|
||||
onClick={() => {
|
||||
setDraft(value);
|
||||
setEditing(true);
|
||||
}}
|
||||
>
|
||||
{value}{unit ? ` ${unit}` : ''}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Select Property Row ──
|
||||
|
||||
interface SelectPropertyRowProps<T extends string> {
|
||||
readonly label: string;
|
||||
readonly value: T;
|
||||
readonly options: readonly { readonly value: T; readonly label: string }[];
|
||||
readonly onChange: (value: T) => void;
|
||||
}
|
||||
|
||||
function SelectPropertyRow<T extends string>({ label, value, options, onChange }: SelectPropertyRowProps<T>) {
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<span className={styles.rowLabel}>{label}</span>
|
||||
<select
|
||||
className={styles.selectInput}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value as T)}
|
||||
>
|
||||
{options.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Electrical Properties ──
|
||||
|
||||
interface ElectricalPropertiesProps {
|
||||
readonly item: ElectricalItem;
|
||||
readonly onUpdate: (item: ElectricalItem) => void;
|
||||
}
|
||||
|
||||
function ElectricalProperties({ item, onUpdate }: ElectricalPropertiesProps) {
|
||||
const { t } = useTranslation();
|
||||
const variant = getElectricalVariant(item.metadata);
|
||||
const def = ELECTRICAL_SYMBOL_DEFS.find(
|
||||
(d) => d.type === item.type && (d.variant ?? 'single') === variant,
|
||||
);
|
||||
|
||||
const isWallMounted = item.wallId !== null;
|
||||
|
||||
const handleXChange = useCallback(
|
||||
(value: string) => {
|
||||
const num = parseFloat(value);
|
||||
if (!isNaN(num)) onUpdate({ ...item, x: num });
|
||||
},
|
||||
[item, onUpdate],
|
||||
);
|
||||
|
||||
const handleYChange = useCallback(
|
||||
(value: string) => {
|
||||
const num = parseFloat(value);
|
||||
if (!isNaN(num)) onUpdate({ ...item, y: num });
|
||||
},
|
||||
[item, onUpdate],
|
||||
);
|
||||
|
||||
const handleRotationChange = useCallback(
|
||||
(value: string) => {
|
||||
const num = parseFloat(value);
|
||||
if (!isNaN(num)) onUpdate({ ...item, rotation: num % 360 });
|
||||
},
|
||||
[item, onUpdate],
|
||||
);
|
||||
|
||||
const handleElevationChange = useCallback(
|
||||
(value: string) => {
|
||||
const num = parseFloat(value);
|
||||
if (!isNaN(num) && num >= 0) {
|
||||
onUpdate({ ...item, elevationFromFloor: num });
|
||||
}
|
||||
},
|
||||
[item, onUpdate],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>
|
||||
{def?.label ?? item.type}
|
||||
</div>
|
||||
<PropertyRow label={t('properties.type')} value={item.type} />
|
||||
{variant !== 'single' && <PropertyRow label={t('properties.variant')} value={variant} />}
|
||||
<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} />
|
||||
{isWallMounted && (
|
||||
<>
|
||||
<PropertyRow label={t('properties.wallMounted')} value={t('properties.yes')} />
|
||||
<EditablePropertyRow
|
||||
label={t('properties.elevation')}
|
||||
value={String(Math.round((item.elevationFromFloor ?? 0) * 1000) / 1000)}
|
||||
unit="m"
|
||||
onCommit={handleElevationChange}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Furniture Properties ──
|
||||
|
||||
interface FurniturePropertiesProps {
|
||||
readonly item: FurnitureItem;
|
||||
readonly onUpdate: (item: FurnitureItem) => void;
|
||||
}
|
||||
|
||||
function FurnitureProperties({ item, onUpdate }: FurniturePropertiesProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleXChange = useCallback(
|
||||
(value: string) => {
|
||||
const num = parseFloat(value);
|
||||
if (!isNaN(num)) onUpdate({ ...item, x: num });
|
||||
},
|
||||
[item, onUpdate],
|
||||
);
|
||||
|
||||
const handleYChange = useCallback(
|
||||
(value: string) => {
|
||||
const num = parseFloat(value);
|
||||
if (!isNaN(num)) onUpdate({ ...item, y: num });
|
||||
},
|
||||
[item, onUpdate],
|
||||
);
|
||||
|
||||
const handleWidthChange = useCallback(
|
||||
(value: string) => {
|
||||
const num = parseFloat(value);
|
||||
if (!isNaN(num) && num > 0) onUpdate({ ...item, width: num });
|
||||
},
|
||||
[item, onUpdate],
|
||||
);
|
||||
|
||||
const handleDepthChange = useCallback(
|
||||
(value: string) => {
|
||||
const num = parseFloat(value);
|
||||
if (!isNaN(num) && num > 0) onUpdate({ ...item, depth: num });
|
||||
},
|
||||
[item, onUpdate],
|
||||
);
|
||||
|
||||
const handleHeightChange = useCallback(
|
||||
(value: string) => {
|
||||
const num = parseFloat(value);
|
||||
if (!isNaN(num) && num > 0) onUpdate({ ...item, height: num });
|
||||
},
|
||||
[item, onUpdate],
|
||||
);
|
||||
|
||||
const handleElevationChange = useCallback(
|
||||
(value: string) => {
|
||||
const num = parseFloat(value);
|
||||
if (!isNaN(num) && num >= 0) onUpdate({ ...item, elevationFromFloor: num });
|
||||
},
|
||||
[item, onUpdate],
|
||||
);
|
||||
|
||||
const handleRotationChange = useCallback(
|
||||
(value: string) => {
|
||||
const num = parseFloat(value);
|
||||
if (!isNaN(num)) onUpdate({ ...item, rotation: num % 360 });
|
||||
},
|
||||
[item, onUpdate],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionTitle}>
|
||||
{item.label ?? item.type}
|
||||
</div>
|
||||
<PropertyRow label={t('properties.type')} value={item.type} />
|
||||
<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} />
|
||||
<EditablePropertyRow label={t('properties.depth')} value={String(item.depth)} unit="m" onCommit={handleDepthChange} />
|
||||
<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} />
|
||||
{item.type === 'TV' && (
|
||||
<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"
|
||||
checked={!item.label?.includes('[no-stand]')}
|
||||
onChange={(e) => {
|
||||
const hasStand = e.target.checked;
|
||||
const cleanLabel = (item.label ?? '').replace('[no-stand]', '').trim();
|
||||
onUpdate({
|
||||
...item,
|
||||
label: hasStand ? (cleanLabel || null) : `${cleanLabel} [no-stand]`.trim(),
|
||||
});
|
||||
}}
|
||||
/>
|
||||
{t('properties.yes')}
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatM(meters: number): string {
|
||||
return `${Math.round(meters * 1000) / 1000}m`;
|
||||
}
|
||||
|
||||
function formatCm(meters: number): string {
|
||||
return `${Math.round(meters * 100)}cm`;
|
||||
}
|
||||
Reference in New Issue
Block a user