195 lines
		
	
	
		
			7.7 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
			
		
		
	
	
			195 lines
		
	
	
		
			7.7 KiB
		
	
	
	
		
			TypeScript
		
	
	
	
	
	
| import React, { useEffect, useMemo, useRef } from 'react';
 | |
| import { Excalidraw, exportToBlob, exportToSvg } from '@excalidraw/excalidraw';
 | |
| import type { AppState, ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types/types';
 | |
| import type { ExcalidrawWrapperProps, Scene, SceneChangeDetail, ReadyDetail } from './types';
 | |
| 
 | |
| /**
 | |
|  * React wrapper mounted inside the <excalidraw-editor> custom element.
 | |
|  * It relies on r2wc to pass the host element via a non-attribute property `__host`.
 | |
|  */
 | |
| export default function ExcalidrawWrapper(props: ExcalidrawWrapperProps & { __host?: HTMLElement; hostRef?: HTMLElement }) {
 | |
|   const apiRef = useRef<ExcalidrawImperativeAPI | null>(null);
 | |
|   const hostRef = useRef<HTMLElement | null>(null);
 | |
|   const pendingReadyRef = useRef<ExcalidrawImperativeAPI | null>(null);
 | |
|   const pendingSceneEventsRef = useRef<SceneChangeDetail[]>([]);
 | |
|   const lastDetailRef = useRef<SceneChangeDetail | null>(null);
 | |
| 
 | |
|   const resolveHost = (candidate?: HTMLElement | null) => {
 | |
|     if (candidate && hostRef.current !== candidate) {
 | |
|       hostRef.current = candidate;
 | |
|       console.log('[excalidraw-editor] host resolved', { hasHost: true });
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   resolveHost((props as any).hostRef as HTMLElement | undefined);
 | |
|   resolveHost((props as any).__host as HTMLElement | undefined);
 | |
| 
 | |
|   // Sync dark/light mode with export utils defaults
 | |
|   const theme = props.theme === 'dark' ? 'dark' : 'light';
 | |
|   const lang = props.lang || 'fr';
 | |
| 
 | |
|   const onChange = (elements: any[], appState: Partial<AppState>, files: any) => {
 | |
|     // CRITICAL DEBUG: Log raw parameters
 | |
|     console.log('[excalidraw-editor] 🔍 onChange called', {
 | |
|       elementsLength: elements?.length,
 | |
|       elementsIsArray: Array.isArray(elements),
 | |
|       elementsRaw: elements,
 | |
|       appStateKeys: appState ? Object.keys(appState) : [],
 | |
|       filesKeys: files ? Object.keys(files) : []
 | |
|     });
 | |
|     
 | |
|     const detail: SceneChangeDetail = { elements, appState, files, source: 'user' };
 | |
|     lastDetailRef.current = detail;
 | |
|     console.log('[excalidraw-editor] 📝 SCENE-CHANGE will dispatch', { 
 | |
|       elCount: Array.isArray(elements) ? elements.length : 'n/a',
 | |
|       elementsType: typeof elements,
 | |
|       isArray: Array.isArray(elements),
 | |
|       viewMode: appState?.viewModeEnabled,
 | |
|       propsReadOnly: props.readOnly,
 | |
|       firstElement: elements?.[0] ? { id: elements[0].id, type: elements[0].type } : null
 | |
|     });
 | |
|     
 | |
|     const host = hostRef.current;
 | |
|     if (!host) {
 | |
|       console.warn('[excalidraw-editor] host unavailable during scene-change, queueing event');
 | |
|       pendingSceneEventsRef.current.push(detail);
 | |
|       return;
 | |
|     }
 | |
|     
 | |
|     // Always dispatch - Angular will handle filtering via hash comparison
 | |
|     host.dispatchEvent(new CustomEvent('scene-change', { detail, bubbles: true, composed: true }));
 | |
|     console.log('[excalidraw-editor] ✅ SCENE-CHANGE dispatched to host');
 | |
|   };
 | |
| 
 | |
|   // When API becomes available
 | |
|   const onApiReady = (api: ExcalidrawImperativeAPI) => {
 | |
|     console.log('[excalidraw-editor] onApiReady', !!api);
 | |
|     apiRef.current = api;
 | |
|     const host = hostRef.current;
 | |
|     if (host) {
 | |
|       (host as any).__excalidrawAPI = api;
 | |
|       host.dispatchEvent(new CustomEvent<ReadyDetail>('ready', { detail: { apiAvailable: true }, bubbles: true, composed: true }));
 | |
|       // Force a couple of refresh cycles to layout the canvas once API is ready
 | |
|       try { api.refresh?.(); } catch {}
 | |
|       queueMicrotask(() => { try { api.refresh?.(); } catch {} });
 | |
|       setTimeout(() => { try { api.refresh?.(); } catch {} }, 0);
 | |
|     } else {
 | |
|       pendingReadyRef.current = api;
 | |
|     }
 | |
|   };
 | |
| 
 | |
|   // Expose imperative export helpers via the host methods (define.ts wires prototypes)
 | |
|   useEffect(() => {
 | |
|     resolveHost((props as any).hostRef as HTMLElement | undefined);
 | |
|     resolveHost((props as any).__host as HTMLElement | undefined);
 | |
| 
 | |
|     const host = hostRef.current;
 | |
|     if (!host) return;
 | |
| 
 | |
|     if (pendingReadyRef.current) {
 | |
|       (host as any).__excalidrawAPI = pendingReadyRef.current;
 | |
|       host.dispatchEvent(new CustomEvent<ReadyDetail>('ready', { detail: { apiAvailable: true }, bubbles: true, composed: true }));
 | |
|       console.log('[excalidraw-editor] flushed pending ready event');
 | |
|       pendingReadyRef.current = null;
 | |
|     }
 | |
| 
 | |
|     if (pendingSceneEventsRef.current.length > 0) {
 | |
|       const queued = pendingSceneEventsRef.current.splice(0);
 | |
|       console.log('[excalidraw-editor] flushing queued scene events', { count: queued.length });
 | |
|       queued.forEach((detail) => {
 | |
|         host.dispatchEvent(new CustomEvent('scene-change', { detail, bubbles: true, composed: true }));
 | |
|       });
 | |
|     }
 | |
| 
 | |
|     (host as any).__getScene = () => {
 | |
|       const api = apiRef.current;
 | |
|       if (!api) return { elements: [], appState: {}, files: {} } as Scene;
 | |
|       return {
 | |
|         elements: api.getSceneElements?.() ?? [],
 | |
|         appState: api.getAppState?.() ?? {},
 | |
|         files: api.getFiles?.() ?? {},
 | |
|       } as Scene;
 | |
|     };
 | |
| 
 | |
|     (host as any).__getLastEventScene = () => {
 | |
|       const ld = lastDetailRef.current;
 | |
|       if (!ld) return undefined;
 | |
|       return { elements: ld.elements ?? [], appState: ld.appState ?? {}, files: ld.files ?? {} } as Scene;
 | |
|     };
 | |
| 
 | |
|     (host as any).__emitSceneChange = () => {
 | |
|       const api = apiRef.current;
 | |
|       if (!api) return;
 | |
|       const elements = api.getSceneElements?.() ?? [];
 | |
|       const appState = api.getAppState?.() ?? {} as Partial<AppState>;
 | |
|       const files = api.getFiles?.() ?? {};
 | |
|       const detail = { elements, appState, files, source: 'flush' } as any;
 | |
|       try {
 | |
|         host.dispatchEvent(new CustomEvent('scene-change', { detail, bubbles: true, composed: true }));
 | |
|       } catch {}
 | |
|     };
 | |
| 
 | |
|     (host as any).__exportPNG = async (opts: { withBackground?: boolean } = {}) => {
 | |
|       const api = apiRef.current;
 | |
|       if (!api) return undefined;
 | |
|       const elements = api.getSceneElements?.() ?? [];
 | |
|       const appState = { ...(api.getAppState?.() ?? {}), exportBackground: !!opts.withBackground } as any;
 | |
|       const files = api.getFiles?.() ?? {};
 | |
|       return await exportToBlob({ elements, appState, files, mimeType: 'image/png', quality: 1 });
 | |
|     };
 | |
| 
 | |
|     (host as any).__exportSVG = async (opts: { withBackground?: boolean } = {}) => {
 | |
|       const api = apiRef.current;
 | |
|       if (!api) return undefined;
 | |
|       const elements = api.getSceneElements?.() ?? [];
 | |
|       const appState = { ...(api.getAppState?.() ?? {}), exportBackground: !!opts.withBackground } as any;
 | |
|       const files = api.getFiles?.() ?? {};
 | |
|       const svgEl = await exportToSvg({ elements, appState, files });
 | |
|       const svgText = new XMLSerializer().serializeToString(svgEl);
 | |
|       return new Blob([svgText], { type: 'image/svg+xml' });
 | |
|     };
 | |
|   });
 | |
| 
 | |
|   // Ensure canvas refreshes when the host size becomes available or changes
 | |
|   useEffect(() => {
 | |
|     const host = hostRef.current;
 | |
|     if (!host) return;
 | |
|     // Force an initial refresh shortly after mount to layout the canvas
 | |
|     const t = setTimeout(() => {
 | |
|       try { apiRef.current?.refresh?.(); } catch {}
 | |
|     }, 0);
 | |
| 
 | |
|     let ro: ResizeObserver | null = null;
 | |
|     try {
 | |
|       ro = new ResizeObserver(() => {
 | |
|         try { apiRef.current?.refresh?.(); } catch {}
 | |
|       });
 | |
|       ro.observe(host);
 | |
|     } catch {}
 | |
| 
 | |
|     return () => {
 | |
|       clearTimeout(t);
 | |
|       try { ro?.disconnect(); } catch {}
 | |
|     };
 | |
|   });
 | |
| 
 | |
|   // Map React props to Excalidraw props
 | |
|   // IMPORTANT: Freeze initialData on first mount to avoid resetting the scene
 | |
|   const initialDataRef = useRef<any>(props.initialData as any);
 | |
| 
 | |
|   return (
 | |
|     <div style={{ height: '100%', width: '100%' }}>
 | |
|       <Excalidraw
 | |
|         excalidrawAPI={onApiReady}
 | |
|         initialData={initialDataRef.current}
 | |
|         viewModeEnabled={!!props.readOnly}
 | |
|         theme={theme}
 | |
|         langCode={lang}
 | |
|         gridModeEnabled={!!props.gridMode}
 | |
|         zenModeEnabled={!!props.zenMode}
 | |
|         onChange={onChange}
 | |
|       />
 | |
|     </div>
 | |
|   );
 | |
| }
 |