Services: Admin-Lehrer, Backend-Lehrer, Studio v2, Website, Klausur-Service, School-Service, Voice-Service, Geo-Service, BreakPilot Drive, Agent-Core Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
444 lines
16 KiB
TypeScript
444 lines
16 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect, useCallback } from 'react'
|
|
import { useTheme } from '@/lib/ThemeContext'
|
|
import { useLanguage } from '@/lib/LanguageContext'
|
|
import { useWorksheet } from '@/lib/worksheet-editor/WorksheetContext'
|
|
import { AVAILABLE_FONTS, DEFAULT_TYPOGRAPHY_PRESETS } from '@/app/worksheet-editor/types'
|
|
|
|
interface PropertiesPanelProps {
|
|
className?: string
|
|
}
|
|
|
|
export function PropertiesPanel({ className = '' }: PropertiesPanelProps) {
|
|
const { isDark } = useTheme()
|
|
const { t } = useLanguage()
|
|
const { selectedObjects, canvas, saveToHistory } = useWorksheet()
|
|
|
|
// Local state for properties
|
|
const [fontFamily, setFontFamily] = useState('Arial')
|
|
const [fontSize, setFontSize] = useState(16)
|
|
const [fontWeight, setFontWeight] = useState<'normal' | 'bold'>('normal')
|
|
const [fontStyle, setFontStyle] = useState<'normal' | 'italic'>('normal')
|
|
const [textAlign, setTextAlign] = useState<'left' | 'center' | 'right'>('left')
|
|
const [lineHeight, setLineHeight] = useState(1.4)
|
|
const [charSpacing, setCharSpacing] = useState(0)
|
|
const [fill, setFill] = useState('#000000')
|
|
const [stroke, setStroke] = useState('#000000')
|
|
const [strokeWidth, setStrokeWidth] = useState(2)
|
|
const [opacity, setOpacity] = useState(100)
|
|
|
|
// Get selected object (cast to any for Fabric.js properties)
|
|
const selectedObject = selectedObjects[0] as any
|
|
const objType = selectedObject?.type
|
|
const isText = objType === 'i-text' || objType === 'text' || objType === 'textbox'
|
|
const isShape = objType === 'rect' || objType === 'circle' || objType === 'line' || objType === 'path'
|
|
const isImage = objType === 'image'
|
|
|
|
// Update local state when selection changes
|
|
useEffect(() => {
|
|
if (!selectedObject) return
|
|
|
|
if (isText) {
|
|
setFontFamily(selectedObject.fontFamily || 'Arial')
|
|
setFontSize(selectedObject.fontSize || 16)
|
|
setFontWeight(selectedObject.fontWeight || 'normal')
|
|
setFontStyle(selectedObject.fontStyle || 'normal')
|
|
setTextAlign(selectedObject.textAlign || 'left')
|
|
setLineHeight(selectedObject.lineHeight || 1.4)
|
|
setCharSpacing(selectedObject.charSpacing || 0)
|
|
setFill(selectedObject.fill || '#000000')
|
|
}
|
|
|
|
if (isShape) {
|
|
setFill(selectedObject.fill || 'transparent')
|
|
setStroke(selectedObject.stroke || '#000000')
|
|
setStrokeWidth(selectedObject.strokeWidth || 2)
|
|
}
|
|
|
|
setOpacity(Math.round((selectedObject.opacity || 1) * 100))
|
|
}, [selectedObject, isText, isShape])
|
|
|
|
// Update object property
|
|
const updateProperty = useCallback((property: string, value: any) => {
|
|
if (!selectedObject || !canvas) return
|
|
|
|
selectedObject.set(property, value)
|
|
canvas.renderAll()
|
|
saveToHistory(`property:${property}`)
|
|
}, [selectedObject, canvas, saveToHistory])
|
|
|
|
// Glassmorphism styles
|
|
const panelStyle = isDark
|
|
? 'backdrop-blur-xl bg-white/10 border border-white/20'
|
|
: 'backdrop-blur-xl bg-white/70 border border-black/10 shadow-xl'
|
|
|
|
const inputStyle = isDark
|
|
? 'bg-white/10 border-white/20 text-white placeholder-white/40 focus:border-purple-400'
|
|
: 'bg-white/50 border-black/10 text-slate-900 placeholder-slate-400 focus:border-purple-500'
|
|
|
|
const labelStyle = isDark ? 'text-white/70' : 'text-slate-600'
|
|
|
|
const selectStyle = isDark
|
|
? 'bg-white/10 border-white/20 text-white'
|
|
: 'bg-white/50 border-black/10 text-slate-900'
|
|
|
|
// No selection
|
|
if (!selectedObject) {
|
|
return (
|
|
<div className={`flex flex-col p-4 rounded-2xl ${panelStyle} ${className}`}>
|
|
<h3 className={`font-semibold text-lg mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
|
Eigenschaften
|
|
</h3>
|
|
<p className={`text-sm ${labelStyle}`}>
|
|
Wählen Sie ein Element aus, um seine Eigenschaften zu bearbeiten.
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className={`flex flex-col p-4 rounded-2xl overflow-y-auto ${panelStyle} ${className}`}>
|
|
<h3 className={`font-semibold text-lg mb-4 ${isDark ? 'text-white' : 'text-slate-900'}`}>
|
|
Eigenschaften
|
|
</h3>
|
|
|
|
{/* Text Properties */}
|
|
{isText && (
|
|
<div className="space-y-4">
|
|
{/* Typography Presets */}
|
|
<div>
|
|
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>Vorlage</label>
|
|
<select
|
|
className={`w-full px-3 py-2 rounded-xl border text-sm ${selectStyle}`}
|
|
onChange={(e) => {
|
|
const preset = DEFAULT_TYPOGRAPHY_PRESETS.find(p => p.id === e.target.value)
|
|
if (preset) {
|
|
updateProperty('fontFamily', preset.fontFamily)
|
|
updateProperty('fontSize', preset.fontSize)
|
|
updateProperty('fontWeight', preset.fontWeight)
|
|
updateProperty('lineHeight', preset.lineHeight)
|
|
setFontFamily(preset.fontFamily)
|
|
setFontSize(preset.fontSize)
|
|
setFontWeight(preset.fontWeight as 'normal' | 'bold')
|
|
setLineHeight(preset.lineHeight)
|
|
}
|
|
}}
|
|
>
|
|
<option value="">Vorlage wählen...</option>
|
|
{DEFAULT_TYPOGRAPHY_PRESETS.map(preset => (
|
|
<option key={preset.id} value={preset.id}>{preset.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Font Family */}
|
|
<div>
|
|
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>Schriftart</label>
|
|
<select
|
|
value={fontFamily}
|
|
onChange={(e) => {
|
|
setFontFamily(e.target.value)
|
|
updateProperty('fontFamily', e.target.value)
|
|
}}
|
|
className={`w-full px-3 py-2 rounded-xl border text-sm ${selectStyle}`}
|
|
>
|
|
{AVAILABLE_FONTS.map(font => (
|
|
<option key={font.name} value={font.name} style={{ fontFamily: font.family }}>
|
|
{font.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
|
|
{/* Font Size */}
|
|
<div>
|
|
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>Schriftgröße</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="range"
|
|
min="8"
|
|
max="120"
|
|
value={fontSize}
|
|
onChange={(e) => {
|
|
const value = parseInt(e.target.value)
|
|
setFontSize(value)
|
|
updateProperty('fontSize', value)
|
|
}}
|
|
className="flex-1"
|
|
/>
|
|
<input
|
|
type="number"
|
|
min="8"
|
|
max="120"
|
|
value={fontSize}
|
|
onChange={(e) => {
|
|
const value = parseInt(e.target.value) || 16
|
|
setFontSize(value)
|
|
updateProperty('fontSize', value)
|
|
}}
|
|
className={`w-16 px-2 py-1 rounded-lg border text-sm text-center ${inputStyle}`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Font Style Buttons */}
|
|
<div>
|
|
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>Stil</label>
|
|
<div className="flex gap-2">
|
|
<button
|
|
onClick={() => {
|
|
const newWeight = fontWeight === 'bold' ? 'normal' : 'bold'
|
|
setFontWeight(newWeight)
|
|
updateProperty('fontWeight', newWeight)
|
|
}}
|
|
className={`flex-1 py-2 rounded-xl font-bold transition-all ${
|
|
fontWeight === 'bold'
|
|
? isDark ? 'bg-purple-500/30 text-purple-300' : 'bg-purple-100 text-purple-700'
|
|
: isDark ? 'bg-white/10 text-white/70' : 'bg-slate-100 text-slate-600'
|
|
}`}
|
|
>
|
|
B
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
const newStyle = fontStyle === 'italic' ? 'normal' : 'italic'
|
|
setFontStyle(newStyle)
|
|
updateProperty('fontStyle', newStyle)
|
|
}}
|
|
className={`flex-1 py-2 rounded-xl italic transition-all ${
|
|
fontStyle === 'italic'
|
|
? isDark ? 'bg-purple-500/30 text-purple-300' : 'bg-purple-100 text-purple-700'
|
|
: isDark ? 'bg-white/10 text-white/70' : 'bg-slate-100 text-slate-600'
|
|
}`}
|
|
>
|
|
I
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Text Alignment */}
|
|
<div>
|
|
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>Ausrichtung</label>
|
|
<div className="flex gap-2">
|
|
{(['left', 'center', 'right'] as const).map(align => (
|
|
<button
|
|
key={align}
|
|
onClick={() => {
|
|
setTextAlign(align)
|
|
updateProperty('textAlign', align)
|
|
}}
|
|
className={`flex-1 py-2 rounded-xl transition-all ${
|
|
textAlign === align
|
|
? isDark ? 'bg-purple-500/30 text-purple-300' : 'bg-purple-100 text-purple-700'
|
|
: isDark ? 'bg-white/10 text-white/70' : 'bg-slate-100 text-slate-600'
|
|
}`}
|
|
>
|
|
{align === 'left' && (
|
|
<svg className="w-5 h-5 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 12h10M4 18h14" />
|
|
</svg>
|
|
)}
|
|
{align === 'center' && (
|
|
<svg className="w-5 h-5 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M7 12h10M5 18h14" />
|
|
</svg>
|
|
)}
|
|
{align === 'right' && (
|
|
<svg className="w-5 h-5 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M10 12h10M6 18h14" />
|
|
</svg>
|
|
)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Line Height */}
|
|
<div>
|
|
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>Zeilenhöhe</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="range"
|
|
min="0.8"
|
|
max="3"
|
|
step="0.1"
|
|
value={lineHeight}
|
|
onChange={(e) => {
|
|
const value = parseFloat(e.target.value)
|
|
setLineHeight(value)
|
|
updateProperty('lineHeight', value)
|
|
}}
|
|
className="flex-1"
|
|
/>
|
|
<span className={`w-12 text-center text-sm ${labelStyle}`}>{lineHeight.toFixed(1)}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Text Color */}
|
|
<div>
|
|
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>Textfarbe</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="color"
|
|
value={fill as string}
|
|
onChange={(e) => {
|
|
setFill(e.target.value)
|
|
updateProperty('fill', e.target.value)
|
|
}}
|
|
className="w-10 h-10 rounded-lg cursor-pointer"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={fill as string}
|
|
onChange={(e) => {
|
|
setFill(e.target.value)
|
|
updateProperty('fill', e.target.value)
|
|
}}
|
|
className={`flex-1 px-3 py-2 rounded-xl border text-sm ${inputStyle}`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Shape Properties */}
|
|
{isShape && (
|
|
<div className="space-y-4">
|
|
{/* Fill Color */}
|
|
<div>
|
|
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>Füllfarbe</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="color"
|
|
value={fill === 'transparent' ? '#ffffff' : (fill as string)}
|
|
onChange={(e) => {
|
|
setFill(e.target.value)
|
|
updateProperty('fill', e.target.value)
|
|
}}
|
|
className="w-10 h-10 rounded-lg cursor-pointer"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={fill as string}
|
|
onChange={(e) => {
|
|
setFill(e.target.value)
|
|
updateProperty('fill', e.target.value)
|
|
}}
|
|
className={`flex-1 px-3 py-2 rounded-xl border text-sm ${inputStyle}`}
|
|
/>
|
|
<button
|
|
onClick={() => {
|
|
setFill('transparent')
|
|
updateProperty('fill', 'transparent')
|
|
}}
|
|
className={`px-3 py-2 rounded-xl text-sm ${
|
|
isDark ? 'bg-white/10 text-white/70 hover:bg-white/20' : 'bg-slate-100 text-slate-600 hover:bg-slate-200'
|
|
}`}
|
|
>
|
|
Keine
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stroke Color */}
|
|
<div>
|
|
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>Rahmenfarbe</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="color"
|
|
value={stroke}
|
|
onChange={(e) => {
|
|
setStroke(e.target.value)
|
|
updateProperty('stroke', e.target.value)
|
|
}}
|
|
className="w-10 h-10 rounded-lg cursor-pointer"
|
|
/>
|
|
<input
|
|
type="text"
|
|
value={stroke}
|
|
onChange={(e) => {
|
|
setStroke(e.target.value)
|
|
updateProperty('stroke', e.target.value)
|
|
}}
|
|
className={`flex-1 px-3 py-2 rounded-xl border text-sm ${inputStyle}`}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stroke Width */}
|
|
<div>
|
|
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>Rahmenstärke</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="20"
|
|
value={strokeWidth}
|
|
onChange={(e) => {
|
|
const value = parseInt(e.target.value)
|
|
setStrokeWidth(value)
|
|
updateProperty('strokeWidth', value)
|
|
}}
|
|
className="flex-1"
|
|
/>
|
|
<span className={`w-12 text-center text-sm ${labelStyle}`}>{strokeWidth}px</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Image Properties */}
|
|
{isImage && (
|
|
<div className="space-y-4">
|
|
<p className={`text-sm ${labelStyle}`}>
|
|
Bildgröße und Position können durch Ziehen der Eckpunkte angepasst werden.
|
|
</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Common Properties */}
|
|
<div className="mt-6 pt-4 border-t border-white/10 space-y-4">
|
|
{/* Opacity */}
|
|
<div>
|
|
<label className={`block text-sm font-medium mb-2 ${labelStyle}`}>Deckkraft</label>
|
|
<div className="flex items-center gap-2">
|
|
<input
|
|
type="range"
|
|
min="0"
|
|
max="100"
|
|
value={opacity}
|
|
onChange={(e) => {
|
|
const value = parseInt(e.target.value)
|
|
setOpacity(value)
|
|
updateProperty('opacity', value / 100)
|
|
}}
|
|
className="flex-1"
|
|
/>
|
|
<span className={`w-12 text-center text-sm ${labelStyle}`}>{opacity}%</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Delete Button */}
|
|
<button
|
|
onClick={() => {
|
|
if (canvas && selectedObject) {
|
|
canvas.remove(selectedObject)
|
|
canvas.discardActiveObject()
|
|
canvas.renderAll()
|
|
saveToHistory('delete')
|
|
}
|
|
}}
|
|
className={`w-full py-3 rounded-xl text-sm font-medium transition-all ${
|
|
isDark
|
|
? 'bg-red-500/20 text-red-400 hover:bg-red-500/30'
|
|
: 'bg-red-50 text-red-600 hover:bg-red-100'
|
|
}`}
|
|
>
|
|
Element löschen
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|