diff --git a/packages/views/editor/attachment-block.test.tsx b/packages/views/editor/attachment-block.test.tsx new file mode 100644 index 000000000..d311379b6 --- /dev/null +++ b/packages/views/editor/attachment-block.test.tsx @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +const { getAttachmentTextContentMock } = vi.hoisted(() => ({ + getAttachmentTextContentMock: vi.fn(), +})); + +vi.mock("@multica/core/api", () => ({ + api: { getAttachmentTextContent: getAttachmentTextContentMock }, + PreviewTooLargeError: class extends Error {}, + PreviewUnsupportedError: class extends Error {}, +})); + +vi.mock("../i18n", () => ({ + useT: () => ({ + t: (sel: (s: Record>) => string) => + sel({ + image: { download: "Download" }, + attachment: { + preview: "Preview", + preview_loading: "Loading preview…", + preview_failed: "Couldn't load preview", + }, + code_block: { copy_code: "Copy code" }, + file_card: { uploading: "Uploading {{filename}}" }, + }), + }), +})); + +import { AttachmentBlock } from "./attachment-block"; + +function renderWithQuery(ui: ReactElement) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + return render({ui}); +} + +beforeEach(() => vi.clearAllMocks()); +afterEach(() => vi.restoreAllMocks()); + +describe("AttachmentBlock — dispatcher", () => { + it("routes html + attachmentId to HtmlAttachmentPreview (no file-card chrome)", () => { + getAttachmentTextContentMock.mockResolvedValueOnce({ + text: "

chart

", + originalContentType: "text/html", + }); + renderWithQuery( + {}} + onDownload={() => {}} + />, + ); + // HtmlAttachmentPreview never renders the filename row — that's the + // file-card chrome it replaces. + expect(screen.queryByText("report.html")).toBeNull(); + // Toolbar shows the Maximize-style preview button. + expect(screen.getByTitle("Preview")).toBeTruthy(); + expect(screen.getByTitle("Copy code")).toBeTruthy(); + }); + + it("routes html WITHOUT attachmentId to AttachmentCard (URL-only is chrome-only)", () => { + renderWithQuery( + {}} + onDownload={() => {}} + />, + ); + expect(screen.getByText("report.html")).toBeTruthy(); + expect(document.querySelector("iframe")).toBeNull(); + }); + + it("routes html while uploading to AttachmentCard (no iframe before upload finishes)", () => { + renderWithQuery( + {}} + onDownload={() => {}} + />, + ); + // Uploading state surfaces the chrome row with the upload template. + expect(screen.getByText("Uploading {{filename}}")).toBeTruthy(); + expect(document.querySelector("iframe")).toBeNull(); + }); + + it("routes non-html kinds (pdf, image, other) to AttachmentCard", () => { + renderWithQuery( + {}} + onDownload={() => {}} + />, + ); + expect(screen.getByText("manual.pdf")).toBeTruthy(); + expect(document.querySelector("iframe")).toBeNull(); + }); +}); diff --git a/packages/views/editor/attachment-block.tsx b/packages/views/editor/attachment-block.tsx new file mode 100644 index 000000000..5b178018c --- /dev/null +++ b/packages/views/editor/attachment-block.tsx @@ -0,0 +1,42 @@ +"use client"; + +/** + * AttachmentBlock — thin dispatcher choosing the renderer for an attachment. + * + * Centralizes the kind-aware routing that previously lived inside + * AttachmentCard (and earlier still, was duplicated across three call sites). + * Three entry points render attachments and all must agree on which kinds get + * which visual treatment, otherwise feature work has to be repeated three + * times — see MUL-2330, where the inline HTML preview was missed on the + * standalone path. + * + * Current routing: + * - kind === "html" + attachmentId + !uploading → HtmlAttachmentPreview + * (independent iframe + hover toolbar, no file-card chrome) + * - everything else → AttachmentCard (icon + filename + Eye/Download row) + * + * Props match AttachmentCardProps exactly so callers stay as-is — only the + * import changes. + */ + +import { getPreviewKind } from "./utils/preview"; +import { AttachmentCard, type AttachmentCardProps } from "./attachment-card"; +import { HtmlAttachmentPreview } from "./html-attachment-preview"; + +export type AttachmentBlockProps = AttachmentCardProps; + +export function AttachmentBlock(props: AttachmentBlockProps) { + const { filename, contentType = "", attachmentId, uploading } = props; + const kind = filename ? getPreviewKind(contentType, filename) : null; + if (kind === "html" && attachmentId && !uploading) { + return ( + + ); + } + return ; +} diff --git a/packages/views/editor/attachment-card.test.tsx b/packages/views/editor/attachment-card.test.tsx index fe722e120..396a1612a 100644 --- a/packages/views/editor/attachment-card.test.tsx +++ b/packages/views/editor/attachment-card.test.tsx @@ -1,19 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { fireEvent, render as rtlRender, screen, waitFor } from "@testing-library/react"; -import type { ReactElement } from "react"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; - -// vi.hoisted lets us reference the mock from inside the vi.mock factory -// even though the factory hoists above the file's top-level statements. -const { getAttachmentTextContentMock } = vi.hoisted(() => ({ - getAttachmentTextContentMock: vi.fn(), -})); - -vi.mock("@multica/core/api", () => ({ - api: { getAttachmentTextContent: getAttachmentTextContentMock }, - PreviewTooLargeError: class extends Error {}, - PreviewUnsupportedError: class extends Error {}, -})); +import { fireEvent, render, screen } from "@testing-library/react"; vi.mock("../i18n", () => ({ useT: () => ({ @@ -31,55 +17,29 @@ vi.mock("../i18n", () => ({ import { AttachmentCard } from "./attachment-card"; -function render(ui: ReactElement) { - const qc = new QueryClient({ - defaultOptions: { queries: { retry: false, gcTime: 0 } }, - }); - return rtlRender({ui}); -} - beforeEach(() => vi.clearAllMocks()); afterEach(() => vi.restoreAllMocks()); -describe("AttachmentCard — kind dispatch", () => { - it("renders chrome only for non-html kinds (image, video, other)", () => { - render( - {}} - onDownload={() => {}} - />, - ); - expect(screen.getByText("snapshot.png")).toBeTruthy(); - // No inline iframe for an image-kind attachment. - expect(document.querySelector("iframe")).toBeNull(); - }); - - it("renders chrome only for an html URL-only source (no attachmentId)", () => { +describe("AttachmentCard — chrome row", () => { + it("renders chrome only and never an inline iframe (HTML rich preview lives in HtmlAttachmentPreview)", () => { render( {}} onDownload={() => {}} />, ); - // Without an attachment id we cannot hit the ID-keyed /content proxy, - // so the card must fall back to chrome-only. - expect(document.querySelector("iframe")).toBeNull(); expect(screen.getByText("report.html")).toBeTruthy(); + expect(document.querySelector("iframe")).toBeNull(); }); it("hides the Eye button for an html URL-only source (the modal's /content proxy is ID-keyed)", () => { // Regression: a cross-comment / copy-pasted `!file[report.html](url)` - // used to surface a dead Eye button — the AttachmentCard allowed - // preview when `previewableFromUrl` was true even without an - // attachmentId, but the modal's tryOpen rejects URL-only text kinds - // and the click became a silent no-op. + // used to surface a dead Eye button — text kinds need an attachmentId, + // otherwise tryOpen rejects and the click becomes a silent no-op. render( { expect(screen.getByTitle("Download")).toBeTruthy(); }); - it("still shows the Eye button for an html source when an attachmentId is available", () => { - getAttachmentTextContentMock.mockResolvedValueOnce({ - text: "

ok

", - originalContentType: "text/html", - }); + it("shows the Eye button for an html source when an attachmentId is available", () => { render( { ); expect(screen.getByTitle("Preview")).toBeTruthy(); }); - - it("renders an inline iframe with sandbox='allow-scripts' for an HTML attachment", async () => { - getAttachmentTextContentMock.mockResolvedValueOnce({ - text: "

chart goes here

", - originalContentType: "text/html", - }); - render( - {}} - onDownload={() => {}} - />, - ); - await waitFor(() => { - const frame = document.querySelector("iframe") as HTMLIFrameElement | null; - expect(frame).toBeTruthy(); - expect(frame?.getAttribute("sandbox")).toBe("allow-scripts"); - expect(frame?.getAttribute("srcdoc")).toBe("

chart goes here

"); - }); - }); }); describe("AttachmentCard — Eye / Download buttons", () => { @@ -185,7 +118,7 @@ describe("AttachmentCard — Eye / Download buttons", () => { expect(onDownload).toHaveBeenCalled(); }); - it("hides the Eye button while uploading and skips the inline HTML preview", () => { + it("hides Eye and Download buttons while uploading", () => { render( { ); expect(screen.queryByTitle("Preview")).toBeNull(); expect(screen.queryByTitle("Download")).toBeNull(); - expect(document.querySelector("iframe")).toBeNull(); // The mock `t()` returns the i18n template as-is; the production t-fn // interpolates {{filename}} → "report.html". Asserting the template // proves the uploading branch was selected without depending on the diff --git a/packages/views/editor/attachment-card.tsx b/packages/views/editor/attachment-card.tsx index f304983ca..cea2a2bde 100644 --- a/packages/views/editor/attachment-card.tsx +++ b/packages/views/editor/attachment-card.tsx @@ -1,84 +1,24 @@ "use client"; /** - * AttachmentCard — shared attachment row UI used by every entry point that - * renders a non-image attachment in the editor surface. + * AttachmentCard — shared file-card row UI (icon + filename + Eye + Download). * - * Three call sites: - * 1. `extensions/file-card.tsx` — Tiptap NodeView for `!file[name](url)` - * inline in markdown. - * 2. `readonly-content.tsx` — readonly file-card `
` - * branch, rendered through preprocessMarkdown. - * 3. `comment-card.tsx` `AttachmentList` — standalone attachments that were - * not referenced by URL inside the markdown body. + * Rendered for every attachment kind that does not have a richer inline + * renderer. Three call sites reach it indirectly through AttachmentBlock, + * which picks the right component per kind: * - * Centralizing this avoids the third-instance trap: every previous attempt to - * add a feature here had to be added in three places, and dropping one - * silently re-introduced the bug — MUL-2330's HTML chart was a standalone - * attachment, so the inline HTML preview only works if THIS path is covered. + * 1. `extensions/file-card.tsx` — Tiptap NodeView for `!file[name](url)`. + * 2. `readonly-content.tsx` — readonly `
`. + * 3. `comment-card.tsx` AttachmentList — standalone attachments not inlined. * - * HTML kind extension: - * - When the attachment is HTML and the caller can provide an - * `attachmentId` (i.e. the attachment record is known — required for the - * ID-keyed `/api/attachments/{id}/content` proxy), the card mounts an - * inline `CodeBlockIframe` underneath the row to render the HTML body - * directly. Loading errors and 413/415 cases collapse back to the bare - * row + Eye/Download buttons. - * - For non-HTML kinds (or HTML where we only have a URL), the card looks - * and behaves exactly like the previous handwritten rows. + * Kind-aware routing (e.g. HTML → inline iframe preview) lives in + * AttachmentBlock — keep that decision out of this file so this stays a + * single-purpose row UI. */ import { Download, Eye, FileText, Loader2 } from "lucide-react"; import { useT } from "../i18n"; import { getPreviewKind } from "./utils/preview"; -import { CodeBlockIframe } from "./code-block-iframe"; -import { useAttachmentHtmlText } from "./hooks/use-attachment-html-text"; - -// --------------------------------------------------------------------------- -// Inline HTML preview body -// --------------------------------------------------------------------------- - -// Fixed height per the V2 plan; auto-resize via postMessage handshake is -// explicitly out of scope for V1. -const INLINE_HTML_HEIGHT = "h-[480px]"; - -function InlineHtmlIframe({ - attachmentId, - filename, -}: { - attachmentId: string; - filename: string; -}) { - const { t } = useT("editor"); - const query = useAttachmentHtmlText(attachmentId); - - if (query.isLoading) { - return ( -
- - {t(($) => $.attachment.preview_loading)} -
- ); - } - // Any error path (413 / 415 / transport) — fall back silently. The - // surrounding card still offers Eye → modal (which surfaces the typed - // error) and Download as escape hatches. - if (query.error || !query.data) return null; - - return ( -
- -
- ); -} - -// --------------------------------------------------------------------------- -// Card chrome — icon + filename + optional Eye + Download -// --------------------------------------------------------------------------- interface AttachmentCardChromeProps { filename: string; @@ -149,23 +89,18 @@ function AttachmentCardChrome({ ); } -// --------------------------------------------------------------------------- -// AttachmentCard — public component -// --------------------------------------------------------------------------- - export interface AttachmentCardProps { /** Filename used for icon label and previewable-kind detection. */ filename: string; /** Content type used in addition to filename for previewable-kind detection. */ contentType?: string; /** - * Attachment id — required for HTML inline rendering (the `/content` - * proxy is ID-keyed). Undefined means we only have a URL (e.g. a - * cross-comment `!file[]()` reference) — the card still renders, the - * HTML iframe just doesn't expand. + * Attachment id — required when the preview proxy is ID-keyed (text kinds + * like markdown / html / text). Media kinds (pdf/video/audio) preview from + * the URL alone. */ attachmentId?: string; - /** Download URL — used purely as a non-null sentinel for the download button. */ + /** Download URL — used as a non-null sentinel for the download button. */ href?: string; /** True while a synchronous upload is in flight (file-card NodeView only). */ uploading?: boolean; @@ -173,12 +108,6 @@ export interface AttachmentCardProps { onPreview: () => void; /** Pressed when the Download button is clicked. */ onDownload: () => void; - /** - * Set to false to disable the HTML inline preview branch (and behave like - * the legacy chrome-only card). Useful for editor NodeViews while a draft - * upload is still in flight. - */ - inlineHtmlEnabled?: boolean; } export function AttachmentCard({ @@ -189,7 +118,6 @@ export function AttachmentCard({ uploading, onPreview, onDownload, - inlineHtmlEnabled = true, }: AttachmentCardProps) { const kind = filename ? getPreviewKind(contentType, filename) : null; // Media kinds (pdf/video/audio) are previewable from a URL alone — the @@ -202,14 +130,6 @@ export function AttachmentCard({ const canPreview = !!href && kind !== null && (!!attachmentId || isUrlPreviewableKind); - // Mount the inline iframe only when we can hit the /content proxy - // (attachmentId present) AND kind is HTML AND no upload is in flight. - const showInlineHtml = - inlineHtmlEnabled && - !uploading && - kind === "html" && - !!attachmentId; - return (
- {showInlineHtml && ( - - )}
); } diff --git a/packages/views/editor/extensions/file-card.test.tsx b/packages/views/editor/extensions/file-card.test.tsx new file mode 100644 index 000000000..7d0ab9904 --- /dev/null +++ b/packages/views/editor/extensions/file-card.test.tsx @@ -0,0 +1,105 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import type { ReactElement } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; + +// Tiptap NodeView primitives can't be instantiated without a full editor. +// Stub the wrapper so FileCardView renders as a plain React component and +// the DOM can be inspected directly. +vi.mock("@tiptap/react", () => ({ + NodeViewWrapper: ({ children, ...rest }: any) =>
{children}
, +})); + +const { getAttachmentTextContentMock, resolveAttachmentMock, openByUrlMock, tryOpenMock } = + vi.hoisted(() => ({ + getAttachmentTextContentMock: vi.fn(), + resolveAttachmentMock: vi.fn(), + openByUrlMock: vi.fn(), + tryOpenMock: vi.fn(), + })); + +vi.mock("@multica/core/api", () => ({ + api: { getAttachmentTextContent: getAttachmentTextContentMock }, + PreviewTooLargeError: class extends Error {}, + PreviewUnsupportedError: class extends Error {}, +})); + +vi.mock("../attachment-download-context", () => ({ + useAttachmentDownloadResolver: () => ({ + openByUrl: openByUrlMock, + resolveAttachment: resolveAttachmentMock, + }), +})); + +vi.mock("../attachment-preview-modal", () => ({ + useAttachmentPreview: () => ({ tryOpen: tryOpenMock, open: vi.fn(), modal: null }), +})); + +vi.mock("../i18n", () => ({ + useT: () => ({ + t: (sel: (s: Record>) => string) => + sel({ + image: { download: "Download" }, + attachment: { + preview: "Preview", + preview_loading: "Loading preview…", + preview_failed: "Couldn't load preview", + }, + code_block: { copy_code: "Copy code" }, + file_card: { uploading: "Uploading {{filename}}" }, + }), + }), +})); + +import { FileCardView } from "./file-card"; + +function renderWithQuery(ui: ReactElement) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + return render({ui}); +} + +beforeEach(() => vi.clearAllMocks()); +afterEach(() => vi.restoreAllMocks()); + +describe("FileCardView — HTML attachment routes through AttachmentBlock to iframe", () => { + // Regression pin for file-card.tsx:59. The NodeView must render through + // , not the older . If someone reverts that + // line, the dispatcher's html+attachmentId branch is bypassed and the user + // is left with the file-card chrome — exactly the bug MUL-2330 surfaced. + it("renders an iframe (no file-card chrome) when the node resolves to an HTML attachment", async () => { + resolveAttachmentMock.mockReturnValue({ + id: "att-1", + content_type: "text/html", + url: "/uploads/report.html", + filename: "report.html", + }); + getAttachmentTextContentMock.mockResolvedValueOnce({ + text: "

chart

", + originalContentType: "text/html", + }); + + const node = { + attrs: { + href: "/uploads/report.html", + filename: "report.html", + uploading: false, + }, + } as any; + + renderWithQuery(); + + const frame = await waitFor(() => { + const f = document.querySelector("iframe") as HTMLIFrameElement | null; + expect(f).toBeTruthy(); + return f!; + }); + expect(frame.getAttribute("sandbox")).toBe("allow-scripts"); + expect(frame.getAttribute("srcdoc")).toContain("

chart

"); + // The AttachmentCard chrome surfaces the filename as text inside its row. + // HtmlAttachmentPreview replaces the chrome entirely, so the filename + // must not appear as visible text. + expect(screen.queryByText("report.html")).toBeNull(); + }); +}); diff --git a/packages/views/editor/extensions/file-card.tsx b/packages/views/editor/extensions/file-card.tsx index 1256f32b0..fe7285a5e 100644 --- a/packages/views/editor/extensions/file-card.tsx +++ b/packages/views/editor/extensions/file-card.tsx @@ -20,7 +20,7 @@ import type { NodeViewProps } from "@tiptap/react"; import { FILE_CARD_URL_PATTERN } from "@multica/ui/markdown"; import { useAttachmentDownloadResolver } from "../attachment-download-context"; import { useAttachmentPreview } from "../attachment-preview-modal"; -import { AttachmentCard } from "../attachment-card"; +import { AttachmentBlock } from "../attachment-block"; const FILE_CARD_MARKDOWN_RE = new RegExp( `^!file\\[([^\\]]*)\\]\\((${FILE_CARD_URL_PATTERN.source})\\)`, @@ -31,7 +31,7 @@ const FILE_CARD_MARKDOWN_RE = new RegExp( // React NodeView // --------------------------------------------------------------------------- -function FileCardView({ node }: NodeViewProps) { +export function FileCardView({ node }: NodeViewProps) { const href = (node.attrs.href as string) || ""; const filename = (node.attrs.filename as string) || ""; const uploading = node.attrs.uploading as boolean; @@ -56,7 +56,7 @@ function FileCardView({ node }: NodeViewProps) { return (
- ({ + getAttachmentTextContentMock: vi.fn(), +})); + +vi.mock("@multica/core/api", () => ({ + api: { getAttachmentTextContent: getAttachmentTextContentMock }, + PreviewTooLargeError: class extends Error {}, + PreviewUnsupportedError: class extends Error {}, +})); + +vi.mock("../i18n", () => ({ + useT: () => ({ + t: (sel: (s: Record>) => string) => + sel({ + image: { download: "Download" }, + attachment: { + preview: "Preview", + preview_loading: "Loading preview…", + preview_failed: "Couldn't load preview", + }, + code_block: { copy_code: "Copy code" }, + }), + }), +})); + +import { HtmlAttachmentPreview } from "./html-attachment-preview"; + +function renderWithQuery(ui: ReactElement) { + const qc = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: 0 } }, + }); + return render({ui}); +} + +beforeEach(() => vi.clearAllMocks()); +afterEach(() => vi.restoreAllMocks()); + +describe("HtmlAttachmentPreview — visual shell (does not use file-card chrome)", () => { + it("does not render the filename row that AttachmentCard chrome would render", async () => { + getAttachmentTextContentMock.mockResolvedValueOnce({ + text: "

ok

", + originalContentType: "text/html", + }); + renderWithQuery( + {}} + onDownload={() => {}} + />, + ); + await waitFor(() => { + expect(document.querySelector("iframe")).toBeTruthy(); + }); + // The chrome row would surface the filename as text; we replace that + // entirely with an iframe + floating toolbar. + expect(screen.queryByText("report.html")).toBeNull(); + }); + + it("renders iframe with sandbox='allow-scripts' and srcdoc when text loads", async () => { + getAttachmentTextContentMock.mockResolvedValueOnce({ + text: "

chart goes here

", + originalContentType: "text/html", + }); + renderWithQuery( + {}} + onDownload={() => {}} + />, + ); + await waitFor(() => { + const frame = document.querySelector("iframe") as HTMLIFrameElement | null; + expect(frame).toBeTruthy(); + // Critical: sandbox must not include allow-same-origin, otherwise the + // sandbox is defeated per the HTML spec. + expect(frame?.getAttribute("sandbox")).toBe("allow-scripts"); + expect(frame?.getAttribute("srcdoc")).toBe("

chart goes here

"); + }); + }); +}); + +describe("HtmlAttachmentPreview — toolbar actions", () => { + it("invokes onPreview when Maximize is clicked", async () => { + getAttachmentTextContentMock.mockResolvedValueOnce({ + text: "

ok

", + originalContentType: "text/html", + }); + const onPreview = vi.fn(); + renderWithQuery( + {}} + />, + ); + await waitFor(() => expect(screen.getByTitle("Preview")).toBeTruthy()); + fireEvent.mouseDown(screen.getByTitle("Preview")); + expect(onPreview).toHaveBeenCalled(); + }); + + it("invokes onDownload when Download is clicked", async () => { + getAttachmentTextContentMock.mockResolvedValueOnce({ + text: "

ok

", + originalContentType: "text/html", + }); + const onDownload = vi.fn(); + renderWithQuery( + {}} + onDownload={onDownload} + />, + ); + await waitFor(() => expect(screen.getByTitle("Download")).toBeTruthy()); + fireEvent.mouseDown(screen.getByTitle("Download")); + expect(onDownload).toHaveBeenCalled(); + }); + + it("writes the loaded text to the clipboard when Copy code is clicked", async () => { + getAttachmentTextContentMock.mockResolvedValueOnce({ + text: "

chart source

", + originalContentType: "text/html", + }); + const writeText = vi.fn().mockResolvedValue(undefined); + // jsdom does not implement navigator.clipboard; install it directly on + // the existing navigator instance so the component's `navigator.clipboard` + // global lookup resolves to our mock. + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText }, + }); + renderWithQuery( + {}} + onDownload={() => {}} + />, + ); + // Wait until the query resolves and the iframe appears — the Copy button + // is rendered in the loading state too (disabled), so we cannot just wait + // for it to exist. + await waitFor(() => expect(document.querySelector("iframe")).toBeTruthy()); + fireEvent.mouseDown(screen.getByTitle("Copy code")); + await waitFor(() => { + expect(writeText).toHaveBeenCalledWith("

chart source

"); + }); + }); +}); + +describe("HtmlAttachmentPreview — failure mode does not unmount the toolbar", () => { + it("keeps Open and Download enabled and disables Copy code when fetch errors", async () => { + getAttachmentTextContentMock.mockRejectedValueOnce(new Error("nope")); + const onPreview = vi.fn(); + const onDownload = vi.fn(); + renderWithQuery( + , + ); + // Wait for the error placeholder — guarantees the query has settled. + await waitFor(() => { + expect( + screen.getByTestId("html-attachment-preview-error"), + ).toBeTruthy(); + }); + // Critical: the figure does NOT collapse, and the chrome row is NOT + // rendered as a fallback. Open and Download stay reachable. + expect(document.querySelector("iframe")).toBeNull(); + expect(screen.queryByText("report.html")).toBeNull(); + + const previewBtn = screen.getByTitle("Preview") as HTMLButtonElement; + const downloadBtn = screen.getByTitle("Download") as HTMLButtonElement; + const copyBtn = screen.getByTitle("Copy code") as HTMLButtonElement; + expect(previewBtn.disabled).toBe(false); + expect(downloadBtn.disabled).toBe(false); + expect(copyBtn.disabled).toBe(true); + + fireEvent.mouseDown(previewBtn); + expect(onPreview).toHaveBeenCalled(); + fireEvent.mouseDown(downloadBtn); + expect(onDownload).toHaveBeenCalled(); + }); +}); diff --git a/packages/views/editor/html-attachment-preview.tsx b/packages/views/editor/html-attachment-preview.tsx new file mode 100644 index 000000000..240648ada --- /dev/null +++ b/packages/views/editor/html-attachment-preview.tsx @@ -0,0 +1,160 @@ +"use client"; + +/** + * HtmlAttachmentPreview — inline HTML attachment renderer. + * + * Visual model mirrors the image renderer: the iframe body is the card, and a + * floating right-top toolbar reveals on hover with Open / Download / Copy code + * actions. No file-card chrome (icon + filename row). + * + * Mounted by AttachmentBlock when the attachment is HTML and the caller can + * supply an `attachmentId` (the /content proxy is ID-keyed). For other kinds, + * AttachmentBlock falls back to the shared AttachmentCard. + * + * Failure mode (413 / 415 / transport): we do not unmount the figure or fall + * back to AttachmentCard chrome — standalone attachment lists filter URLs + * already inlined in the markdown body, so a silent unmount would remove the + * user's only Open/Download entry point. Instead the body collapses to an + * 80px placeholder, the toolbar pins itself open, Open and Download remain + * enabled, and Copy code is disabled (no text payload available). + */ + +import { useState } from "react"; +import { Check, Copy, Download, Maximize2 } from "lucide-react"; +import { cn } from "@multica/ui/lib/utils"; +import { useT } from "../i18n"; +import { useAttachmentHtmlText } from "./hooks/use-attachment-html-text"; + +const PREVIEW_HEIGHT = "h-[480px]"; +const ERROR_PLACEHOLDER_HEIGHT = "h-20"; + +interface HtmlAttachmentPreviewProps { + attachmentId: string; + filename: string; + onPreview: () => void; + onDownload: () => void; +} + +export function HtmlAttachmentPreview({ + attachmentId, + filename, + onPreview, + onDownload, +}: HtmlAttachmentPreviewProps) { + const { t } = useT("editor"); + const query = useAttachmentHtmlText(attachmentId); + const [copied, setCopied] = useState(false); + + const text = query.data?.text; + const isLoading = query.isLoading; + const isError = !isLoading && (!!query.error || !text); + const canCopy = !!text; + + const handleCopy = async () => { + if (!text) return; + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard failures are user-recoverable (try again, or open in modal + // and use the text view). No toast — keep the toolbar quiet. + } + }; + + return ( +
e.stopPropagation()} + > + {isLoading ? ( +
+ {t(($) => $.attachment.preview_loading)} +
+ ) : isError ? ( +
+ {t(($) => $.attachment.preview_failed)} +
+ ) : ( +