import React from "react" import { Icon } from "@icones/react" import { useTranslate } from "../../lib/locale" import { getSandboxDefinition } from "./registry" import { SandboxErrorBoundary } from "./sandbox-error-boundary" import { SandboxFrame } from "./sandbox-frame" import { highlightSource, type HighlightedSource } from "./highlight-source" import type { SandboxDefinition, SandboxPreviewModule, SandboxSource, } from "./types" type SandboxView = "code" | "preview" const codeFontSizes = [12, 13, 14, 16] as const export type SandboxProps = { className?: string example: string title?: string } export function Sandbox({ className, example, title }: SandboxProps) { const definition = getSandboxDefinition(example) const t = useTranslate() if (!definition) { return ( {t({ zh: `找不到 Sandbox 示例:${example}`, us: `Sandbox example not found: ${example}`, })} ) } return ( ) } export type SingleFileSandboxProps = { className?: string component: React.ComponentType filename?: string source: string title?: string } export function SingleFileSandbox({ className, component, filename = "App.tsx", source, title, }: SingleFileSandboxProps) { const definition = React.useMemo( () => ({ defaultFile: filename, entry: async () => ({ default: component }), files: { [filename]: source }, id: `single-file:${filename}`, title, }), [component, filename, source, title] ) return } function SandboxViewer({ className, definition, title, }: { className?: string definition: SandboxDefinition title?: string }) { const rootRef = React.useRef(null) const t = useTranslate() const filenames = React.useMemo( () => Object.keys(definition.files), [definition.files] ) const [view, setView] = React.useState("preview") const [selectedFile, setSelectedFile] = React.useState( definition.defaultFile ?? filenames[0] ?? "" ) const [previewModule, setPreviewModule] = React.useState(null) const [previewError, setPreviewError] = React.useState(null) const [source, setSource] = React.useState(null) const [sourceError, setSourceError] = React.useState(null) const [highlightedSource, setHighlightedSource] = React.useState(null) const [codeFontSizeIndex, setCodeFontSizeIndex] = React.useState(1) const [reloadKey, setReloadKey] = React.useState(0) const [copied, setCopied] = React.useState(false) const [fullscreen, setFullscreen] = React.useState(false) const resolvedTitle = title ?? definition.title ?? definition.id React.useEffect(() => { const root = rootRef.current if (!root) return const ownerDocument = root.ownerDocument const syncFullscreen = () => { setFullscreen(ownerDocument.fullscreenElement === root) } syncFullscreen() ownerDocument.addEventListener("fullscreenchange", syncFullscreen) return () => { ownerDocument.removeEventListener("fullscreenchange", syncFullscreen) } }, []) React.useEffect(() => { let active = true setPreviewModule(null) setPreviewError(null) definition.entry().then( (module) => { if (active) setPreviewModule(module) }, (error: unknown) => { if (active) setPreviewError(toError(error)) } ) return () => { active = false } }, [definition]) React.useEffect(() => { if (view !== "code") return const file = definition.files[selectedFile] if (!file) { setSource(null) setSourceError(new Error(`Source file not found: ${selectedFile}`)) return } let active = true setSource(null) setSourceError(null) setHighlightedSource(null) resolveSource(file).then( (value) => { if (active) setSource(value) }, (error: unknown) => { if (active) setSourceError(toError(error)) } ) return () => { active = false } }, [definition.files, selectedFile, view]) React.useEffect(() => { if (!source || view !== "code") return let active = true setHighlightedSource(null) highlightSource(source, selectedFile).then( (value) => { if (active) setHighlightedSource(value) }, () => { if (active) setHighlightedSource(null) } ) return () => { active = false } }, [selectedFile, source, view]) React.useEffect(() => { setSelectedFile(definition.defaultFile ?? filenames[0] ?? "") }, [definition.defaultFile, filenames]) const copySource = async () => { if (!source) return await navigator.clipboard.writeText(source) setCopied(true) window.setTimeout(() => setCopied(false), 1500) } const toggleFullscreen = async () => { const root = rootRef.current if (!root) return const ownerDocument = root.ownerDocument if (ownerDocument.fullscreenElement === root) { await ownerDocument.exitFullscreen() return } if (ownerDocument.fullscreenElement) { await ownerDocument.exitFullscreen() } await root.requestFullscreen() } return (
setView("preview")} > {t({ zh: "预览", us: "Preview" })} setView("code")}> {t({ zh: "代码", us: "Code" })}
{resolvedTitle}
{view === "code" ? ( <> void copySource()} > ) : ( setReloadKey((value) => value + 1)} > )} void toggleFullscreen()} >
{view === "preview" ? ( {previewError ? ( {previewError.message} ) : previewModule ? ( React.createElement(previewModule.default, { key: reloadKey }) ) : ( {t({ zh: "正在加载预览…", us: "Loading preview…" })} )} ) : (
1 ? "md:grid-cols-[12rem_minmax(0,1fr)]" : ""}`} > {filenames.length > 1 ? ( ) : null}
            
              {sourceError ? (
                sourceError.message
              ) : highlightedSource ? (
                
              ) : (
                (source ?? t({ zh: "正在加载源码…", us: "Loading source…" }))
              )}
            
          
)}
) } function CodeFontSizeControl({ decreaseLabel, increaseLabel, index, onIndexChange, }: { decreaseLabel: string increaseLabel: string index: number onIndexChange: React.Dispatch> }) { return (
onIndexChange((value) => Math.max(0, value - 1))} > {codeFontSizes[index]} onIndexChange((value) => Math.min(codeFontSizes.length - 1, value + 1) ) } >
) } function HighlightedCode({ source }: { source: HighlightedSource }) { return source.map((line, lineIndex) => ( {line.tokens.map((token) => ( {token.content} ))} {lineIndex < source.length - 1 ? "\n" : null} )) } function SandboxTab({ active, children, onClick, }: { active: boolean children: React.ReactNode onClick: VoidFunction }) { return ( ) } function SandboxAction({ children, disabled, label, onClick, pressed, }: { children: React.ReactNode disabled?: boolean label: string onClick: VoidFunction pressed?: boolean }) { return ( ) } function SandboxMessage({ children, className, tone = "neutral", }: { children: React.ReactNode className?: string tone?: "error" | "neutral" }) { return (
{children}
) } async function resolveSource(source: SandboxSource) { if (typeof source === "string") return source const loaded = await source() return typeof loaded === "string" ? loaded : loaded.default } function toError(value: unknown) { return value instanceof Error ? value : new Error(String(value)) } function renderSandboxError(error: Error) { return {error.message} }