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

498 lines
17 KiB
TypeScript
Raw Normal View History

2026-06-22 23:05:27 +00:00
import React, { useState, useEffect, useCallback, useRef } from 'react';
import './PropertiesPanel.css';
// ─── Types ───────────────────────────────────────────────────────────────────
interface CADLayer {
id: string;
name: string;
visible: boolean;
locked: boolean;
color?: string;
lineType?: string;
transparency?: number;
sortOrder?: number;
}
interface CADProperties {
fill?: string;
stroke?: string;
strokeWidth?: number;
rotation?: number;
lineType?: 'solid' | 'dashed' | 'dotted';
radius?: number;
[key: string]: unknown;
}
interface CADElement {
id: string;
type: string;
layerId: string;
x: number;
y: number;
width: number;
height: number;
properties: CADProperties;
}
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);
// ── 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;
// Update YjsDocument
const yjsDoc = yjsDocRef?.current;
if (yjsDoc) {
yjsDoc.updateElement(selectedElement.id, updates);
}
// Update fabric object directly
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
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;