feat(webui): fullscreen toggle for html preview panel

This commit is contained in:
m4
2026-08-08 22:37:24 +08:00
parent 3e705023b6
commit bbea13b68a
+392 -344
View File
@@ -10,6 +10,8 @@ import React, {
import {
Download,
Loader2,
Maximize2,
Minimize2,
FileText,
Code,
Pencil,
@@ -189,6 +191,21 @@ export const WorkspaceFileDialog = React.memo<{
const [htmlView, setHtmlView] = useState<"preview" | "source">("preview");
// Bumped after a successful save so the preview iframe reloads new content.
const [savedTick, setSavedTick] = useState(0);
// Browser-level fullscreen for the preview panel (toolbar stays usable).
const previewAreaRef = useRef<HTMLDivElement>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
useEffect(() => {
const onChange = () =>
setIsFullscreen(document.fullscreenElement === previewAreaRef.current);
document.addEventListener("fullscreenchange", onChange);
return () => document.removeEventListener("fullscreenchange", onChange);
}, []);
const toggleFullscreen = () => {
if (document.fullscreenElement) void document.exitFullscreen();
else void previewAreaRef.current?.requestFullscreen();
};
const [discardAction, setDiscardAction] = useState<
"close" | "cancel-edit" | null
>(null);
@@ -492,114 +509,315 @@ export const WorkspaceFileDialog = React.memo<{
<DialogContent
aria-describedby={undefined}
className="flex h-[80vh] max-h-[80vh] min-w-[60vw] flex-col p-6"
onEscapeKeyDown={(e) => {
// While the preview panel is fullscreen, Esc must only exit
// fullscreen — not close the whole dialog.
if (document.fullscreenElement) e.preventDefault();
}}
>
<DialogTitle className="sr-only">{path}</DialogTitle>
<div className="mb-4 flex items-center justify-between gap-3 border-b border-border pb-4">
<div className="flex min-w-0 items-center gap-2">
<FileText
className="h-5 w-5 shrink-0 text-[var(--color-text-tertiary)]"
aria-hidden="true"
/>
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-base font-medium text-primary">
{path}
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
{editing ? (
<>
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={() => {
runOrConfirmDiscard("cancel-edit");
}}
disabled={saving}
>
<Eye
size={16}
className="mr-1"
aria-hidden="true"
/>
Cancel
</Button>
<Button
size="sm"
className="h-8 bg-[var(--brand-solid)] px-3 text-[var(--brand-foreground)] hover:opacity-90"
onClick={save}
disabled={saving || !dirty}
>
{saving ? (
<Loader2
size={16}
className="mr-1 animate-spin"
aria-hidden="true"
/>
) : (
<Save
size={16}
className="mr-1"
aria-hidden="true"
/>
)}
{saving ? "Saving…" : "Save"}
</Button>
</>
) : (
<>
{showHtmlToggle && (
<div
role="group"
aria-label="HTML view"
className="flex items-center rounded-md border border-border"
>
<Button
variant="ghost"
size="sm"
className={`h-8 rounded-r-none px-2 ${
htmlView === "preview" ? "bg-accent" : ""
}`}
onClick={() => setHtmlView("preview")}
aria-pressed={htmlView === "preview"}
>
<Eye size={16} className="mr-1" aria-hidden="true" />
Preview
</Button>
<Button
variant="ghost"
size="sm"
className={`h-8 rounded-l-none px-2 ${
htmlView === "source" ? "bg-accent" : ""
}`}
onClick={() => setHtmlView("source")}
aria-pressed={htmlView === "source"}
>
<Code size={16} className="mr-1" aria-hidden="true" />
Source
</Button>
</div>
)}
{editable && (
<div
ref={previewAreaRef}
className={
isFullscreen
? "flex h-full w-full flex-col bg-background p-4"
: "flex min-h-0 flex-1 flex-col"
}
>
<div className="mb-4 flex items-center justify-between gap-3 border-b border-border pb-4">
<div className="flex min-w-0 items-center gap-2">
<FileText
className="h-5 w-5 shrink-0 text-[var(--color-text-tertiary)]"
aria-hidden="true"
/>
<span className="overflow-hidden text-ellipsis whitespace-nowrap text-base font-medium text-primary">
{path}
</span>
</div>
<div className="flex shrink-0 items-center gap-1">
{editing ? (
<>
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={startEdit}
disabled={loading || content === null}
aria-label="Edit file"
onClick={() => {
runOrConfirmDiscard("cancel-edit");
}}
disabled={saving}
>
<Pencil
<Eye
size={16}
className="mr-1"
aria-hidden="true"
/>
Edit
Cancel
</Button>
)}
<Button
size="sm"
className="h-8 bg-[var(--brand-solid)] px-3 text-[var(--brand-foreground)] hover:opacity-90"
onClick={save}
disabled={saving || !dirty}
>
{saving ? (
<Loader2
size={16}
className="mr-1 animate-spin"
aria-hidden="true"
/>
) : (
<Save
size={16}
className="mr-1"
aria-hidden="true"
/>
)}
{saving ? "Saving…" : "Save"}
</Button>
</>
) : (
<>
{showHtmlToggle && (
<div
role="group"
aria-label="HTML view"
className="flex items-center rounded-md border border-border"
>
<Button
variant="ghost"
size="sm"
className={`h-8 rounded-r-none px-2 ${
htmlView === "preview" ? "bg-accent" : ""
}`}
onClick={() => setHtmlView("preview")}
aria-pressed={htmlView === "preview"}
>
<Eye size={16} className="mr-1" aria-hidden="true" />
Preview
</Button>
<Button
variant="ghost"
size="sm"
className={`h-8 rounded-l-none px-2 ${
htmlView === "source" ? "bg-accent" : ""
}`}
onClick={() => setHtmlView("source")}
aria-pressed={htmlView === "source"}
>
<Code size={16} className="mr-1" aria-hidden="true" />
Source
</Button>
</div>
)}
{showHtmlToggle && (
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={toggleFullscreen}
aria-label="Toggle fullscreen"
aria-pressed={isFullscreen}
title="Fullscreen"
>
{isFullscreen ? (
<Minimize2 size={16} aria-hidden="true" />
) : (
<Maximize2 size={16} aria-hidden="true" />
)}
</Button>
)}
{editable && (
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
onClick={startEdit}
disabled={loading || content === null}
aria-label="Edit file"
>
<Pencil
size={16}
className="mr-1"
aria-hidden="true"
/>
Edit
</Button>
)}
<Button
variant="ghost"
size="sm"
className="h-8 px-2"
asChild
>
<a
href={workspaceFileUrl(threadId, path, true)}
download={name}
>
<Download
size={16}
className="mr-1"
aria-hidden="true"
/>
Download
</a>
</Button>
<Button
variant="ghost"
size="sm"
className="h-8 px-2 text-muted-foreground hover:text-destructive"
onClick={() => setDeleteOpen(true)}
disabled={deleting}
aria-label="Delete file"
title="Delete file"
>
{deleting ? (
<Loader2
size={16}
className="animate-spin"
aria-hidden="true"
/>
) : (
<Trash2
size={16}
aria-hidden="true"
/>
)}
</Button>
</>
)}
</div>
</div>
{actionError && (
<p
role="alert"
className="mb-2 text-sm text-destructive"
>
{actionError}
</p>
)}
<div className="min-h-0 flex-1 overflow-hidden">
{editing ? (
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
spellCheck={false}
aria-label="File content"
className="h-full w-full resize-none rounded-md border border-border bg-background p-4 font-mono text-sm leading-relaxed text-foreground outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
placeholder="File is empty…"
/>
) : kind === "image" ? (
<div className="relative h-full overflow-hidden rounded-md bg-[var(--color-surface)]">
{/* key on path: switching files resets the zoom state. */}
<TransformWrapper
key={path}
initialScale={1}
minScale={0.2}
maxScale={8}
centerOnInit
smooth={false}
wheel={{ step: 0.05 }}
doubleClick={{ mode: "reset" }}
>
{({ zoomIn, zoomOut, resetTransform }) => (
<>
<div className="absolute right-2 top-2 z-10 flex gap-0.5 rounded-md border border-border bg-background/90 p-1 shadow-sm">
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
onClick={() => zoomOut()}
aria-label="Zoom out"
title="Zoom out"
>
<ZoomOut className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
onClick={() => zoomIn()}
aria-label="Zoom in"
title="Zoom in"
>
<ZoomIn className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
onClick={() => resetTransform()}
aria-label="Reset zoom"
title="Fit to window"
>
<RotateCcw className="size-4" />
</Button>
</div>
<TransformComponent
wrapperClass="!h-full !w-full"
contentClass="!h-full !w-full flex items-center justify-center"
>
<img
src={workspaceFileUrl(threadId, path)}
alt={name}
className="max-h-full max-w-full select-none object-contain"
draggable={false}
/>
</TransformComponent>
{onNavigate && (siblingNav.prev || siblingNav.next) && (
<>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute left-2 top-1/2 z-10 size-8 -translate-y-1/2 rounded-md border border-border bg-background/90 shadow-sm disabled:opacity-30"
onClick={() => navigateTo(siblingNav.prev)}
disabled={!siblingNav.prev}
aria-label="Previous image"
title="Previous image"
>
<ChevronLeft className="size-5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 z-10 size-8 -translate-y-1/2 rounded-md border border-border bg-background/90 shadow-sm disabled:opacity-30"
onClick={() => navigateTo(siblingNav.next)}
disabled={!siblingNav.next}
aria-label="Next image"
title="Next image"
>
<ChevronRight className="size-5" />
</Button>
</>
)}
</>
)}
</TransformWrapper>
</div>
) : kind === "pdf" ? (
<iframe
src={workspaceFileUrl(threadId, path)}
title={name}
className="h-full w-full rounded-md border border-border"
/>
) : kind === "binary" || tooBigForText || tooBigForOffice ? (
<div className="flex h-full flex-col items-center justify-center gap-3 p-12 text-center">
<p className="text-sm text-muted-foreground">
{tooBigForText
? "This file is too large to preview inline."
: tooBigForOffice
? "Office files larger than 25 MB can't be previewed inline."
: "This file type can't be previewed."}
</p>
<Button
variant="ghost"
variant="outline"
size="sm"
className="h-8 px-2"
asChild
>
<a
@@ -611,268 +829,98 @@ export const WorkspaceFileDialog = React.memo<{
className="mr-1"
aria-hidden="true"
/>
Download
Download file
</a>
</Button>
</div>
) : loading ? (
<div className="flex h-full items-center justify-center">
<Loader2
className="size-5 animate-spin text-muted-foreground"
aria-hidden="true"
/>
</div>
) : error ? (
<div className="flex h-full flex-col items-center justify-center gap-3 p-12 text-center">
<p className="text-sm text-destructive">{error}</p>
<Button
variant="ghost"
variant="outline"
size="sm"
className="h-8 px-2 text-muted-foreground hover:text-destructive"
onClick={() => setDeleteOpen(true)}
disabled={deleting}
aria-label="Delete file"
title="Delete file"
asChild
>
{deleting ? (
<Loader2
<a
href={workspaceFileUrl(threadId, path, true)}
download={name}
>
<Download
size={16}
className="animate-spin"
className="mr-1"
aria-hidden="true"
/>
) : (
<Trash2
size={16}
aria-hidden="true"
/>
)}
Download file
</a>
</Button>
</>
</div>
) : officePreview?.kind === "pdf" ? (
<iframe
src={officePreview.url}
title={`${name} preview`}
className="h-full w-full rounded-md border border-border"
/>
) : officePreview?.kind === "docx" ? (
<DocxPreview
document={officePreview.document}
onError={useDocxPreviewFallback}
/>
) : officePreview?.kind === "spreadsheet" ? (
<SpreadsheetPreview
document={officePreview.document}
onError={setError}
/>
) : isHtml && htmlView === "preview" && !tooBigForText ? (
<iframe
key={savedTick}
src={`${workspaceFileUrl(threadId, path)}&render=1`}
sandbox="allow-scripts"
title={name}
className="h-full w-full rounded-md border border-border bg-white"
/>
) : (
<ScrollArea className="h-full rounded-md bg-[var(--color-surface)]">
<div className="p-4">
{content && content.length > 0 ? (
isMarkdown ? (
<div className="rounded-md p-6">
<MarkdownContent content={content} />
</div>
) : (
<SyntaxHighlighter
language={language}
style={oneDark}
customStyle={{
margin: 0,
borderRadius: "0.5rem",
fontSize: "0.875rem",
}}
showLineNumbers
wrapLines={true}
lineProps={{ style: { whiteSpace: "pre-wrap" } }}
>
{content}
</SyntaxHighlighter>
)
) : (
<div className="flex items-center justify-center p-12">
<p className="text-sm text-muted-foreground">
File is empty
</p>
</div>
)}
</div>
</ScrollArea>
)}
</div>
</div>
{actionError && (
<p
role="alert"
className="mb-2 text-sm text-destructive"
>
{actionError}
</p>
)}
<div className="min-h-0 flex-1 overflow-hidden">
{editing ? (
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
spellCheck={false}
aria-label="File content"
className="h-full w-full resize-none rounded-md border border-border bg-background p-4 font-mono text-sm leading-relaxed text-foreground outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
placeholder="File is empty…"
/>
) : kind === "image" ? (
<div className="relative h-full overflow-hidden rounded-md bg-[var(--color-surface)]">
{/* key on path: switching files resets the zoom state. */}
<TransformWrapper
key={path}
initialScale={1}
minScale={0.2}
maxScale={8}
centerOnInit
smooth={false}
wheel={{ step: 0.05 }}
doubleClick={{ mode: "reset" }}
>
{({ zoomIn, zoomOut, resetTransform }) => (
<>
<div className="absolute right-2 top-2 z-10 flex gap-0.5 rounded-md border border-border bg-background/90 p-1 shadow-sm">
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
onClick={() => zoomOut()}
aria-label="Zoom out"
title="Zoom out"
>
<ZoomOut className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
onClick={() => zoomIn()}
aria-label="Zoom in"
title="Zoom in"
>
<ZoomIn className="size-4" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="size-7"
onClick={() => resetTransform()}
aria-label="Reset zoom"
title="Fit to window"
>
<RotateCcw className="size-4" />
</Button>
</div>
<TransformComponent
wrapperClass="!h-full !w-full"
contentClass="!h-full !w-full flex items-center justify-center"
>
<img
src={workspaceFileUrl(threadId, path)}
alt={name}
className="max-h-full max-w-full select-none object-contain"
draggable={false}
/>
</TransformComponent>
{onNavigate && (siblingNav.prev || siblingNav.next) && (
<>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute left-2 top-1/2 z-10 size-8 -translate-y-1/2 rounded-md border border-border bg-background/90 shadow-sm disabled:opacity-30"
onClick={() => navigateTo(siblingNav.prev)}
disabled={!siblingNav.prev}
aria-label="Previous image"
title="Previous image"
>
<ChevronLeft className="size-5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className="absolute right-2 top-1/2 z-10 size-8 -translate-y-1/2 rounded-md border border-border bg-background/90 shadow-sm disabled:opacity-30"
onClick={() => navigateTo(siblingNav.next)}
disabled={!siblingNav.next}
aria-label="Next image"
title="Next image"
>
<ChevronRight className="size-5" />
</Button>
</>
)}
</>
)}
</TransformWrapper>
</div>
) : kind === "pdf" ? (
<iframe
src={workspaceFileUrl(threadId, path)}
title={name}
className="h-full w-full rounded-md border border-border"
/>
) : kind === "binary" || tooBigForText || tooBigForOffice ? (
<div className="flex h-full flex-col items-center justify-center gap-3 p-12 text-center">
<p className="text-sm text-muted-foreground">
{tooBigForText
? "This file is too large to preview inline."
: tooBigForOffice
? "Office files larger than 25 MB can't be previewed inline."
: "This file type can't be previewed."}
</p>
<Button
variant="outline"
size="sm"
asChild
>
<a
href={workspaceFileUrl(threadId, path, true)}
download={name}
>
<Download
size={16}
className="mr-1"
aria-hidden="true"
/>
Download file
</a>
</Button>
</div>
) : loading ? (
<div className="flex h-full items-center justify-center">
<Loader2
className="size-5 animate-spin text-muted-foreground"
aria-hidden="true"
/>
</div>
) : error ? (
<div className="flex h-full flex-col items-center justify-center gap-3 p-12 text-center">
<p className="text-sm text-destructive">{error}</p>
<Button
variant="outline"
size="sm"
asChild
>
<a
href={workspaceFileUrl(threadId, path, true)}
download={name}
>
<Download
size={16}
className="mr-1"
aria-hidden="true"
/>
Download file
</a>
</Button>
</div>
) : officePreview?.kind === "pdf" ? (
<iframe
src={officePreview.url}
title={`${name} preview`}
className="h-full w-full rounded-md border border-border"
/>
) : officePreview?.kind === "docx" ? (
<DocxPreview
document={officePreview.document}
onError={useDocxPreviewFallback}
/>
) : officePreview?.kind === "spreadsheet" ? (
<SpreadsheetPreview
document={officePreview.document}
onError={setError}
/>
) : isHtml && htmlView === "preview" && !tooBigForText ? (
<iframe
key={savedTick}
src={`${workspaceFileUrl(threadId, path)}&render=1`}
sandbox="allow-scripts"
title={name}
className="h-full w-full rounded-md border border-border bg-white"
/>
) : (
<ScrollArea className="h-full rounded-md bg-[var(--color-surface)]">
<div className="p-4">
{content && content.length > 0 ? (
isMarkdown ? (
<div className="rounded-md p-6">
<MarkdownContent content={content} />
</div>
) : (
<SyntaxHighlighter
language={language}
style={oneDark}
customStyle={{
margin: 0,
borderRadius: "0.5rem",
fontSize: "0.875rem",
}}
showLineNumbers
wrapLines={true}
lineProps={{ style: { whiteSpace: "pre-wrap" } }}
>
{content}
</SyntaxHighlighter>
)
) : (
<div className="flex items-center justify-center p-12">
<p className="text-sm text-muted-foreground">
File is empty
</p>
</div>
)}
</div>
</ScrollArea>
)}
</div>
</DialogContent>
</Dialog>