Files
house-plan-maker/apps/client/src/components/editor/EditorToolbar.tsx
T
alexei.dolgolyov 521ea5e85b feat: add outlet direction (horizontal/vertical), wall light styles, floor textures, and stretch ceiling
- Add configurable outlet direction (horizontal/vertical) stored in metadata
- Add wall light style variants (classic, pendant-globe, sconce-up, sconce-down)
- Add PBR floor textures including natural oak
- Add stretch ceiling offset support with DB migration
- Add furniture surface texture selection
- Add canvas theme colors utility for dark mode support
- Update projection views with improved rendering
- Add EN and RU translations for all new properties
2026-04-12 20:52:49 +03:00

295 lines
9.4 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, globalFurnitureOpacity } = 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')}
>
&#x21A9;
</button>
<button
className={styles.actionBtn}
onClick={redo}
disabled={!canRedo}
title={t('toolbar.redo')}
>
&#x21AA;
</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')}
>
&#x2212;
</button>
<span className={styles.zoomLabel}>{zoomPercent}%</span>
<button
className={styles.actionBtn}
onClick={() => setZoom(zoom * 1.2)}
title={t('toolbar.zoomIn')}
>
&#x002B;
</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>
<button
className={[
styles.toggleBtn,
layerVisibility.stretchCeiling ? styles.toggleBtnActive : '',
].join(' ')}
onClick={() => dispatch({ type: 'TOGGLE_LAYER', layer: 'stretchCeiling' })}
title={t('toolbar.toggleStretchCeiling')}
>
{t('toolbar.stretchCeiling')}
</button>
<label
title={t('toolbar.furnitureOpacity') ?? 'Furniture opacity'}
style={{
display: 'flex',
alignItems: 'center',
gap: 6,
paddingLeft: 6,
fontSize: 12,
color: 'var(--color-text-secondary)',
}}
>
<span aria-hidden style={{ fontSize: 14 }}></span>
<input
type="range"
min={0}
max={1}
step={0.05}
value={globalFurnitureOpacity}
onChange={(e) => {
const next = parseFloat(e.target.value);
if (Number.isFinite(next)) {
dispatch({ type: 'SET_GLOBAL_FURNITURE_OPACITY', opacity: next });
}
}}
style={{ width: 70 }}
aria-label={t('toolbar.furnitureOpacity') ?? 'Furniture opacity'}
/>
<span style={{ minWidth: 30, textAlign: 'right', fontVariantNumeric: 'tabular-nums' }}>
{Math.round(globalFurnitureOpacity * 100)}%
</span>
</label>
</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')}
>
&#x2B06;
</button>
)}
{onExport && (
<button
className={styles.actionBtn}
onClick={onExport}
title={t('toolbar.export')}
>
&#x2B07;
</button>
)}
<button
className={styles.saveBtn}
onClick={onSave}
disabled={isSaving}
title={t('toolbar.saveShortcut')}
>
{isSaving ? t('editor.saving') : t('editor.save')}
</button>
</div>
</div>
);
}