520 lines
15 KiB
TypeScript
520 lines
15 KiB
TypeScript
import React from "react"
|
|
import { Icon } from "@icones/react"
|
|
|
|
import { useLocale, 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,
|
|
SandboxLocalizedText,
|
|
SandboxPreviewProps,
|
|
SandboxPreviewModule,
|
|
SandboxSource,
|
|
} from "./types"
|
|
|
|
type SandboxView = "code" | "preview"
|
|
|
|
const codeFontSizes = [12, 13, 14, 16] as const
|
|
|
|
export type SandboxProps = {
|
|
className?: string
|
|
example: string
|
|
title?: SandboxLocalizedText
|
|
}
|
|
|
|
export function Sandbox({ className, example, title }: SandboxProps) {
|
|
const definition = getSandboxDefinition(example)
|
|
const t = useTranslate()
|
|
|
|
if (!definition) {
|
|
return (
|
|
<SandboxMessage className={className} tone="error">
|
|
{t({
|
|
zh: `找不到 Sandbox 示例:${example}`,
|
|
us: `Sandbox example not found: ${example}`,
|
|
})}
|
|
</SandboxMessage>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<SandboxViewer
|
|
className={className}
|
|
definition={definition}
|
|
title={title}
|
|
/>
|
|
)
|
|
}
|
|
|
|
export type SingleFileSandboxProps = {
|
|
className?: string
|
|
component: React.ComponentType
|
|
filename?: string
|
|
source: string
|
|
title?: SandboxLocalizedText
|
|
}
|
|
|
|
export function SingleFileSandbox({
|
|
className,
|
|
component,
|
|
filename = "App.tsx",
|
|
source,
|
|
title,
|
|
}: SingleFileSandboxProps) {
|
|
const definition = React.useMemo<SandboxDefinition>(
|
|
() => ({
|
|
defaultFile: filename,
|
|
entry: async () => ({
|
|
default: component as React.ComponentType<SandboxPreviewProps>,
|
|
}),
|
|
files: { [filename]: source },
|
|
id: `single-file:${filename}`,
|
|
title,
|
|
}),
|
|
[component, filename, source, title]
|
|
)
|
|
|
|
return <SandboxViewer className={className} definition={definition} />
|
|
}
|
|
|
|
function SandboxViewer({
|
|
className,
|
|
definition,
|
|
title,
|
|
}: {
|
|
className?: string
|
|
definition: SandboxDefinition
|
|
title?: SandboxLocalizedText
|
|
}) {
|
|
const rootRef = React.useRef<HTMLElement>(null)
|
|
const locale = useLocale()
|
|
const t = useTranslate()
|
|
const filenames = React.useMemo(
|
|
() => Object.keys(definition.files),
|
|
[definition.files]
|
|
)
|
|
const [view, setView] = React.useState<SandboxView>("preview")
|
|
const [selectedFile, setSelectedFile] = React.useState(
|
|
definition.defaultFile ?? filenames[0] ?? ""
|
|
)
|
|
const [previewModule, setPreviewModule] =
|
|
React.useState<SandboxPreviewModule | null>(null)
|
|
const [previewError, setPreviewError] = React.useState<Error | null>(null)
|
|
const [source, setSource] = React.useState<string | null>(null)
|
|
const [sourceError, setSourceError] = React.useState<Error | null>(null)
|
|
const [highlightedSource, setHighlightedSource] =
|
|
React.useState<HighlightedSource | null>(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 = resolveSandboxText(
|
|
title ?? definition.title,
|
|
locale,
|
|
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 (
|
|
<section
|
|
ref={rootRef}
|
|
className={`not-prose overflow-hidden border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950 ${fullscreen ? "m-0 flex h-dvh w-dvw flex-col rounded-none border-0" : "my-6 rounded-xl"} ${className ?? ""}`}
|
|
aria-label={resolvedTitle}
|
|
>
|
|
<div className="flex min-h-11 flex-wrap items-center gap-2 border-b border-slate-200 bg-slate-50 px-2 py-1.5 dark:border-slate-800 dark:bg-slate-900/70">
|
|
<div className="inline-flex rounded-lg bg-slate-200/70 p-0.5 dark:bg-slate-800">
|
|
<SandboxTab
|
|
active={view === "preview"}
|
|
onClick={() => setView("preview")}
|
|
>
|
|
<Icon name="lucide:play" className="size-3.5" />
|
|
{t({ zh: "预览", us: "Preview" })}
|
|
</SandboxTab>
|
|
<SandboxTab active={view === "code"} onClick={() => setView("code")}>
|
|
<Icon name="lucide:code-2" className="size-3.5" />
|
|
{t({ zh: "代码", us: "Code" })}
|
|
</SandboxTab>
|
|
</div>
|
|
|
|
<span className="min-w-0 truncate px-1 text-xs text-slate-500 dark:text-slate-400">
|
|
{resolvedTitle}
|
|
</span>
|
|
|
|
<div className="ml-auto flex items-center gap-1">
|
|
{view === "code" ? (
|
|
<>
|
|
<CodeFontSizeControl
|
|
decreaseLabel={t({ zh: "减小字号", us: "Decrease font size" })}
|
|
increaseLabel={t({ zh: "增大字号", us: "Increase font size" })}
|
|
index={codeFontSizeIndex}
|
|
onIndexChange={setCodeFontSizeIndex}
|
|
/>
|
|
<SandboxAction
|
|
disabled={!source}
|
|
label={t({ zh: "复制源码", us: "Copy source" })}
|
|
onClick={() => void copySource()}
|
|
>
|
|
<Icon
|
|
name={copied ? "lucide:check" : "lucide:copy"}
|
|
className="size-4"
|
|
/>
|
|
</SandboxAction>
|
|
</>
|
|
) : (
|
|
<SandboxAction
|
|
label={t({ zh: "重置预览", us: "Reset preview" })}
|
|
onClick={() => setReloadKey((value) => value + 1)}
|
|
>
|
|
<Icon name="lucide:rotate-ccw" className="size-4" />
|
|
</SandboxAction>
|
|
)}
|
|
<SandboxAction
|
|
label={t({
|
|
zh: fullscreen ? "退出全屏" : "全屏显示",
|
|
us: fullscreen ? "Exit fullscreen" : "Enter fullscreen",
|
|
})}
|
|
pressed={fullscreen}
|
|
onClick={() => void toggleFullscreen()}
|
|
>
|
|
<Icon
|
|
name={fullscreen ? "lucide:minimize-2" : "lucide:maximize-2"}
|
|
className="size-4"
|
|
/>
|
|
</SandboxAction>
|
|
</div>
|
|
</div>
|
|
|
|
{view === "preview" ? (
|
|
<SandboxFrame
|
|
fill={fullscreen}
|
|
locale={locale}
|
|
reloadKey={reloadKey}
|
|
title={resolvedTitle}
|
|
>
|
|
<SandboxErrorBoundary key={reloadKey} fallback={renderSandboxError}>
|
|
{previewError ? (
|
|
<SandboxMessage tone="error">
|
|
{previewError.message}
|
|
</SandboxMessage>
|
|
) : previewModule ? (
|
|
React.createElement(previewModule.default, {
|
|
key: reloadKey,
|
|
locale,
|
|
})
|
|
) : (
|
|
<SandboxMessage>
|
|
{t({ zh: "正在加载预览…", us: "Loading preview…" })}
|
|
</SandboxMessage>
|
|
)}
|
|
</SandboxErrorBoundary>
|
|
</SandboxFrame>
|
|
) : (
|
|
<div
|
|
className={`grid min-h-72 ${fullscreen ? "min-h-0 flex-1 overflow-hidden" : ""} ${filenames.length > 1 ? "md:grid-cols-[12rem_minmax(0,1fr)]" : ""}`}
|
|
>
|
|
{filenames.length > 1 ? (
|
|
<nav
|
|
className="flex overflow-x-auto border-b border-slate-200 bg-slate-50 p-2 md:flex-col md:border-r md:border-b-0 dark:border-slate-800 dark:bg-slate-900/40"
|
|
aria-label={t({ zh: "示例文件", us: "Example files" })}
|
|
>
|
|
{filenames.map((filename) => (
|
|
<button
|
|
key={filename}
|
|
type="button"
|
|
className="rounded-md px-2 py-1.5 text-left font-mono text-xs whitespace-nowrap text-slate-500 hover:bg-slate-200/70 hover:text-slate-950 aria-pressed:bg-indigo-100 aria-pressed:text-indigo-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100 dark:aria-pressed:bg-indigo-950 dark:aria-pressed:text-indigo-300"
|
|
aria-pressed={selectedFile === filename}
|
|
onClick={() => setSelectedFile(filename)}
|
|
>
|
|
{filename}
|
|
</button>
|
|
))}
|
|
</nav>
|
|
) : null}
|
|
<pre
|
|
className="m-0 min-w-0 overflow-auto bg-slate-50 p-4 font-mono text-slate-800 dark:bg-slate-950 dark:text-slate-100"
|
|
style={{
|
|
fontSize: `${codeFontSizes[codeFontSizeIndex]}px`,
|
|
lineHeight: 1.55,
|
|
}}
|
|
>
|
|
<code className="block min-w-max">
|
|
{sourceError ? (
|
|
sourceError.message
|
|
) : highlightedSource ? (
|
|
<HighlightedCode source={highlightedSource} />
|
|
) : (
|
|
(source ?? t({ zh: "正在加载源码…", us: "Loading source…" }))
|
|
)}
|
|
</code>
|
|
</pre>
|
|
</div>
|
|
)}
|
|
</section>
|
|
)
|
|
}
|
|
|
|
function resolveSandboxText(
|
|
value: SandboxLocalizedText | undefined,
|
|
locale: "en-US" | "zh-Hans",
|
|
fallback: string
|
|
) {
|
|
if (typeof value === "string") return value
|
|
return value?.[locale] ?? fallback
|
|
}
|
|
|
|
function CodeFontSizeControl({
|
|
decreaseLabel,
|
|
increaseLabel,
|
|
index,
|
|
onIndexChange,
|
|
}: {
|
|
decreaseLabel: string
|
|
increaseLabel: string
|
|
index: number
|
|
onIndexChange: React.Dispatch<React.SetStateAction<number>>
|
|
}) {
|
|
return (
|
|
<div
|
|
className="inline-flex h-8 items-center rounded-md border border-slate-200 bg-white dark:border-slate-700 dark:bg-slate-900"
|
|
aria-label={`${codeFontSizes[index]}px`}
|
|
>
|
|
<SandboxAction
|
|
disabled={index === 0}
|
|
label={decreaseLabel}
|
|
onClick={() => onIndexChange((value) => Math.max(0, value - 1))}
|
|
>
|
|
<Icon name="lucide:minus" className="size-3.5" />
|
|
</SandboxAction>
|
|
<span className="w-9 text-center font-mono text-[11px] text-slate-500 tabular-nums dark:text-slate-400">
|
|
{codeFontSizes[index]}
|
|
</span>
|
|
<SandboxAction
|
|
disabled={index === codeFontSizes.length - 1}
|
|
label={increaseLabel}
|
|
onClick={() =>
|
|
onIndexChange((value) =>
|
|
Math.min(codeFontSizes.length - 1, value + 1)
|
|
)
|
|
}
|
|
>
|
|
<Icon name="lucide:plus" className="size-3.5" />
|
|
</SandboxAction>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function HighlightedCode({ source }: { source: HighlightedSource }) {
|
|
return source.map((line, lineIndex) => (
|
|
<React.Fragment key={line.offset}>
|
|
<span className="sandbox-code-line">
|
|
{line.tokens.map((token) => (
|
|
<span
|
|
key={token.offset}
|
|
className="sandbox-code-token"
|
|
style={
|
|
{
|
|
"--sandbox-token-dark": token.dark,
|
|
"--sandbox-token-light": token.light,
|
|
} as React.CSSProperties
|
|
}
|
|
>
|
|
{token.content}
|
|
</span>
|
|
))}
|
|
</span>
|
|
{lineIndex < source.length - 1 ? "\n" : null}
|
|
</React.Fragment>
|
|
))
|
|
}
|
|
|
|
function SandboxTab({
|
|
active,
|
|
children,
|
|
onClick,
|
|
}: {
|
|
active: boolean
|
|
children: React.ReactNode
|
|
onClick: VoidFunction
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="inline-flex h-7 items-center gap-1.5 rounded-md px-2 text-xs font-medium text-slate-500 transition-colors hover:text-slate-950 aria-pressed:bg-white aria-pressed:text-slate-950 aria-pressed:shadow-sm dark:text-slate-400 dark:hover:text-slate-100 dark:aria-pressed:bg-slate-700 dark:aria-pressed:text-slate-100"
|
|
aria-pressed={active}
|
|
onClick={onClick}
|
|
>
|
|
{children}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function SandboxAction({
|
|
children,
|
|
disabled,
|
|
label,
|
|
onClick,
|
|
pressed,
|
|
}: {
|
|
children: React.ReactNode
|
|
disabled?: boolean
|
|
label: string
|
|
onClick: VoidFunction
|
|
pressed?: boolean
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className="inline-flex size-8 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-slate-200 hover:text-slate-950 disabled:opacity-40 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-100"
|
|
aria-label={label}
|
|
aria-pressed={pressed}
|
|
disabled={disabled}
|
|
onClick={onClick}
|
|
>
|
|
{children}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function SandboxMessage({
|
|
children,
|
|
className,
|
|
tone = "neutral",
|
|
}: {
|
|
children: React.ReactNode
|
|
className?: string
|
|
tone?: "error" | "neutral"
|
|
}) {
|
|
return (
|
|
<div
|
|
className={`flex min-h-72 items-center justify-center p-6 text-sm ${tone === "error" ? "text-red-600 dark:text-red-400" : "text-slate-500 dark:text-slate-400"} ${className ?? ""}`}
|
|
role={tone === "error" ? "alert" : undefined}
|
|
>
|
|
{children}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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 <SandboxMessage tone="error">{error.message}</SandboxMessage>
|
|
}
|