Files
web-cad/frontend/src/components/PropertiesPanel/PropertiesPanel.tsx
T

486 lines
17 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback, useRef } from 'react';
import type { CADLayer, CADProperties, CADElement } from '../../types/cad.types';
import './PropertiesPanel.css';
// ─── Types (local-only, not duplicated from cad.types.ts) ─────────────────────
interface YjsDocumentLike {
layers: {
forEach: (cb: (layer: CADLayer, id: string) => void) => void;
observe: (cb: () => void) => void;
unobserve: (cb: () => void) => void;
};
elements: {
toArray: () => CADElement[];
observe: (cb: () => void) => void;
unobserve: (cb: () => void) => void;
};
updateElement: (id: string, updates: Partial<CADElement>) => void;
getElementById: (id: string) => CADElement | undefined;
}
interface FabricObjectLike {
id?: string;
type?: string;
left?: number;
top?: number;
width?: number;
height?: number;
angle?: number;
fill?: string | null;
stroke?: string | null;
strokeWidth?: number;
strokeDashArray?: number[];
set: (props: Record<string, unknown>) => void;
setCoords: () => void;
}
interface FabricCanvasLike {
getActiveObject: () => FabricObjectLike | null;
on: (event: string, handler: (...args: unknown[]) => void) => void;
off: (event: string, handler: (...args: unknown[]) => void) => void;
renderAll: () => void;
fire: (event: string, payload?: unknown) => void;
}
interface CanvasRefLike {
getCanvas?: () => FabricCanvasLike;
getSelectedElements?: () => FabricObjectLike[];
}
interface PropertiesPanelProps {
canvasRef: React.RefObject<CanvasRefLike | null>;
yjsDocRef: React.RefObject<YjsDocumentLike | null>;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
const LINE_TYPE_DASH_MAP: Record<string, number[] | null> = {
solid: null,
dashed: [8, 4],
dotted: [2, 3],
};
function dashArrayToLineType(dashArray?: number[] | null): 'solid' | 'dashed' | 'dotted' {
if (!dashArray || dashArray.length === 0) return 'solid';
if (dashArray[0] <= 2) return 'dotted';
return 'dashed';
}
function clampNumber(value: string, min: number, max: number): number {
const n = parseFloat(value);
if (Number.isNaN(n)) return min;
return Math.min(Math.max(n, min), max);
}
// ─── Component ───────────────────────────────────────────────────────────────
const PropertiesPanel: React.FC<PropertiesPanelProps> = ({ canvasRef, yjsDocRef }) => {
const [selectedElement, setSelectedElement] = useState<CADElement | null>(null);
const [layers, setLayers] = useState<CADLayer[]>([]);
const [fabricObject, setFabricObject] = useState<FabricObjectLike | null>(null);
const fabricCanvasRef = useRef<FabricCanvasLike | null>(null);
// Debounce timer for Yjs updates (300ms) — no new dependency needed
const yjsDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Clear debounce timer on unmount
useEffect(() => {
return () => {
if (yjsDebounceRef.current) {
clearTimeout(yjsDebounceRef.current);
}
};
}, []);
// ── Sync layers from YjsDocument ──────────────────────────────────────────
useEffect(() => {
const yjsDoc = yjsDocRef?.current;
if (!yjsDoc) return;
const syncLayers = () => {
const arr: CADLayer[] = [];
yjsDoc.layers.forEach((layer, id) => {
arr.push({ ...layer, id });
});
arr.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
setLayers(arr);
};
syncLayers();
yjsDoc.layers.observe(syncLayers);
return () => yjsDoc.layers.unobserve(syncLayers);
}, [yjsDocRef]);
// ── Sync elements from YjsDocument (for external updates) ─────────────────
useEffect(() => {
const yjsDoc = yjsDocRef?.current;
if (!yjsDoc) return;
const syncElements = () => {
if (selectedElement) {
const updated = yjsDoc.getElementById(selectedElement.id);
if (updated) {
setSelectedElement(updated);
}
}
};
yjsDoc.elements.observe(syncElements);
return () => yjsDoc.elements.unobserve(syncElements);
}, [yjsDocRef, selectedElement]);
// ── Wire fabric selection events ──────────────────────────────────────────
useEffect(() => {
const ref = canvasRef?.current;
if (!ref || !ref.getCanvas) return;
const fabricCanvas = ref.getCanvas();
if (!fabricCanvas) return;
fabricCanvasRef.current = fabricCanvas;
const handleSelection = () => {
const active = fabricCanvas.getActiveObject();
if (!active) {
setSelectedElement(null);
setFabricObject(null);
return;
}
setFabricObject(active);
// Try to match with YjsDocument element by id
const yjsDoc = yjsDocRef?.current;
const elementId = (active as FabricObjectLike).id;
if (elementId && yjsDoc) {
const el = yjsDoc.getElementById(elementId);
if (el) {
setSelectedElement(el);
return;
}
}
// Fallback: build a transient element from fabric properties
setSelectedElement({
id: (active as FabricObjectLike).id ?? 'transient',
type: (active as FabricObjectLike).type ?? 'unknown',
layerId: 'default',
x: active.left ?? 0,
y: active.top ?? 0,
width: active.width ?? 0,
height: active.height ?? 0,
properties: {
fill: (active.fill as string) ?? 'transparent',
stroke: active.stroke ?? '#000000',
strokeWidth: active.strokeWidth ?? 1,
rotation: active.angle ?? 0,
lineType: dashArrayToLineType(active.strokeDashArray),
},
});
};
const handleCleared = () => {
setSelectedElement(null);
setFabricObject(null);
};
fabricCanvas.on('selection:created', handleSelection);
fabricCanvas.on('selection:updated', handleSelection);
fabricCanvas.on('selection:cleared', handleCleared);
return () => {
fabricCanvas.off('selection:created', handleSelection);
fabricCanvas.off('selection:updated', handleSelection);
fabricCanvas.off('selection:cleared', handleCleared);
};
}, [canvasRef, yjsDocRef]);
// ── Update handlers (Panel → Canvas + YjsDocument) ──────────────────────────
const updateFabricAndYjs = useCallback(
(updates: Partial<CADElement> & { properties?: Partial<CADProperties> }) => {
if (!selectedElement) return;
// Debounce Yjs write (300ms) — only write after user pauses typing
if (yjsDebounceRef.current) {
clearTimeout(yjsDebounceRef.current);
}
const elId = selectedElement.id;
yjsDebounceRef.current = setTimeout(() => {
const yjsDoc = yjsDocRef?.current;
if (yjsDoc) {
yjsDoc.updateElement(elId, updates);
}
yjsDebounceRef.current = null;
}, 300);
// Update fabric object directly (immediate, no debounce)
const fabricObj = fabricObject;
const fabricCanvas = fabricCanvasRef.current;
if (fabricObj && fabricCanvas) {
if (updates.x !== undefined) fabricObj.set({ left: updates.x });
if (updates.y !== undefined) fabricObj.set({ top: updates.y });
if (updates.width !== undefined) fabricObj.set({ width: updates.width });
if (updates.height !== undefined) fabricObj.set({ height: updates.height });
if (updates.properties?.rotation !== undefined)
fabricObj.set({ angle: updates.properties.rotation });
if (updates.properties?.fill !== undefined)
fabricObj.set({ fill: updates.properties.fill });
if (updates.properties?.stroke !== undefined)
fabricObj.set({ stroke: updates.properties.stroke });
if (updates.properties?.strokeWidth !== undefined)
fabricObj.set({ strokeWidth: updates.properties.strokeWidth });
if (updates.properties?.lineType !== undefined) {
const dash = LINE_TYPE_DASH_MAP[updates.properties.lineType];
fabricObj.set({ strokeDashArray: dash });
}
fabricObj.setCoords();
fabricCanvas.renderAll();
}
// Update local state (immediate, no debounce)
setSelectedElement((prev) => {
if (!prev) return prev;
return {
...prev,
...updates,
properties: { ...prev.properties, ...updates.properties },
};
});
},
[selectedElement, fabricObject, yjsDocRef]
);
// ── Field change handlers ──────────────────────────────────────────────────
const handleNumberChange = (field: 'x' | 'y' | 'width' | 'height', value: string) => {
const n = clampNumber(value, -100000, 100000);
updateFabricAndYjs({ [field]: n });
};
const handleRotationChange = (value: string) => {
const n = clampNumber(value, -360, 360);
updateFabricAndYjs({ properties: { rotation: n } });
};
const handleFillChange = (value: string) => {
updateFabricAndYjs({ properties: { fill: value } });
};
const handleStrokeChange = (value: string) => {
updateFabricAndYjs({ properties: { stroke: value } });
};
const handleStrokeWidthChange = (value: string) => {
const n = clampNumber(value, 0, 100);
updateFabricAndYjs({ properties: { strokeWidth: n } });
};
const handleLineTypeChange = (value: 'solid' | 'dashed' | 'dotted') => {
updateFabricAndYjs({ properties: { lineType: value } });
};
const handleLayerChange = (value: string) => {
updateFabricAndYjs({ layerId: value });
};
// ── Render ──────────────────────────────────────────────────────────────────
if (!selectedElement) {
return (
<div className="properties-panel-empty">
<div className="properties-panel-empty-icon" aria-hidden="true"></div>
<p className="properties-panel-empty-text">
Kein Element ausgewählt. Wähle ein Element auf dem Canvas, um seine Eigenschaften zu bearbeiten.
</p>
</div>
);
}
const props = selectedElement.properties;
const fillValue = props.fill ?? 'transparent';
const strokeValue = props.stroke ?? '#000000';
const strokeWidthValue = props.strokeWidth ?? 1;
const rotationValue = props.rotation ?? 0;
const lineTypeValue = props.lineType ?? 'solid';
return (
<div className="properties-panel" role="form" aria-label="Element Properties">
{/* Element info header */}
<div className="properties-panel-header">
<span className="properties-panel-type-badge">{selectedElement.type}</span>
<span className="properties-panel-id">ID: {selectedElement.id.substring(0, 12)}</span>
</div>
{/* Position section */}
<fieldset className="properties-panel-fieldset">
<legend>Position</legend>
<div className="properties-panel-row">
<label className="properties-panel-label" htmlFor="prop-x">
X
<input
id="prop-x"
type="number"
className="properties-panel-input"
value={Math.round(selectedElement.x * 100) / 100}
onChange={(e) => handleNumberChange('x', e.target.value)}
step="1"
/>
</label>
<label className="properties-panel-label" htmlFor="prop-y">
Y
<input
id="prop-y"
type="number"
className="properties-panel-input"
value={Math.round(selectedElement.y * 100) / 100}
onChange={(e) => handleNumberChange('y', e.target.value)}
step="1"
/>
</label>
</div>
</fieldset>
{/* Size section */}
<fieldset className="properties-panel-fieldset">
<legend>Größe</legend>
<div className="properties-panel-row">
<label className="properties-panel-label" htmlFor="prop-width">
Breite
<input
id="prop-width"
type="number"
className="properties-panel-input"
value={Math.round(selectedElement.width * 100) / 100}
onChange={(e) => handleNumberChange('width', e.target.value)}
step="1"
min="0"
/>
</label>
<label className="properties-panel-label" htmlFor="prop-height">
Höhe
<input
id="prop-height"
type="number"
className="properties-panel-input"
value={Math.round(selectedElement.height * 100) / 100}
onChange={(e) => handleNumberChange('height', e.target.value)}
step="1"
min="0"
/>
</label>
</div>
</fieldset>
{/* Rotation section */}
<fieldset className="properties-panel-fieldset">
<legend>Rotation</legend>
<label className="properties-panel-label" htmlFor="prop-rotation">
Winkel (Grad)
<input
id="prop-rotation"
type="number"
className="properties-panel-input"
value={Math.round(rotationValue * 100) / 100}
onChange={(e) => handleRotationChange(e.target.value)}
step="1"
min="-360"
max="360"
/>
</label>
</fieldset>
{/* Appearance section */}
<fieldset className="properties-panel-fieldset">
<legend>Erscheinungsbild</legend>
<label className="properties-panel-label" htmlFor="prop-fill">
Füllfarbe
<input
id="prop-fill"
type="color"
className="properties-panel-color-input"
value={fillValue === 'transparent' ? '#ffffff' : fillValue}
onChange={(e) => handleFillChange(e.target.value)}
/>
<input
type="text"
className="properties-panel-text-input"
value={fillValue}
onChange={(e) => handleFillChange(e.target.value)}
placeholder="#RRGGBB oder transparent"
/>
</label>
<label className="properties-panel-label" htmlFor="prop-stroke">
Linienfarbe
<input
id="prop-stroke"
type="color"
className="properties-panel-color-input"
value={strokeValue}
onChange={(e) => handleStrokeChange(e.target.value)}
/>
<input
type="text"
className="properties-panel-text-input"
value={strokeValue}
onChange={(e) => handleStrokeChange(e.target.value)}
placeholder="#RRGGBB"
/>
</label>
<label className="properties-panel-label" htmlFor="prop-stroke-width">
Linienstärke
<input
id="prop-stroke-width"
type="number"
className="properties-panel-input"
value={strokeWidthValue}
onChange={(e) => handleStrokeWidthChange(e.target.value)}
step="0.5"
min="0"
/>
</label>
<label className="properties-panel-label" htmlFor="prop-line-type">
Linientyp
<select
id="prop-line-type"
className="properties-panel-select"
value={lineTypeValue}
onChange={(e) => handleLineTypeChange(e.target.value as 'solid' | 'dashed' | 'dotted')}
>
<option value="solid">Solid</option>
<option value="dashed">Dashed</option>
<option value="dotted">Dotted</option>
</select>
</label>
</fieldset>
{/* Layer assignment section */}
<fieldset className="properties-panel-fieldset">
<legend>Layer</legend>
<label className="properties-panel-label" htmlFor="prop-layer">
Zugewiesener Layer
<select
id="prop-layer"
className="properties-panel-select"
value={selectedElement.layerId}
onChange={(e) => handleLayerChange(e.target.value)}
>
{layers.length === 0 ? (
<option value="default">Default</option>
) : (
layers.map((layer) => (
<option key={layer.id} value={layer.id}>
{layer.name}{layer.locked ? ' (gesperrt)' : ''}
</option>
))
)}
</select>
</label>
</fieldset>
</div>
);
};
export default PropertiesPanel;