af8b9fe00f
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
254 lines
7.9 KiB
TypeScript
254 lines
7.9 KiB
TypeScript
import { useTranslation } from 'react-i18next';
|
|
import { useEditor } from './context/EditorContext';
|
|
import { useUndoRedo } from './context/UndoRedoContext';
|
|
import type { EditorToolType, AlignmentDirection } from './types';
|
|
import styles from './editor-toolbar.module.css';
|
|
|
|
interface EditorToolbarProps {
|
|
readonly onSave: () => void;
|
|
readonly isSaving: boolean;
|
|
readonly isDirty?: boolean;
|
|
readonly isAutoSaving?: boolean;
|
|
readonly onExport?: () => void;
|
|
readonly onImport?: () => void;
|
|
}
|
|
|
|
interface ToolDef {
|
|
readonly type: EditorToolType;
|
|
readonly labelKey: string;
|
|
readonly shortcut: string;
|
|
readonly icon: string;
|
|
}
|
|
|
|
const TOOLS: readonly ToolDef[] = [
|
|
{ type: 'select', labelKey: 'toolbar.select', shortcut: 'V', icon: '\u25B3' },
|
|
{ type: 'door', labelKey: 'toolbar.door', shortcut: 'D', icon: '\u25A1' },
|
|
{ type: 'window', labelKey: 'toolbar.window', shortcut: 'W', icon: '\u2550' },
|
|
{ type: 'electrical', labelKey: 'toolbar.electrical', shortcut: 'E', icon: '\u26A1' },
|
|
{ type: 'furniture', labelKey: 'toolbar.furniture', shortcut: 'F', icon: '\u{1FA91}' },
|
|
{ type: 'measure', labelKey: 'toolbar.measure', shortcut: 'M', icon: '\u{1F4CF}' },
|
|
{ type: 'annotate', labelKey: 'toolbar.annotate', shortcut: 'T', icon: '\u{1D5A0}' },
|
|
];
|
|
|
|
interface AlignDef {
|
|
readonly alignment: AlignmentDirection;
|
|
readonly label: string;
|
|
readonly titleKey: string;
|
|
readonly icon: string;
|
|
}
|
|
|
|
const ALIGNMENT_BUTTONS: readonly AlignDef[] = [
|
|
{ alignment: 'left', label: 'L', titleKey: 'toolbar.alignLeft', icon: '\u2590' },
|
|
{ alignment: 'center-h', label: 'CH', titleKey: 'toolbar.alignCenterH', icon: '\u2503' },
|
|
{ alignment: 'right', label: 'R', titleKey: 'toolbar.alignRight', icon: '\u258C' },
|
|
{ alignment: 'top', label: 'T', titleKey: 'toolbar.alignTop', icon: '\u2580' },
|
|
{ alignment: 'center-v', label: 'CV', titleKey: 'toolbar.alignCenterV', icon: '\u2501' },
|
|
{ alignment: 'bottom', label: 'B', titleKey: 'toolbar.alignBottom', icon: '\u2584' },
|
|
{ alignment: 'distribute-h', label: 'DH', titleKey: 'toolbar.distributeH', icon: '\u2506' },
|
|
{ alignment: 'distribute-v', label: 'DV', titleKey: 'toolbar.distributeV', icon: '\u2504' },
|
|
];
|
|
|
|
export function EditorToolbar({ onSave, isSaving, onExport, onImport }: EditorToolbarProps) {
|
|
const { t } = useTranslation();
|
|
const { state, setTool, setZoom, dispatch } = useEditor();
|
|
const { undo, redo, canUndo, canRedo } = useUndoRedo();
|
|
|
|
const { activeTool, zoom, gridVisible, snapEnabled, layerVisibility } = state;
|
|
|
|
const zoomPercent = Math.round((zoom / 100) * 100);
|
|
|
|
return (
|
|
<div className={styles.toolbar}>
|
|
{/* Tool buttons */}
|
|
<div className={styles.group}>
|
|
{TOOLS.map((tool) => {
|
|
const label = t(tool.labelKey);
|
|
return (
|
|
<button
|
|
key={tool.type}
|
|
className={[
|
|
styles.toolBtn,
|
|
activeTool === tool.type ? styles.toolBtnActive : '',
|
|
].join(' ')}
|
|
onClick={() => setTool(tool.type)}
|
|
title={`${label} (${tool.shortcut})`}
|
|
>
|
|
<span className={styles.toolIcon}>{tool.icon}</span>
|
|
<span className={styles.toolLabel}>{label}</span>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
<div className={styles.separator} />
|
|
|
|
{/* Undo / Redo */}
|
|
<div className={styles.group}>
|
|
<button
|
|
className={styles.actionBtn}
|
|
onClick={undo}
|
|
disabled={!canUndo}
|
|
title={t('toolbar.undo')}
|
|
>
|
|
↩
|
|
</button>
|
|
<button
|
|
className={styles.actionBtn}
|
|
onClick={redo}
|
|
disabled={!canRedo}
|
|
title={t('toolbar.redo')}
|
|
>
|
|
↪
|
|
</button>
|
|
</div>
|
|
|
|
<div className={styles.separator} />
|
|
|
|
{/* Zoom controls */}
|
|
<div className={styles.group}>
|
|
<button
|
|
className={styles.actionBtn}
|
|
onClick={() => setZoom(zoom / 1.2)}
|
|
title={t('toolbar.zoomOut')}
|
|
>
|
|
−
|
|
</button>
|
|
<span className={styles.zoomLabel}>{zoomPercent}%</span>
|
|
<button
|
|
className={styles.actionBtn}
|
|
onClick={() => setZoom(zoom * 1.2)}
|
|
title={t('toolbar.zoomIn')}
|
|
>
|
|
+
|
|
</button>
|
|
</div>
|
|
|
|
<div className={styles.separator} />
|
|
|
|
{/* Grid + Snap toggles */}
|
|
<div className={styles.group}>
|
|
<button
|
|
className={[
|
|
styles.toggleBtn,
|
|
gridVisible ? styles.toggleBtnActive : '',
|
|
].join(' ')}
|
|
onClick={() => dispatch({ type: 'TOGGLE_GRID' })}
|
|
title={t('toolbar.toggleGrid')}
|
|
>
|
|
{t('toolbar.grid')}
|
|
</button>
|
|
<button
|
|
className={[
|
|
styles.toggleBtn,
|
|
snapEnabled ? styles.toggleBtnActive : '',
|
|
].join(' ')}
|
|
onClick={() => dispatch({ type: 'TOGGLE_SNAP' })}
|
|
title={t('toolbar.toggleSnap')}
|
|
>
|
|
{t('toolbar.snap')}
|
|
</button>
|
|
</div>
|
|
|
|
<div className={styles.separator} />
|
|
|
|
{/* Layer visibility toggles */}
|
|
<div className={styles.group}>
|
|
<button
|
|
className={[
|
|
styles.toggleBtn,
|
|
layerVisibility.walls ? styles.toggleBtnActive : '',
|
|
].join(' ')}
|
|
onClick={() => dispatch({ type: 'TOGGLE_LAYER', layer: 'walls' })}
|
|
title={t('toolbar.toggleWalls')}
|
|
>
|
|
{t('toolbar.walls')}
|
|
</button>
|
|
<button
|
|
className={[
|
|
styles.toggleBtn,
|
|
layerVisibility.electrical ? styles.toggleBtnActive : '',
|
|
].join(' ')}
|
|
onClick={() => dispatch({ type: 'TOGGLE_LAYER', layer: 'electrical' })}
|
|
title={t('toolbar.toggleElectrical')}
|
|
>
|
|
{t('toolbar.elec')}
|
|
</button>
|
|
<button
|
|
className={[
|
|
styles.toggleBtn,
|
|
layerVisibility.furniture ? styles.toggleBtnActive : '',
|
|
].join(' ')}
|
|
onClick={() => dispatch({ type: 'TOGGLE_LAYER', layer: 'furniture' })}
|
|
title={t('toolbar.toggleFurniture')}
|
|
>
|
|
{t('toolbar.furn')}
|
|
</button>
|
|
<button
|
|
className={[
|
|
styles.toggleBtn,
|
|
layerVisibility.measurements ? styles.toggleBtnActive : '',
|
|
].join(' ')}
|
|
onClick={() => dispatch({ type: 'TOGGLE_LAYER', layer: 'measurements' })}
|
|
title={t('toolbar.toggleMeasurements')}
|
|
>
|
|
{t('toolbar.meas')}
|
|
</button>
|
|
</div>
|
|
|
|
{/* Alignment tools — visible when 2+ items selected */}
|
|
{state.selectedIds.size >= 2 && (
|
|
<>
|
|
<div className={styles.separator} />
|
|
<div className={styles.group}>
|
|
{ALIGNMENT_BUTTONS.map((btn) => (
|
|
<button
|
|
key={btn.alignment}
|
|
className={styles.actionBtn}
|
|
onClick={() =>
|
|
dispatch({ type: 'ALIGN_SELECTED', alignment: btn.alignment })
|
|
}
|
|
title={t(btn.titleKey)}
|
|
>
|
|
{btn.icon}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{/* Spacer */}
|
|
<div className={styles.spacer} />
|
|
|
|
{/* Import + Export + Save buttons */}
|
|
<div className={styles.group}>
|
|
{onImport && (
|
|
<button
|
|
className={styles.actionBtn}
|
|
onClick={onImport}
|
|
title={t('toolbar.import')}
|
|
>
|
|
⬆
|
|
</button>
|
|
)}
|
|
{onExport && (
|
|
<button
|
|
className={styles.actionBtn}
|
|
onClick={onExport}
|
|
title={t('toolbar.export')}
|
|
>
|
|
⬇
|
|
</button>
|
|
)}
|
|
<button
|
|
className={styles.saveBtn}
|
|
onClick={onSave}
|
|
disabled={isSaving}
|
|
title={t('toolbar.saveShortcut')}
|
|
>
|
|
{isSaving ? t('editor.saving') : t('editor.save')}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|