feat(docs): add sandbox fullscreen mode
This commit is contained in:
@@ -5,10 +5,12 @@ const minimumFrameHeight = 288
|
|||||||
|
|
||||||
export function SandboxFrame({
|
export function SandboxFrame({
|
||||||
children,
|
children,
|
||||||
|
fill = false,
|
||||||
reloadKey,
|
reloadKey,
|
||||||
title,
|
title,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
|
fill?: boolean
|
||||||
reloadKey: number
|
reloadKey: number
|
||||||
title: string
|
title: string
|
||||||
}) {
|
}) {
|
||||||
@@ -71,7 +73,7 @@ export function SandboxFrame({
|
|||||||
}, [reloadKey])
|
}, [reloadKey])
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
if (!mountNode) return
|
if (!mountNode || fill) return
|
||||||
|
|
||||||
const frameDocument = mountNode.ownerDocument
|
const frameDocument = mountNode.ownerDocument
|
||||||
const updateHeight = () => {
|
const updateHeight = () => {
|
||||||
@@ -87,14 +89,14 @@ export function SandboxFrame({
|
|||||||
updateHeight()
|
updateHeight()
|
||||||
|
|
||||||
return () => resizeObserver.disconnect()
|
return () => resizeObserver.disconnect()
|
||||||
}, [mountNode])
|
}, [fill, mountNode])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<iframe
|
<iframe
|
||||||
ref={frameRef}
|
ref={frameRef}
|
||||||
className="block w-full border-0 bg-white dark:bg-slate-950"
|
className={`block w-full border-0 bg-white dark:bg-slate-950 ${fill ? "min-h-0 flex-1" : ""}`}
|
||||||
style={{ height }}
|
style={{ height: fill ? "100%" : height }}
|
||||||
sandbox="allow-same-origin"
|
sandbox="allow-same-origin"
|
||||||
title={title}
|
title={title}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -84,23 +84,45 @@ function SandboxViewer({
|
|||||||
definition: SandboxDefinition
|
definition: SandboxDefinition
|
||||||
title?: string
|
title?: string
|
||||||
}) {
|
}) {
|
||||||
|
const rootRef = React.useRef<HTMLElement>(null)
|
||||||
const t = useTranslate()
|
const t = useTranslate()
|
||||||
const filenames = React.useMemo(
|
const filenames = React.useMemo(
|
||||||
() => Object.keys(definition.files),
|
() => Object.keys(definition.files),
|
||||||
[definition.files]
|
[definition.files]
|
||||||
)
|
)
|
||||||
const [view, setView] = React.useState<SandboxView>("preview")
|
const [view, setView] = React.useState<SandboxView>("preview")
|
||||||
const [selectedFile, setSelectedFile] = React.useState(definition.defaultFile ?? filenames[0] ?? "")
|
const [selectedFile, setSelectedFile] = React.useState(
|
||||||
const [previewModule, setPreviewModule] = React.useState<SandboxPreviewModule | null>(null)
|
definition.defaultFile ?? filenames[0] ?? ""
|
||||||
|
)
|
||||||
|
const [previewModule, setPreviewModule] =
|
||||||
|
React.useState<SandboxPreviewModule | null>(null)
|
||||||
const [previewError, setPreviewError] = React.useState<Error | null>(null)
|
const [previewError, setPreviewError] = React.useState<Error | null>(null)
|
||||||
const [source, setSource] = React.useState<string | null>(null)
|
const [source, setSource] = React.useState<string | null>(null)
|
||||||
const [sourceError, setSourceError] = React.useState<Error | null>(null)
|
const [sourceError, setSourceError] = React.useState<Error | null>(null)
|
||||||
const [highlightedSource, setHighlightedSource] = React.useState<HighlightedSource | null>(null)
|
const [highlightedSource, setHighlightedSource] =
|
||||||
|
React.useState<HighlightedSource | null>(null)
|
||||||
const [codeFontSizeIndex, setCodeFontSizeIndex] = React.useState(1)
|
const [codeFontSizeIndex, setCodeFontSizeIndex] = React.useState(1)
|
||||||
const [reloadKey, setReloadKey] = React.useState(0)
|
const [reloadKey, setReloadKey] = React.useState(0)
|
||||||
const [copied, setCopied] = React.useState(false)
|
const [copied, setCopied] = React.useState(false)
|
||||||
|
const [fullscreen, setFullscreen] = React.useState(false)
|
||||||
const resolvedTitle = title ?? definition.title ?? definition.id
|
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(() => {
|
React.useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
setPreviewModule(null)
|
setPreviewModule(null)
|
||||||
@@ -178,9 +200,26 @@ function SandboxViewer({
|
|||||||
window.setTimeout(() => setCopied(false), 1500)
|
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 (
|
return (
|
||||||
<section
|
<section
|
||||||
className={`not-prose my-6 overflow-hidden rounded-xl border border-slate-200 bg-white shadow-sm dark:border-slate-800 dark:bg-slate-950 ${className ?? ""}`}
|
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}
|
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="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">
|
||||||
@@ -230,11 +269,28 @@ function SandboxViewer({
|
|||||||
<Icon name="lucide:rotate-ccw" className="size-4" />
|
<Icon name="lucide:rotate-ccw" className="size-4" />
|
||||||
</SandboxAction>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{view === "preview" ? (
|
{view === "preview" ? (
|
||||||
<SandboxFrame reloadKey={reloadKey} title={resolvedTitle}>
|
<SandboxFrame
|
||||||
|
fill={fullscreen}
|
||||||
|
reloadKey={reloadKey}
|
||||||
|
title={resolvedTitle}
|
||||||
|
>
|
||||||
<SandboxErrorBoundary key={reloadKey} fallback={renderSandboxError}>
|
<SandboxErrorBoundary key={reloadKey} fallback={renderSandboxError}>
|
||||||
{previewError ? (
|
{previewError ? (
|
||||||
<SandboxMessage tone="error">
|
<SandboxMessage tone="error">
|
||||||
@@ -251,7 +307,7 @@ function SandboxViewer({
|
|||||||
</SandboxFrame>
|
</SandboxFrame>
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
className={`grid min-h-72 ${filenames.length > 1 ? "md:grid-cols-[12rem_minmax(0,1fr)]" : ""}`}
|
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 ? (
|
{filenames.length > 1 ? (
|
||||||
<nav
|
<nav
|
||||||
@@ -385,17 +441,20 @@ function SandboxAction({
|
|||||||
disabled,
|
disabled,
|
||||||
label,
|
label,
|
||||||
onClick,
|
onClick,
|
||||||
|
pressed,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
disabled?: boolean
|
disabled?: boolean
|
||||||
label: string
|
label: string
|
||||||
onClick: VoidFunction
|
onClick: VoidFunction
|
||||||
|
pressed?: boolean
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="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"
|
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-label={label}
|
||||||
|
aria-pressed={pressed}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user