From 0abc26a84059fda9c7ded548217ed7dee7f1419d Mon Sep 17 00:00:00 2001 From: Leopoldadmin Date: Mon, 22 Jun 2026 23:05:27 +0000 Subject: [PATCH] T19: Add PropertiesPanel.tsx component --- .../PropertiesPanel/PropertiesPanel.tsx | 497 ++++++++++++++++++ 1 file changed, 497 insertions(+) create mode 100644 frontend/src/components/PropertiesPanel/PropertiesPanel.tsx diff --git a/frontend/src/components/PropertiesPanel/PropertiesPanel.tsx b/frontend/src/components/PropertiesPanel/PropertiesPanel.tsx new file mode 100644 index 0000000..5c7a5ca --- /dev/null +++ b/frontend/src/components/PropertiesPanel/PropertiesPanel.tsx @@ -0,0 +1,497 @@ +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) => 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) => 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; + yjsDocRef: React.RefObject; +} + +// ─── Helpers ───────────────────────────────────────────────────────────────── + +const LINE_TYPE_DASH_MAP: Record = { + 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 = ({ canvasRef, yjsDocRef }) => { + const [selectedElement, setSelectedElement] = useState(null); + const [layers, setLayers] = useState([]); + const [fabricObject, setFabricObject] = useState(null); + const fabricCanvasRef = useRef(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 & { properties?: Partial }) => { + 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 ( +
+ +

+ Kein Element ausgewählt. Wähle ein Element auf dem Canvas, um seine Eigenschaften zu bearbeiten. +

+
+ ); + } + + 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 ( +
+ {/* Element info header */} +
+ {selectedElement.type} + ID: {selectedElement.id.substring(0, 12)} +
+ + {/* Position section */} +
+ Position +
+ + +
+
+ + {/* Size section */} +
+ Größe +
+ + +
+
+ + {/* Rotation section */} +
+ Rotation + +
+ + {/* Appearance section */} +
+ Erscheinungsbild + + + + +
+ + {/* Layer assignment section */} +
+ Layer + +
+
+ ); +}; + +export default PropertiesPanel;