Compare commits

...

2 Commits

Author SHA1 Message Date
Naiyuan Qing
316d5f4c00 test(editor): pin three entry points to AttachmentBlock HTML route (MUL-2345)
Reviewer flagged that the v4 dispatcher refactor only had tests on the
shared AttachmentBlock + HtmlAttachmentPreview; the three real call
sites at file-card.tsx:59, readonly-content.tsx:279, and
comment-card.tsx:152 had no regression coverage. Reverting any one
would silently lose the inline HTML iframe path — the exact MUL-2330
regression we're meant to be locking down.

Each new test renders the real entry point with an HTML+attachmentId
fixture and asserts the dispatched iframe (sandbox=allow-scripts,
srcdoc) shows up while the AttachmentCard chrome (filename row) does
not. FileCardView and AttachmentList are exported from their files for
direct rendering, mirroring the existing CodeBlockView test pattern.

Mutation-tested locally: temporarily flipping each site back to
<AttachmentCard> turns its corresponding test red.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 17:35:42 +08:00
Naiyuan Qing
32c4b9b51d feat(editor): HTML attachments render like images (MUL-2345 v4)
HTML attachments no longer wear the file-card chrome (icon + filename
row). They now render as a sandboxed iframe with a hover-revealed
right-top toolbar (Open / Download / Copy code), mirroring the image
attachment visual model.

- New HtmlAttachmentPreview owns the iframe + hover toolbar plus three
  states (loading / success / error). Failure mode keeps the toolbar
  pinned open and Open/Download enabled so the user is never stranded
  without an escape hatch — Copy code disables when the text body is
  unavailable.
- New AttachmentBlock thin dispatcher picks the renderer per kind:
  html + attachmentId + !uploading -> HtmlAttachmentPreview, else
  AttachmentCard. All three entry points (file-card NodeView, readonly
  file-card, standalone AttachmentList) call AttachmentBlock, so feature
  work on a new kind only touches one place.
- AttachmentCard collapses back to a pure file-card row UI: the inline
  HTML iframe branch (InlineHtmlIframe + inlineHtmlEnabled +
  showInlineHtml) is removed.
- AttachmentBlock added to the editor barrel export.

Sandbox/server-side defenses unchanged: sandbox="allow-scripts" (no
allow-same-origin), srcDoc, server still returns text/plain + nosniff
on the /content proxy.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 17:14:07 +08:00
13 changed files with 769 additions and 182 deletions

View File

@@ -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, Record<string, string>>) => 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(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.restoreAllMocks());
describe("AttachmentBlock — dispatcher", () => {
it("routes html + attachmentId to HtmlAttachmentPreview (no file-card chrome)", () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>chart</p>",
originalContentType: "text/html",
});
renderWithQuery(
<AttachmentBlock
filename="report.html"
contentType="text/html"
attachmentId="att-1"
href="https://cdn.example/report.html"
onPreview={() => {}}
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(
<AttachmentBlock
filename="report.html"
contentType="text/html"
href="https://cdn.example/report.html"
onPreview={() => {}}
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(
<AttachmentBlock
filename="report.html"
contentType="text/html"
attachmentId="att-1"
href="https://cdn.example/report.html"
uploading
onPreview={() => {}}
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(
<AttachmentBlock
filename="manual.pdf"
contentType="application/pdf"
attachmentId="att-1"
href="https://cdn.example/manual.pdf"
onPreview={() => {}}
onDownload={() => {}}
/>,
);
expect(screen.getByText("manual.pdf")).toBeTruthy();
expect(document.querySelector("iframe")).toBeNull();
});
});

View File

@@ -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 (
<HtmlAttachmentPreview
attachmentId={attachmentId}
filename={filename}
onPreview={props.onPreview}
onDownload={props.onDownload}
/>
);
}
return <AttachmentCard {...props} />;
}

View File

@@ -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(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.restoreAllMocks());
describe("AttachmentCard — kind dispatch", () => {
it("renders chrome only for non-html kinds (image, video, other)", () => {
render(
<AttachmentCard
filename="snapshot.png"
contentType="image/png"
attachmentId="att-1"
href="https://cdn.example/snapshot.png"
onPreview={() => {}}
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(
<AttachmentCard
filename="report.html"
contentType="text/html"
attachmentId="att-1"
href="https://cdn.example/report.html"
onPreview={() => {}}
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(
<AttachmentCard
filename="report.html"
@@ -94,11 +54,7 @@ describe("AttachmentCard — kind dispatch", () => {
expect(screen.getByTitle("Download")).toBeTruthy();
});
it("still shows the Eye button for an html source when an attachmentId is available", () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>ok</p>",
originalContentType: "text/html",
});
it("shows the Eye button for an html source when an attachmentId is available", () => {
render(
<AttachmentCard
filename="report.html"
@@ -127,29 +83,6 @@ describe("AttachmentCard — kind dispatch", () => {
);
expect(screen.getByTitle("Preview")).toBeTruthy();
});
it("renders an inline iframe with sandbox='allow-scripts' for an HTML attachment", async () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>chart goes here</p>",
originalContentType: "text/html",
});
render(
<AttachmentCard
filename="report.html"
contentType="text/html"
attachmentId="att-1"
href="https://cdn.example/report.html"
onPreview={() => {}}
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("<p>chart goes here</p>");
});
});
});
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(
<AttachmentCard
filename="report.html"
@@ -199,7 +132,6 @@ describe("AttachmentCard — Eye / Download buttons", () => {
);
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

View File

@@ -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 `<div data-type="fileCard">`
* 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 `<div data-type="fileCard">`.
* 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 (
<div className="mt-1 flex h-[480px] items-center justify-center gap-2 rounded-md border border-border bg-muted/30 text-xs text-muted-foreground">
<Loader2 className="size-3.5 animate-spin" />
{t(($) => $.attachment.preview_loading)}
</div>
);
}
// 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 (
<div className="mt-1">
<CodeBlockIframe
html={query.data.text}
title={filename}
heightClassName={INLINE_HTML_HEIGHT}
/>
</div>
);
}
// ---------------------------------------------------------------------------
// 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 (
<div className="my-1">
<AttachmentCardChrome
@@ -220,9 +140,6 @@ export function AttachmentCard({
onPreview={onPreview}
onDownload={onDownload}
/>
{showInlineHtml && (
<InlineHtmlIframe attachmentId={attachmentId!} filename={filename} />
)}
</div>
);
}

View File

@@ -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) => <div {...rest}>{children}</div>,
}));
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, Record<string, string>>) => 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(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
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
// <AttachmentBlock>, not the older <AttachmentCard>. 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: "<p>chart</p>",
originalContentType: "text/html",
});
const node = {
attrs: {
href: "/uploads/report.html",
filename: "report.html",
uploading: false,
},
} as any;
renderWithQuery(<FileCardView node={node} {...({} as any)} />);
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("<p>chart</p>");
// 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();
});
});

View File

@@ -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 (
<NodeViewWrapper as="div" className="file-card-node" data-type="fileCard">
<div contentEditable={false}>
<AttachmentCard
<AttachmentBlock
filename={filename}
contentType={attachment?.content_type ?? ""}
attachmentId={attachment?.id}

View File

@@ -0,0 +1,196 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } 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, Record<string, string>>) => 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(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
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: "<p>ok</p>",
originalContentType: "text/html",
});
renderWithQuery(
<HtmlAttachmentPreview
attachmentId="att-1"
filename="report.html"
onPreview={() => {}}
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: "<p>chart goes here</p>",
originalContentType: "text/html",
});
renderWithQuery(
<HtmlAttachmentPreview
attachmentId="att-1"
filename="report.html"
onPreview={() => {}}
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("<p>chart goes here</p>");
});
});
});
describe("HtmlAttachmentPreview — toolbar actions", () => {
it("invokes onPreview when Maximize is clicked", async () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>ok</p>",
originalContentType: "text/html",
});
const onPreview = vi.fn();
renderWithQuery(
<HtmlAttachmentPreview
attachmentId="att-1"
filename="report.html"
onPreview={onPreview}
onDownload={() => {}}
/>,
);
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: "<p>ok</p>",
originalContentType: "text/html",
});
const onDownload = vi.fn();
renderWithQuery(
<HtmlAttachmentPreview
attachmentId="att-1"
filename="report.html"
onPreview={() => {}}
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: "<p>chart source</p>",
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(
<HtmlAttachmentPreview
attachmentId="att-1"
filename="report.html"
onPreview={() => {}}
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("<p>chart source</p>");
});
});
});
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(
<HtmlAttachmentPreview
attachmentId="att-1"
filename="report.html"
onPreview={onPreview}
onDownload={onDownload}
/>,
);
// 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();
});
});

View File

@@ -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 (
<div
className="group/html-preview relative my-1"
onMouseDown={(e) => e.stopPropagation()}
>
{isLoading ? (
<div
className={cn(
"flex items-center justify-center rounded-md border border-border bg-muted/30 text-xs text-muted-foreground",
PREVIEW_HEIGHT,
)}
>
{t(($) => $.attachment.preview_loading)}
</div>
) : isError ? (
<div
className={cn(
"flex items-center rounded-md border border-border bg-muted/30 px-3 text-xs text-muted-foreground",
ERROR_PLACEHOLDER_HEIGHT,
)}
data-testid="html-attachment-preview-error"
>
<span className="truncate">{t(($) => $.attachment.preview_failed)}</span>
</div>
) : (
<iframe
srcDoc={text}
sandbox="allow-scripts"
title={filename}
className={cn(
"block w-full rounded-md border border-border bg-background",
PREVIEW_HEIGHT,
)}
/>
)}
<div
className={cn(
"absolute right-2 top-2 flex items-center gap-0.5 rounded-md border border-border bg-background/95 p-0.5 shadow-sm transition-opacity",
// Error state pins the toolbar open — Open / Download are the only
// user-reachable escape hatches when inline render fails.
isError
? "opacity-100"
: "opacity-0 group-hover/html-preview:opacity-100",
)}
>
<button
type="button"
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title={t(($) => $.attachment.preview)}
aria-label={t(($) => $.attachment.preview)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
onPreview();
}}
>
<Maximize2 className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title={t(($) => $.image.download)}
aria-label={t(($) => $.image.download)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
onDownload();
}}
>
<Download className="h-3.5 w-3.5" />
</button>
<button
type="button"
className={cn(
"flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground",
!canCopy && "cursor-not-allowed opacity-50 hover:bg-transparent hover:text-muted-foreground",
)}
disabled={!canCopy}
title={t(($) => $.code_block.copy_code)}
aria-label={t(($) => $.code_block.copy_code)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
if (canCopy) void handleCopy();
}}
>
{copied ? (
<Check className="h-3.5 w-3.5" />
) : (
<Copy className="h-3.5 w-3.5" />
)}
</button>
</div>
</div>
);
}

View File

@@ -22,3 +22,5 @@ export {
export type { AttachmentPreviewHandle } from "./attachment-preview-modal";
export { AttachmentCard } from "./attachment-card";
export type { AttachmentCardProps } from "./attachment-card";
export { AttachmentBlock } from "./attachment-block";
export type { AttachmentBlockProps } from "./attachment-block";

View File

@@ -1,5 +1,17 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { fireEvent, render, waitFor } 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("@multica/core/paths", () => ({
useWorkspacePaths: () => ({
@@ -253,3 +265,47 @@ describe("ReadonlyContent HTML block rendering", () => {
).not.toBeNull();
});
});
describe("ReadonlyContent file-card → AttachmentBlock HTML routing", () => {
// Regression pin for readonly-content.tsx:279. The `div data-type=fileCard`
// branch must render through <AttachmentBlock>, not the older
// <AttachmentCard>. Reverting that line would skip the html+attachmentId
// dispatcher branch and surface the bare file-card chrome (filename row)
// instead of the rendered iframe — the exact regression MUL-2330 fixed.
function renderWithQuery(ui: ReactElement) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
it("renders the !file[](url) HTML attachment as an iframe (no file-card chrome)", async () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>chart</p>",
originalContentType: "text/html",
});
const attachment = {
id: "att-1",
url: "/uploads/report.html",
filename: "report.html",
content_type: "text/html",
size_bytes: 0,
} as any;
const { container, queryByText } = renderWithQuery(
<ReadonlyContent
content="!file[report.html](/uploads/report.html)"
attachments={[attachment]}
/>,
);
const frame = await waitFor(() => {
const f = container.querySelector<HTMLIFrameElement>("iframe");
expect(f).not.toBeNull();
return f!;
});
expect(frame.getAttribute("sandbox")).toBe("allow-scripts");
expect(frame.getAttribute("srcdoc")).toContain("<p>chart</p>");
// AttachmentCard chrome surfaces the filename as visible text in a
// <p class="truncate"> row. HtmlAttachmentPreview replaces it entirely.
expect(queryByText("report.html")).toBeNull();
});
});

View File

@@ -48,7 +48,7 @@ import { MermaidDiagram } from "./mermaid-diagram";
import { HtmlBlockPreview } from "./html-block-preview";
import { useDownloadAttachment } from "./use-download-attachment";
import { useAttachmentPreview, type PreviewSource } from "./attachment-preview-modal";
import { AttachmentCard } from "./attachment-card";
import { AttachmentBlock } from "./attachment-block";
import "katex/dist/katex.min.css";
import "./content-editor.css";
@@ -276,7 +276,7 @@ function ReadonlyFileCard({
}
};
return (
<AttachmentCard
<AttachmentBlock
filename={filename}
contentType={attachment?.content_type ?? ""}
attachmentId={attachment?.id}

View File

@@ -0,0 +1,64 @@
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";
const { getAttachmentTextContentMock } = vi.hoisted(() => ({
getAttachmentTextContentMock: vi.fn(),
}));
vi.mock("@multica/core/api", () => ({
api: {
getAttachmentTextContent: getAttachmentTextContentMock,
getAttachment: vi.fn(),
},
PreviewTooLargeError: class extends Error {},
PreviewUnsupportedError: class extends Error {},
}));
import { AttachmentList } from "./comment-card";
function renderWithQuery(ui: ReactElement) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.restoreAllMocks());
describe("AttachmentList — standalone HTML attachment routes through AttachmentBlock", () => {
// Regression pin for comment-card.tsx:152. This is the entry point
// MUL-2330 originally regressed on: standalone HTML attachments (not
// referenced inline in the markdown body) MUST render through
// <AttachmentBlock> so the html+attachmentId dispatch fires. Reverting to
// <AttachmentCard> here re-introduces the "report.html shows as a bare
// file card row instead of the rendered chart" bug.
it("renders an iframe (no file-card chrome) for a standalone HTML attachment", async () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>chart</p>",
originalContentType: "text/html",
});
const attachment = {
id: "att-1",
url: "/uploads/report.html",
filename: "report.html",
content_type: "text/html",
size_bytes: 0,
} as any;
renderWithQuery(<AttachmentList attachments={[attachment]} content="" />);
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("<p>chart</p>");
// AttachmentCard chrome would render the filename as visible <p> text;
// HtmlAttachmentPreview replaces the row entirely.
expect(screen.queryByText("report.html")).toBeNull();
});
});

View File

@@ -30,7 +30,7 @@ import { QuickEmojiPicker } from "@multica/ui/components/common/quick-emoji-pick
import { cn } from "@multica/ui/lib/utils";
import { useActorName } from "@multica/core/workspace/hooks";
import { timeAgo } from "@multica/core/utils";
import { ContentEditor, type ContentEditorRef, copyMarkdown, ReadonlyContent, useFileDropZone, FileDropOverlay, useDownloadAttachment, useAttachmentPreview, AttachmentCard } from "../../editor";
import { ContentEditor, type ContentEditorRef, copyMarkdown, ReadonlyContent, useFileDropZone, FileDropOverlay, useDownloadAttachment, useAttachmentPreview, AttachmentBlock } from "../../editor";
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { api } from "@multica/core/api";
@@ -121,7 +121,7 @@ function DeleteCommentDialog({
// Standalone attachment list — renders attachments not already in the markdown
// ---------------------------------------------------------------------------
function AttachmentList({ attachments, content, className }: { attachments?: Attachment[]; content?: string; className?: string }) {
export function AttachmentList({ attachments, content, className }: { attachments?: Attachment[]; content?: string; className?: string }) {
const download = useDownloadAttachment();
const preview = useAttachmentPreview();
if (!attachments?.length) return null;
@@ -149,7 +149,7 @@ function AttachmentList({ attachments, content, className }: { attachments?: Att
return (
<div className={cn("flex flex-col gap-1", className)}>
{standalone.map((a) => (
<AttachmentCard
<AttachmentBlock
key={a.id}
filename={a.filename}
contentType={a.content_type}