feat: add Meilisearch backend integration with Docker Compose setup and Excalidraw support
This commit is contained in:
@@ -0,0 +1,194 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import * as ReactDOM from 'react-dom/client';
|
||||
import r2wc from 'react-to-webcomponent';
|
||||
import ExcalidrawWrapper from './ExcalidrawElement';
|
||||
import type { Scene } from './types'; // Importer le type Scene si défini ailleurs
|
||||
import { exportToBlob, exportToSvg } from '@excalidraw/excalidraw'; // Importer les fonctions exportToBlob et exportToSvg
|
||||
|
||||
// Définition du type pour l'événement personnalisé
|
||||
declare global {
|
||||
interface HTMLElementEventMap {
|
||||
'ready': CustomEvent<{ apiAvailable: boolean }>;
|
||||
}
|
||||
}
|
||||
|
||||
// Map attributes/props to React props
|
||||
const BaseCE = r2wc(ExcalidrawWrapper as any, React as any, ReactDOM as any, {
|
||||
props: {
|
||||
initialData: 'json',
|
||||
readOnly: 'boolean',
|
||||
theme: 'string',
|
||||
lang: 'string',
|
||||
gridMode: 'boolean',
|
||||
zenMode: 'boolean',
|
||||
hostRef: 'object',
|
||||
},
|
||||
// render in light DOM to allow package styles to apply
|
||||
});
|
||||
|
||||
class ExcalidrawElement extends (BaseCE as any) {
|
||||
connectedCallback() {
|
||||
// Ensure the React component receives a reference to the host element before first render
|
||||
(this as any).__host = this;
|
||||
(this as any).hostRef = this;
|
||||
|
||||
// Dispatch a ready event once the API has been set on the host by the React wrapper
|
||||
const onReady = () => {
|
||||
console.log('[excalidraw-editor] 🎨 READY event dispatched', { apiAvailable: !!(this as any).__excalidrawAPI });
|
||||
this.dispatchEvent(new CustomEvent('ready', {
|
||||
detail: { apiAvailable: !!(this as any).__excalidrawAPI },
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
};
|
||||
|
||||
if ((this as any).__excalidrawAPI) {
|
||||
onReady();
|
||||
} else {
|
||||
const checkApi = setInterval(() => {
|
||||
if ((this as any).__excalidrawAPI) {
|
||||
clearInterval(checkApi);
|
||||
onReady();
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
super.connectedCallback?.();
|
||||
}
|
||||
|
||||
|
||||
// Imperative API surface
|
||||
getScene() {
|
||||
const api = (this as any).__excalidrawAPI;
|
||||
if (!api) return { elements: [], appState: {}, files: {} } as Scene;
|
||||
return {
|
||||
elements: api.getSceneElements?.() ?? [],
|
||||
appState: api.getAppState?.() ?? {},
|
||||
files: api.getFiles?.() ?? {},
|
||||
} as Scene;
|
||||
}
|
||||
exportPNG(opts?: { withBackground?: boolean }) {
|
||||
const api = (this as any).__excalidrawAPI;
|
||||
if (!api) return { elements: [], appState: {}, files: {} };
|
||||
const elements = api.getSceneElements?.() ?? [];
|
||||
const appState = { ...(api.getAppState?.() ?? {}), exportBackground: !!opts?.withBackground } as any;
|
||||
const files = api.getFiles?.() ?? {};
|
||||
return exportToBlob({ elements, appState, files, mimeType: 'image/png', quality: 1 });
|
||||
}
|
||||
async exportSVG(opts?: { withBackground?: boolean }) {
|
||||
const api = (this as any).__excalidrawAPI;
|
||||
if (!api) return { elements: [], appState: {}, files: {} };
|
||||
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' });
|
||||
}
|
||||
setScene(scene: any) {
|
||||
const api = (this as any).__excalidrawAPI;
|
||||
if (!api) return;
|
||||
api.updateScene(scene);
|
||||
}
|
||||
refresh() {
|
||||
const api = (this as any).__excalidrawAPI;
|
||||
if (!api) return;
|
||||
api.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get('excalidraw-editor')) {
|
||||
customElements.define('excalidraw-editor', ExcalidrawElement as any);
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export type ThemeName = 'light' | 'dark';
|
||||
|
||||
export type Scene = {
|
||||
elements: any[];
|
||||
appState?: Record<string, any>;
|
||||
files?: Record<string, any>;
|
||||
};
|
||||
|
||||
export type ExcalidrawWrapperProps = {
|
||||
initialData?: Scene;
|
||||
readOnly?: boolean;
|
||||
theme?: ThemeName;
|
||||
lang?: string;
|
||||
gridMode?: boolean;
|
||||
zenMode?: boolean;
|
||||
};
|
||||
|
||||
export type SceneChangeDetail = Scene & { source?: 'user' | 'program' };
|
||||
|
||||
export type ReadyDetail = { apiAvailable: boolean };
|
||||
|
||||
export type RequestExportDetail = { type: 'png' | 'svg'; withBackground?: boolean };
|
||||
Reference in New Issue
Block a user