import { useCallback, useEffect, useRef, useState, type FormEvent, } from 'react'; import { fileOpen } from 'browser-fs-access'; import { exportToSvg, restoreElements, loadFromBlob, loadLibraryFromBlob, getNonDeletedElements, } from '@excalidraw/excalidraw'; import type { BinaryFiles } from '@excalidraw/excalidraw/types'; import type { ExcalidrawElement } from '@excalidraw/excalidraw/element/types'; import GitHubCorner from './GitHubCorner'; import { getBeginTimeList } from './animate'; import { exportToSvgFile, exportToWebmFile, prepareWebmData } from './export'; import { applyThemeToSvg } from './useLoadSvg'; import { Modal } from './Modal'; const loadFromJSON = async () => { const blob = await fileOpen({ description: 'Excalidraw files', }); return loadFromBlob(blob, null, null); }; const linkRegex = /#json=([a-zA-Z0-9_-]+),?([a-zA-Z0-9_-]*)|^http.*\.excalidrawlib$/; const getCombinedBeginTimeList = (svgList: Props['svgList']) => { const beginTimeList = ([] as number[]).concat( ...svgList.map(({ svg }) => getBeginTimeList(svg).map((n) => Math.floor(n / 100) * 100), ), ); return [...new Set(beginTimeList)].sort((a, b) => a - b); }; const removeBackgroundRect = (svg: SVGSVGElement): SVGSVGElement => { const cloned = svg.cloneNode(true) as SVGSVGElement; const firstRect = cloned.querySelector('rect'); if (firstRect) { firstRect.remove(); } return cloned; }; type Props = { svgList: { svg: SVGSVGElement; finishedMs: number; }[]; loadDataList: ( data: { elements: readonly ExcalidrawElement[]; appState: Parameters[0]['appState']; files: BinaryFiles; }[], ) => void; theme: 'light' | 'dark'; }; const Toggle = ({ checked, onChange, ariaLabel, title, }: { checked: boolean; onChange: () => void; ariaLabel: string; title: string; }) => ( ); const Toolbar = ({ svgList, loadDataList, theme }: Props) => { const [showToolbar, setShowToolbar] = useState(false); const [paused, setPaused] = useState(false); const [processing, setProcessing] = useState(false); const [link, setLink] = useState(''); const [webmData, setWebmData] = useState(); const [showExport, setShowExport] = useState(false); const [exportTheme, setExportTheme] = useState<'light' | 'dark'>(theme); const [exportBackground, setExportBackground] = useState(false); const [error, setError] = useState(null); useEffect(() => { // FIXME // eslint-disable-next-line react-hooks/set-state-in-effect setWebmData(undefined); }, [svgList]); useEffect(() => { svgList.forEach(({ svg }) => { if (paused) { svg.pauseAnimations(); } else { svg.unpauseAnimations(); } }); }, [svgList, paused]); useEffect(() => { const hash = window.location.hash.slice(1); const searchParams = new URLSearchParams(hash); if (searchParams.get('toolbar') !== 'no') { // FIXME // eslint-disable-next-line react-hooks/set-state-in-effect setShowToolbar(true); } else { setShowToolbar('never'); } }, []); const loadFile = async () => { try { const data = await loadFromJSON(); loadDataList([data]); } catch (e) { console.error('Failed to load file:', e); setError('Failed to load file'); } }; useEffect(() => { if (!showExport) return; const onKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { setShowExport(false); } }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [showExport]); const loadLibrary = async () => { try { const blob = await fileOpen({ description: 'Excalidraw library files', extensions: ['.json', '.excalidrawlib'], mimeTypes: ['application/json'], }); const libraryItems = await loadLibraryFromBlob(blob); const dataList = libraryItems.map((libraryItem) => getNonDeletedElements(restoreElements(libraryItem.elements, null)), ); loadDataList( dataList.map((elements) => ({ elements, appState: {}, files: {} })), ); } catch (e) { console.error('Failed to load library:', e); setError('Failed to load library'); } }; const loadLink = (event: FormEvent) => { event.preventDefault(); const match = linkRegex.exec(link); if (!match) { window.alert('Invalid link'); return; } if (match[1]) { window.location.hash = match[0]; } else { window.location.hash = `library=${match[0]}`; } window.location.reload(); }; const togglePausedAnimations = useCallback(() => { if (!svgList.length) { return; } setPaused((p) => !p); }, [svgList]); const timer = useRef(undefined); const stepForwardAnimations = useCallback(() => { if (!svgList.length) { return; } const beginTimeList = getCombinedBeginTimeList(svgList); const currentTime = svgList[0].svg.getCurrentTime() * 1000; let nextTime = beginTimeList.find((t) => t > currentTime + 50); if (nextTime) { nextTime -= 1; } else { nextTime = currentTime + 500; } clearTimeout(timer.current as NodeJS.Timeout); svgList.forEach(({ svg }) => { svg.unpauseAnimations(); }); timer.current = setTimeout(() => { svgList.forEach(({ svg }) => { svg.pauseAnimations(); svg.setCurrentTime((nextTime as number) / 1000); }); setPaused(true); }, nextTime - currentTime); }, [svgList]); const resetAnimations = useCallback(() => { svgList.forEach(({ svg }) => { svg.setCurrentTime(0); }); }, [svgList]); useEffect(() => { const onKeydown = (e: KeyboardEvent) => { if (e.key.toLowerCase() === 'p') { togglePausedAnimations(); } else if (e.key.toLowerCase() === 's') { stepForwardAnimations(); } else if (e.key.toLowerCase() === 'r') { resetAnimations(); } else if (e.key.toLowerCase() === 'q') { // toggle toolbar setShowToolbar((s) => (typeof s === 'boolean' ? !s : s)); } else { // show toolbar otherwise setShowToolbar((s) => (typeof s === 'boolean' ? true : s)); } }; document.addEventListener('keydown', onKeydown); return () => { document.removeEventListener('keydown', onKeydown); }; }, [togglePausedAnimations, stepForwardAnimations, resetAnimations]); const hideToolbar = () => { setShowToolbar((s) => (typeof s === 'boolean' ? false : s)); }; const exportToSvg = () => { if (!svgList.length) { return; } svgList.forEach(({ svg }) => { if (exportTheme === 'light' && exportBackground) { exportToSvgFile(svg); } else if (exportTheme === 'light' && !exportBackground) { exportToSvgFile(removeBackgroundRect(svg)); } else if (exportTheme === 'dark' && exportBackground) { exportToSvgFile(applyThemeToSvg(svg, 'dark')); } else if (exportTheme === 'dark' && !exportBackground) { exportToSvgFile(applyThemeToSvg(removeBackgroundRect(svg), 'dark')); } }); }; const exportToWebm = async () => { if (!svgList.length) { return; } if (webmData) { await exportToWebmFile(webmData); return; } setProcessing(true); setShowToolbar(false); try { const data = await prepareWebmData(svgList); setWebmData(data); } catch (e) { console.log(e); } setShowToolbar(true); setProcessing(false); }; if (showToolbar !== true) { return null; } return ( <>
OR OR
setLink(e.target.value)} />
{!!svgList.length && (
)}
{showExport && ( setShowExport(false)} footerLabel="Export" footerTitle="Export with current settings to SVG" onFooterClick={() => { exportToSvg(); setShowExport(false); }} >
Background
setExportBackground(!exportBackground)} ariaLabel="Toggle background" title={ exportBackground ? 'Background enabled' : 'Background disabled' } />
Dark mode
setExportTheme(exportTheme === 'dark' ? 'light' : 'dark') } ariaLabel="Toggle dark mode" title={exportTheme === 'dark' ? 'Dark mode' : 'Light mode'} />
)} {error && ( setError(null)}>

{error}

)} ); }; export default Toolbar;