Files
multica/packages/views/editor/code-block-iframe.tsx
Naiyuan Qing ceb967aefa feat(editor): inline HTML attachment preview + ```html block render (MUL-2345) (#2790)
* feat(editor): inline HTML attachment preview + ```html block render (MUL-2345)

* attachment-preview-modal: switch HTML iframe sandbox from "" to
  "allow-scripts" so JS-driven chart libraries render. The opaque-origin
  iframe still cannot touch cookies, localStorage, parent state, or
  top-nav — only scripts run.
* New shared AttachmentCard wired into the three attachment surfaces
  (file-card NodeView, ReadonlyContent file-card branch, comment-card
  standalone AttachmentList). HTML attachments now render inline via a
  sandboxed iframe pulled through the existing /content proxy; other
  kinds keep the original chrome behavior.
* New HtmlBlockPreview for fenced ```html blocks in ReadonlyContent —
  default preview iframe, source/Copy toggle. Two-layer code+pre unwrap
  mirrors the Mermaid pattern; unwrap now matches on language-* class
  because react-markdown invokes pre before the code renderer runs.
* CodeBlockView (Tiptap NodeView) renders an iframe preview for
  language=html with a CSS-hidden toggle to the editable source — the
  <NodeViewContent as="code"/> mount must remain in the tree.
* Shared use-attachment-html-text hook keeps inline and modal HTML
  rendering on the same React Query cache.
* Vitest coverage: allow-scripts assertion, attachment-card kind
  branches, readonly HTML iframe + Mermaid unwrap regression, NodeView
  editable + preview/source toggle.

No backend changes; server-side text/plain + nosniff defense kept.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

* fix(editor): tighten attachment preview and pre unwrap gates (MUL-2345)

Addresses Reviewer REQUEST CHANGES on PR #2790:

1. URL-only text/html attachment cards no longer surface a dead Eye
   button. `AttachmentCard` previously allowed preview when
   `previewableFromUrl=true` regardless of kind, but the modal's
   `tryOpen` rejects URL-only text kinds because the `/content` proxy
   is ID-keyed. Drop the `previewableFromUrl` prop and gate the
   no-attachmentId path strictly to URL-previewable media kinds
   (pdf/video/audio).

2. Readonly `pre` unwrap now uses exact class-token matching. The
   previous `className.includes("language-html")` check also fired
   on `language-htmlbars`, silently stripping its `<pre>` wrapper.
   Use `/(^|\s)language-(html|mermaid)(\s|$)/` so only the exact
   tokens unwrap.

Regression tests:
- `report.html + no attachmentId` asserts no Preview button.
- `pdf URL-only` asserts Preview button still appears.
- `htmlbars` / `mermaidx` fences keep their `<pre><code>` wrapper.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
Co-authored-by: multica-agent <github@multica.ai>
2026-05-18 16:23:40 +08:00

59 lines
1.9 KiB
TypeScript

"use client";
/**
* Shared HTML preview iframe.
*
* Used by:
* - InlineHtmlIframe inside AttachmentCard (HTML attachments inline preview)
* - CodeBlockView for fenced ```html blocks (editable Tiptap NodeView)
* - HtmlBlockPreview for fenced ```html blocks (ReadonlyContent)
* - AttachmentPreviewModal's full-screen HTML kind
*
* Sandbox semantics:
* sandbox="allow-scripts" (NOT "allow-same-origin")
* → iframe runs in an opaque origin: scripts execute (chart JS works),
* but cookie / localStorage / parent access / top-nav / popups / forms
* remain blocked. This is the standard "preview untrusted HTML" model
* (HTML spec §iframe sandbox, MDN, Claude artifacts, v0.dev preview).
*
* The server-side `text/plain` + `nosniff` defense at
* /api/attachments/{id}/content remains untouched — we only feed iframe.srcDoc
* the text body we fetched, never point iframe.src at the proxy URL.
*/
import { cn } from "@multica/ui/lib/utils";
interface CodeBlockIframeProps {
/** Document source for srcDoc. Empty string renders a blank frame. */
html: string;
/** Iframe title for accessibility. */
title: string;
className?: string;
/** Tailwind height token; defaults to h-[320px]. */
heightClassName?: string;
}
export function CodeBlockIframe({
html,
title,
className,
heightClassName = "h-[320px]",
}: CodeBlockIframeProps) {
return (
<iframe
// srcDoc keeps the body in the parent's process but isolated to an
// opaque origin via sandbox. Critical that we never combine
// `allow-scripts` with `allow-same-origin` — that pairing defeats the
// sandbox per the HTML spec (notes on the sandbox attribute).
srcDoc={html}
sandbox="allow-scripts"
title={title}
className={cn(
"w-full rounded-md border border-border bg-background",
heightClassName,
className,
)}
/>
);
}