'use client'
import dynamic from 'next/dynamic'
import { useState, useEffect, useRef, useMemo } from 'react'
import { useRouter } from 'next/navigation'
import { toast } from 'sonner'
import { Download, Presentation, ExternalLink, Maximize2, ChevronLeft, ChevronRight } from 'lucide-react'
import type { ExcalidrawElement } from '@excalidraw/excalidraw/element/types'
import type { AppState, BinaryFiles } from '@excalidraw/excalidraw/types'
import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types'
import '@excalidraw/excalidraw/index.css'
import type { PresentationSpec } from '@/lib/types/presentation'
const Excalidraw = dynamic(
async () => (await import('@excalidraw/excalidraw')).Excalidraw,
{ ssr: false }
)
const SlidesRenderer = dynamic(
() => import('./slides-renderer').then(m => ({ default: m.SlidesRenderer })),
{ ssr: false, loading: () =>
Chargement des slides…
}
)
interface CanvasBoardProps {
initialData?: string
canvasId?: string
name: string
}
type PptxPayload = { type: string; filename: string; base64: string; slideCount?: number; theme?: string }
type SlidesPayload = { type: 'slides'; html: string; title: string; theme?: string; style?: string; slideCount?: number; spec?: PresentationSpec }
function parseCanvasScene(initialData?: string): {
slides: SlidesPayload | null
pptx: PptxPayload | null
elements: readonly ExcalidrawElement[]
files: BinaryFiles
} {
if (!initialData) {
return { slides: null, pptx: null, elements: [], files: {} }
}
try {
const parsed = JSON.parse(initialData)
if (parsed && parsed.type === 'slides' && parsed.html) {
return { slides: parsed as SlidesPayload, pptx: null, elements: [], files: {} }
}
if (parsed && parsed.type === 'pptx' && parsed.base64) {
return { slides: null, pptx: parsed as PptxPayload, elements: [], files: {} }
}
if (parsed && Array.isArray(parsed)) {
return { slides: null, pptx: null, elements: parsed as ExcalidrawElement[], files: {} }
}
if (parsed && parsed.elements) {
const files: BinaryFiles =
parsed.files && typeof parsed.files === 'object' ? parsed.files : {}
return { slides: null, pptx: null, elements: parsed.elements as readonly ExcalidrawElement[], files }
}
} catch (e) {
console.error('[CanvasBoard] Failed to parse canvas data:', e)
}
return { slides: null, pptx: null, elements: [], files: {} }
}
function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
const handleDownload = () => {
try {
const byteCharacters = atob(data.base64)
const byteNumbers = new Array(byteCharacters.length)
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i)
}
const byteArray = new Uint8Array(byteNumbers)
const blob = new Blob([byteArray], {
type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
})
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = data.filename || `${name}.pptx`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
} catch (e) {
console.error('[PptxViewer] Download failed:', e)
toast.error('Failed to download presentation')
}
}
return (
{name}
PowerPoint Presentation
{data.slideCount ? ` • ${data.slideCount} slides` : ''}
{data.theme ? ` • ${data.theme} theme` : ''}
This file can be opened in Microsoft PowerPoint, Google Slides, or Keynote.
)
}
function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: string; canvasId?: string | null }) {
const iframeRef = useRef(null)
const [currentSlide, setCurrentSlide] = useState(1)
const [isLoaded, setIsLoaded] = useState(!!data.spec) // spec → React renderer, no iframe loading
const totalSlides = data.slideCount || 1
// Hide the global AI floating button while slides are displayed
useEffect(() => {
window.dispatchEvent(new CustomEvent('contextual-ai-visibility', { detail: true }))
return () => {
window.dispatchEvent(new CustomEvent('contextual-ai-visibility', { detail: false }))
}
}, [])
// Listen for slide-change events from the iframe via postMessage
useEffect(() => {
const handler = (e: MessageEvent) => {
if (e.data?.type === 'slideChange' && typeof e.data.current === 'number') {
setCurrentSlide(e.data.current)
}
}
window.addEventListener('message', handler)
return () => window.removeEventListener('message', handler)
}, [])
const navigate = (dir: number) => {
const iframe = iframeRef.current
if (!iframe) return
// Direct contentWindow access (works with allow-same-origin)
try {
const win = iframe.contentWindow as any
if (typeof win?.changeSlide === 'function') {
win.changeSlide(dir)
return
}
} catch (_) {}
// Fallback: postMessage
iframe.contentWindow?.postMessage({ type: 'navigate', dir }, '*')
}
const openFullscreen = () => {
const blob = new Blob([data.html], { type: 'text/html' })
const url = URL.createObjectURL(blob)
window.open(url, '_blank')
setTimeout(() => URL.revokeObjectURL(url), 10000)
}
const downloadHtml = () => {
const blob = new Blob([data.html], { type: 'text/html' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `${name.replace(/[^a-z0-9]/gi, '-').toLowerCase() || 'presentation'}.html`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
setTimeout(() => URL.revokeObjectURL(url), 5000)
}
return (
{/* Toolbar */}
{name}
{totalSlides > 1 && (
{currentSlide} / {totalSlides}
)}
{/* Slides: React renderer (legacy spec) or iframe (new HTML) */}
{data.spec ? (
) : (
<>
{/* Loading overlay — visible until iframe fires onLoad */}
{!isLoaded && (
Chargement de la présentation…
)}
)
}
export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
const [isDarkMode, setIsDarkMode] = useState(false)
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'error'>('saved')
const [localId, setLocalId] = useState(canvasId || null)
const router = useRouter()
const saveTimeoutRef = useRef(null)
const excalidrawAPIRef = useRef(null)
const filesRef = useRef({})
const scene = useMemo(
() => parseCanvasScene(initialData),
[canvasId, initialData]
)
useEffect(() => {
filesRef.current = scene.files
}, [scene])
useEffect(() => {
const checkDarkMode = () => {
const isDark = document.documentElement.classList.contains('dark')
setIsDarkMode(isDark)
}
checkDarkMode()
const observer = new MutationObserver(checkDarkMode)
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
return () => observer.disconnect()
}, [])
const handleChange = (
excalidrawElements: readonly ExcalidrawElement[],
_appState: AppState,
files: BinaryFiles
) => {
if (files) filesRef.current = files
if (saveTimeoutRef.current) clearTimeout(saveTimeoutRef.current)
setSaveStatus('saving')
saveTimeoutRef.current = setTimeout(async () => {
try {
const snapshot = JSON.stringify({ elements: excalidrawElements, files: filesRef.current })
const res = await fetch('/api/canvas', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: localId || null, name, data: snapshot })
})
const data = await res.json()
if (data.success && data.canvas?.id) {
if (!localId) {
setLocalId(data.canvas.id)
router.replace(`/lab?id=${data.canvas.id}`, { scroll: false })
}
setSaveStatus('saved')
} else {
throw new Error(data.error || 'Failed to save')
}
} catch (e) {
console.error('[CanvasBoard] Save failure:', e)
setSaveStatus('error')
}
}, 2000)
}
if (scene.slides) {
return
}
if (scene.pptx) {
return
}
const excalKey = localId ? `excal-${localId}` : 'excal-new'
return (
{ excalidrawAPIRef.current = api }}
initialData={{ elements: scene.elements, files: scene.files }}
theme={isDarkMode ? 'dark' : 'light'}
onChange={handleChange}
UIOptions={{
canvasActions: {
loadScene: true,
saveToActiveFile: false,
clearCanvas: true,
}
}}
validateEmbeddable={false}
/>
)
}