T19: Add PropertiesPanel.tsx component
This commit is contained in:
@@ -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<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;
|
||||||
Reference in New Issue
Block a user