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>
420 lines
11 KiB
TypeScript
420 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import React, { createContext, useContext, useState, useCallback, useRef, useEffect, ReactNode } from 'react'
|
|
import type { Canvas, Object as FabricObject } from 'fabric'
|
|
import type {
|
|
EditorTool,
|
|
WorksheetDocument,
|
|
WorksheetPage,
|
|
PageFormat,
|
|
HistoryEntry,
|
|
DEFAULT_PAGE_FORMAT
|
|
} from '@/app/worksheet-editor/types'
|
|
|
|
// Context Types
|
|
interface WorksheetContextType {
|
|
// Canvas
|
|
canvas: Canvas | null
|
|
setCanvas: (canvas: Canvas | null) => void
|
|
|
|
// Document
|
|
document: WorksheetDocument | null
|
|
setDocument: (doc: WorksheetDocument | null) => void
|
|
|
|
// Tool State
|
|
activeTool: EditorTool
|
|
setActiveTool: (tool: EditorTool) => void
|
|
|
|
// Selection
|
|
selectedObjects: FabricObject[]
|
|
setSelectedObjects: (objects: FabricObject[]) => void
|
|
|
|
// Zoom
|
|
zoom: number
|
|
setZoom: (zoom: number) => void
|
|
zoomIn: () => void
|
|
zoomOut: () => void
|
|
zoomToFit: () => void
|
|
|
|
// Grid
|
|
showGrid: boolean
|
|
setShowGrid: (show: boolean) => void
|
|
snapToGrid: boolean
|
|
setSnapToGrid: (snap: boolean) => void
|
|
gridSize: number
|
|
setGridSize: (size: number) => void
|
|
|
|
// Pages
|
|
currentPageIndex: number
|
|
setCurrentPageIndex: (index: number) => void
|
|
addPage: () => void
|
|
deletePage: (index: number) => void
|
|
|
|
// History
|
|
canUndo: boolean
|
|
canRedo: boolean
|
|
undo: () => void
|
|
redo: () => void
|
|
saveToHistory: (action: string) => void
|
|
|
|
// Save/Load
|
|
saveDocument: () => Promise<void>
|
|
loadDocument: (id: string) => Promise<void>
|
|
|
|
// Export
|
|
exportToPDF: () => Promise<Blob>
|
|
exportToImage: (format: 'png' | 'jpg') => Promise<Blob>
|
|
|
|
// Dirty State
|
|
isDirty: boolean
|
|
setIsDirty: (dirty: boolean) => void
|
|
}
|
|
|
|
const WorksheetContext = createContext<WorksheetContextType | null>(null)
|
|
|
|
// Provider Props
|
|
interface WorksheetProviderProps {
|
|
children: ReactNode
|
|
}
|
|
|
|
// Generate unique ID
|
|
const generateId = () => `ws_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`
|
|
|
|
// Default Page Format
|
|
const defaultPageFormat: PageFormat = {
|
|
width: 210,
|
|
height: 297,
|
|
orientation: 'portrait',
|
|
margins: { top: 15, right: 15, bottom: 15, left: 15 }
|
|
}
|
|
|
|
export function WorksheetProvider({ children }: WorksheetProviderProps) {
|
|
// Canvas State
|
|
const [canvas, setCanvas] = useState<Canvas | null>(null)
|
|
|
|
// Document State
|
|
const [document, setDocument] = useState<WorksheetDocument | null>(null)
|
|
|
|
// Editor State
|
|
const [activeTool, setActiveTool] = useState<EditorTool>('select')
|
|
const [selectedObjects, setSelectedObjects] = useState<FabricObject[]>([])
|
|
const [zoom, setZoom] = useState(1)
|
|
const [showGrid, setShowGrid] = useState(true)
|
|
const [snapToGrid, setSnapToGrid] = useState(true)
|
|
const [gridSize, setGridSize] = useState(10)
|
|
const [currentPageIndex, setCurrentPageIndex] = useState(0)
|
|
const [isDirty, setIsDirty] = useState(false)
|
|
|
|
// History State
|
|
const historyRef = useRef<HistoryEntry[]>([])
|
|
const historyIndexRef = useRef(-1)
|
|
const [canUndo, setCanUndo] = useState(false)
|
|
const [canRedo, setCanRedo] = useState(false)
|
|
|
|
// Initialize empty document
|
|
useEffect(() => {
|
|
if (!document) {
|
|
const newDoc: WorksheetDocument = {
|
|
id: generateId(),
|
|
title: 'Neues Arbeitsblatt',
|
|
pages: [{
|
|
id: generateId(),
|
|
index: 0,
|
|
canvasJSON: ''
|
|
}],
|
|
pageFormat: defaultPageFormat,
|
|
createdAt: new Date().toISOString(),
|
|
updatedAt: new Date().toISOString()
|
|
}
|
|
setDocument(newDoc)
|
|
}
|
|
}, [document])
|
|
|
|
// Zoom functions
|
|
const zoomIn = useCallback(() => {
|
|
setZoom(prev => Math.min(prev * 1.2, 4))
|
|
}, [])
|
|
|
|
const zoomOut = useCallback(() => {
|
|
setZoom(prev => Math.max(prev / 1.2, 0.25))
|
|
}, [])
|
|
|
|
const zoomToFit = useCallback(() => {
|
|
setZoom(1)
|
|
}, [])
|
|
|
|
// Page functions
|
|
const addPage = useCallback(() => {
|
|
if (!document) return
|
|
|
|
const newPage: WorksheetPage = {
|
|
id: generateId(),
|
|
index: document.pages.length,
|
|
canvasJSON: ''
|
|
}
|
|
|
|
setDocument({
|
|
...document,
|
|
pages: [...document.pages, newPage],
|
|
updatedAt: new Date().toISOString()
|
|
})
|
|
setCurrentPageIndex(document.pages.length)
|
|
setIsDirty(true)
|
|
}, [document])
|
|
|
|
const deletePage = useCallback((index: number) => {
|
|
if (!document || document.pages.length <= 1) return
|
|
|
|
const newPages = document.pages.filter((_, i) => i !== index)
|
|
.map((page, i) => ({ ...page, index: i }))
|
|
|
|
setDocument({
|
|
...document,
|
|
pages: newPages,
|
|
updatedAt: new Date().toISOString()
|
|
})
|
|
|
|
if (currentPageIndex >= newPages.length) {
|
|
setCurrentPageIndex(newPages.length - 1)
|
|
}
|
|
setIsDirty(true)
|
|
}, [document, currentPageIndex])
|
|
|
|
// History functions
|
|
const saveToHistory = useCallback((action: string) => {
|
|
if (!canvas) return
|
|
|
|
try {
|
|
const canvasData = canvas.toJSON()
|
|
if (!canvasData) return
|
|
|
|
const entry: HistoryEntry = {
|
|
canvasJSON: JSON.stringify(canvasData),
|
|
timestamp: Date.now(),
|
|
action
|
|
}
|
|
|
|
// Remove any future history if we're not at the end
|
|
if (historyIndexRef.current < historyRef.current.length - 1) {
|
|
historyRef.current = historyRef.current.slice(0, historyIndexRef.current + 1)
|
|
}
|
|
|
|
historyRef.current.push(entry)
|
|
historyIndexRef.current = historyRef.current.length - 1
|
|
|
|
// Limit history size
|
|
if (historyRef.current.length > 50) {
|
|
historyRef.current.shift()
|
|
historyIndexRef.current--
|
|
}
|
|
|
|
setCanUndo(historyIndexRef.current > 0)
|
|
setCanRedo(false)
|
|
setIsDirty(true)
|
|
} catch (error) {
|
|
console.error('Failed to save history:', error)
|
|
}
|
|
}, [canvas])
|
|
|
|
const undo = useCallback(() => {
|
|
if (!canvas || historyIndexRef.current <= 0) return
|
|
|
|
historyIndexRef.current--
|
|
const entry = historyRef.current[historyIndexRef.current]
|
|
|
|
canvas.loadFromJSON(JSON.parse(entry.canvasJSON), () => {
|
|
canvas.renderAll()
|
|
})
|
|
|
|
setCanUndo(historyIndexRef.current > 0)
|
|
setCanRedo(true)
|
|
}, [canvas])
|
|
|
|
const redo = useCallback(() => {
|
|
if (!canvas || historyIndexRef.current >= historyRef.current.length - 1) return
|
|
|
|
historyIndexRef.current++
|
|
const entry = historyRef.current[historyIndexRef.current]
|
|
|
|
canvas.loadFromJSON(JSON.parse(entry.canvasJSON), () => {
|
|
canvas.renderAll()
|
|
})
|
|
|
|
setCanUndo(true)
|
|
setCanRedo(historyIndexRef.current < historyRef.current.length - 1)
|
|
}, [canvas])
|
|
|
|
// Save/Load functions
|
|
const saveDocument = useCallback(async () => {
|
|
if (!canvas || !document) return
|
|
|
|
// Save current page state
|
|
const currentPage = document.pages[currentPageIndex]
|
|
currentPage.canvasJSON = JSON.stringify(canvas.toJSON())
|
|
|
|
// Get API base URL (use same protocol as page)
|
|
const { hostname, protocol } = window.location
|
|
const apiBase = hostname === 'localhost' ? 'http://localhost:8086' : `${protocol}//${hostname}:8086`
|
|
|
|
try {
|
|
const response = await fetch(`${apiBase}/api/v1/worksheet/save`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(document)
|
|
})
|
|
|
|
if (!response.ok) throw new Error('Failed to save')
|
|
|
|
const result = await response.json()
|
|
setDocument({ ...document, id: result.id })
|
|
setIsDirty(false)
|
|
} catch (error) {
|
|
console.error('Save failed:', error)
|
|
// Fallback to localStorage
|
|
localStorage.setItem(`worksheet_${document.id}`, JSON.stringify(document))
|
|
setIsDirty(false)
|
|
}
|
|
}, [canvas, document, currentPageIndex])
|
|
|
|
const loadDocument = useCallback(async (id: string) => {
|
|
const { hostname, protocol } = window.location
|
|
const apiBase = hostname === 'localhost' ? 'http://localhost:8086' : `${protocol}//${hostname}:8086`
|
|
|
|
try {
|
|
const response = await fetch(`${apiBase}/api/v1/worksheet/${id}`)
|
|
|
|
if (!response.ok) throw new Error('Failed to load')
|
|
|
|
const doc = await response.json()
|
|
setDocument(doc)
|
|
setCurrentPageIndex(0)
|
|
|
|
if (canvas && doc.pages[0]?.canvasJSON) {
|
|
canvas.loadFromJSON(JSON.parse(doc.pages[0].canvasJSON), () => {
|
|
canvas.renderAll()
|
|
})
|
|
}
|
|
} catch (error) {
|
|
console.error('Load failed:', error)
|
|
// Try localStorage fallback
|
|
const stored = localStorage.getItem(`worksheet_${id}`)
|
|
if (stored) {
|
|
const doc = JSON.parse(stored)
|
|
setDocument(doc)
|
|
setCurrentPageIndex(0)
|
|
}
|
|
}
|
|
}, [canvas])
|
|
|
|
// Export functions
|
|
const exportToPDF = useCallback(async (): Promise<Blob> => {
|
|
if (!canvas || !document) throw new Error('No canvas or document')
|
|
|
|
const { PDFDocument, rgb } = await import('pdf-lib')
|
|
|
|
const pdfDoc = await PDFDocument.create()
|
|
|
|
for (const page of document.pages) {
|
|
const pdfPage = pdfDoc.addPage([
|
|
document.pageFormat.width * 2.83465, // mm to points
|
|
document.pageFormat.height * 2.83465
|
|
])
|
|
|
|
// Export canvas as PNG and embed
|
|
if (page.canvasJSON) {
|
|
// Load the page content
|
|
if (currentPageIndex !== page.index) {
|
|
await new Promise<void>((resolve) => {
|
|
canvas.loadFromJSON(JSON.parse(page.canvasJSON), () => {
|
|
canvas.renderAll()
|
|
resolve()
|
|
})
|
|
})
|
|
}
|
|
|
|
const dataUrl = canvas.toDataURL({ format: 'png', multiplier: 2 })
|
|
const imageBytes = await fetch(dataUrl).then(res => res.arrayBuffer())
|
|
const image = await pdfDoc.embedPng(imageBytes)
|
|
|
|
pdfPage.drawImage(image, {
|
|
x: 0,
|
|
y: 0,
|
|
width: pdfPage.getWidth(),
|
|
height: pdfPage.getHeight()
|
|
})
|
|
}
|
|
}
|
|
|
|
const pdfBytes = await pdfDoc.save()
|
|
// Convert Uint8Array to ArrayBuffer for Blob compatibility
|
|
const arrayBuffer = pdfBytes.slice().buffer as ArrayBuffer
|
|
return new Blob([arrayBuffer], { type: 'application/pdf' })
|
|
}, [canvas, document, currentPageIndex])
|
|
|
|
const exportToImage = useCallback(async (format: 'png' | 'jpg'): Promise<Blob> => {
|
|
if (!canvas) throw new Error('No canvas')
|
|
|
|
// Fabric.js uses 'jpeg' not 'jpg'
|
|
const fabricFormat = format === 'jpg' ? 'jpeg' : format
|
|
const dataUrl = canvas.toDataURL({
|
|
format: fabricFormat as 'png' | 'jpeg',
|
|
quality: format === 'jpg' ? 0.9 : undefined,
|
|
multiplier: 2
|
|
})
|
|
|
|
const response = await fetch(dataUrl)
|
|
return response.blob()
|
|
}, [canvas])
|
|
|
|
const value: WorksheetContextType = {
|
|
canvas,
|
|
setCanvas,
|
|
document,
|
|
setDocument,
|
|
activeTool,
|
|
setActiveTool,
|
|
selectedObjects,
|
|
setSelectedObjects,
|
|
zoom,
|
|
setZoom,
|
|
zoomIn,
|
|
zoomOut,
|
|
zoomToFit,
|
|
showGrid,
|
|
setShowGrid,
|
|
snapToGrid,
|
|
setSnapToGrid,
|
|
gridSize,
|
|
setGridSize,
|
|
currentPageIndex,
|
|
setCurrentPageIndex,
|
|
addPage,
|
|
deletePage,
|
|
canUndo,
|
|
canRedo,
|
|
undo,
|
|
redo,
|
|
saveToHistory,
|
|
saveDocument,
|
|
loadDocument,
|
|
exportToPDF,
|
|
exportToImage,
|
|
isDirty,
|
|
setIsDirty
|
|
}
|
|
|
|
return (
|
|
<WorksheetContext.Provider value={value}>
|
|
{children}
|
|
</WorksheetContext.Provider>
|
|
)
|
|
}
|
|
|
|
export function useWorksheet() {
|
|
const context = useContext(WorksheetContext)
|
|
if (!context) {
|
|
throw new Error('useWorksheet must be used within a WorksheetProvider')
|
|
}
|
|
return context
|
|
}
|