97c39d5e15
- Changes panel: Preview beside Whole file opens the page in a modal (HtmlViewerModal, shared with the File Explorer) instead of inside the panel; Changes and Whole file stay as they were. - Chat: an HTML file's card, Outputs row or a path in a reply (in the workspace or not) opens FilePreview as a modal — Preview | Code, versions, Save to Artifacts kept — rather than the side panel or the Changes panel. - Esc closes both modals: they stop every key from reaching the view below, which also kept useDialog's document listener from seeing it.
110 lines
3.4 KiB
TypeScript
110 lines
3.4 KiB
TypeScript
/**
|
|
* @file HtmlViewerModal.tsx
|
|
* @description An HTML file shown as the page in a modal over the whole
|
|
* screen — the one viewer the File Explorer and the Changes panel's Preview
|
|
* open (the Chat's file preview opens its own panel as a modal instead, for
|
|
* its versions and Code tab). The page itself is `HtmlFrame`: the chat
|
|
* artifact's sandbox, its relative files read through the caller's guarded
|
|
* `fetchFile`. Keys and clicks stop at the modal, so a shortcut of the view
|
|
* underneath (the explorer's Delete) never fires from inside it.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { useRef, type ReactNode } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Download, X } from "lucide-react";
|
|
import { useDialog } from "../hooks/useDialog";
|
|
import { HtmlFrame } from "./HtmlFrame";
|
|
import type { FetchFile } from "../lib/htmlInline";
|
|
|
|
export function HtmlViewerModal({
|
|
path,
|
|
html,
|
|
fetchFile,
|
|
onDownload,
|
|
onClose,
|
|
testId = "html-viewer",
|
|
}: {
|
|
/** The file's path relative to the place `fetchFile` reads from; also its title. */
|
|
path: string;
|
|
/** The page's text; read through `fetchFile` when absent. */
|
|
html?: string;
|
|
fetchFile?: FetchFile | null;
|
|
onDownload?: () => void;
|
|
onClose: () => void;
|
|
testId?: string;
|
|
}) {
|
|
const { t } = useTranslation(["lanes"]);
|
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
useDialog(true, panelRef, onClose);
|
|
const name = path.slice(path.lastIndexOf("/") + 1);
|
|
return createPortal(
|
|
<div
|
|
className="fixed inset-0 z-[70] flex items-center justify-center bg-black/60 p-3"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onClose();
|
|
}}
|
|
onKeyDown={(e) => {
|
|
// Nothing reaches the view underneath — so Esc, which useDialog's
|
|
// document listener would never see, closes here.
|
|
e.stopPropagation();
|
|
if (e.key === "Escape") onClose();
|
|
}}
|
|
role="presentation"
|
|
>
|
|
<div
|
|
ref={panelRef}
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-label={name}
|
|
data-testid={testId}
|
|
className="flex h-[92vh] w-[96vw] max-w-7xl flex-col overflow-hidden rounded-xl border border-border bg-surface-1 shadow-2xl shadow-black/50"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<div className="flex items-center gap-2 border-b border-border px-3 py-2">
|
|
<span className="min-w-0 flex-1 truncate font-mono text-[13px] text-fg-primary">
|
|
{path}
|
|
</span>
|
|
{onDownload && (
|
|
<HeadButton label={t("fx.download")} onClick={onDownload}>
|
|
<Download className="h-4 w-4" />
|
|
</HeadButton>
|
|
)}
|
|
<HeadButton label={t("fx.close")} onClick={onClose}>
|
|
<X className="h-4 w-4" />
|
|
</HeadButton>
|
|
</div>
|
|
<HtmlFrame path={path} html={html} fetchFile={fetchFile} title={name} />
|
|
</div>
|
|
</div>,
|
|
document.body
|
|
);
|
|
}
|
|
|
|
function HeadButton({
|
|
label,
|
|
onClick,
|
|
children,
|
|
}: {
|
|
label: string;
|
|
onClick: () => void;
|
|
children: ReactNode;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
aria-label={label}
|
|
title={label}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onClick();
|
|
}}
|
|
className="rounded-md p-1.5 text-fg-secondary transition-colors hover:bg-surface-2 hover:text-fg-primary"
|
|
>
|
|
{children}
|
|
</button>
|
|
);
|
|
}
|