Files
breakpilot-lehrer/studio-v2/lib/worksheet-editor/WorksheetContext.tsx
Benjamin Boenisch 5a31f52310 Initial commit: breakpilot-lehrer - Lehrer KI Platform
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>
2026-02-11 23:47:26 +01:00

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
}