486 lines
17 KiB
TypeScript
486 lines
17 KiB
TypeScript
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;
|