Files
multica/packages/views/editor/code-block-static.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

58 lines
1.8 KiB
TypeScript

"use client";
/**
* CodeBlockStatic — read-only lowlight-highlighted code block.
*
* Used by:
* - AttachmentPreviewModal's text-kind fallback (extracted from there).
* - HtmlBlockPreview's "source" toggle in ReadonlyContent.
*
* NOT used by Tiptap's editable code-block NodeView: that path must keep
* `<NodeViewContent as="code" />` so the user can continue typing into the
* code block. Replacing it with a static lowlight component would freeze
* the content and desync ProseMirror state from the DOM.
*/
import { useMemo } from "react";
import { createLowlight, common } from "lowlight";
// @ts-expect-error -- hast-util-to-html has no bundled type declarations
import { toHtml } from "hast-util-to-html";
import { cn } from "@multica/ui/lib/utils";
const lowlight = createLowlight(common);
interface CodeBlockStaticProps {
language: string | undefined;
body: string;
className?: string;
}
export function CodeBlockStatic({ language, body, className }: CodeBlockStaticProps) {
const html = useMemo(() => {
const code = body.replace(/\n$/, "");
try {
const tree = language
? lowlight.highlight(language, code)
: lowlight.highlightAuto(code);
return toHtml(tree) as string;
} catch {
// Unknown language tag — fall back to escaped plain text so we don't
// crash on an esoteric extension.
return escapeHtml(code);
}
}, [body, language]);
return (
<pre className={cn("rich-text-editor m-0 overflow-auto text-sm", className)}>
<code
className={cn("hljs", language && `language-${language}`)}
dangerouslySetInnerHTML={{ __html: html }}
/>
</pre>
);
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}