mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-20 08:41:28 +02:00
* 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>
99 lines
3.4 KiB
TypeScript
99 lines
3.4 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { act, fireEvent, render, screen } from "@testing-library/react";
|
|
|
|
// Tiptap's NodeView primitives are hard to instantiate in jsdom without a
|
|
// full editor. Stub them so the test can render <CodeBlockView /> as a plain
|
|
// React component and inspect the resulting DOM shape.
|
|
vi.mock("@tiptap/react", () => {
|
|
const NodeViewWrapper = ({ children, ...rest }: any) => (
|
|
<div data-testid="nvw" {...rest}>
|
|
{children}
|
|
</div>
|
|
);
|
|
// The real NodeViewContent renders an element managed by ProseMirror. For
|
|
// the test it's enough to surface a sentinel element so we can assert it
|
|
// remains mounted while CSS-hidden.
|
|
const NodeViewContent = ({ as = "div", ...rest }: any) => {
|
|
const Tag = as;
|
|
return <Tag data-testid="nvc" {...rest} />;
|
|
};
|
|
return { NodeViewWrapper, NodeViewContent };
|
|
});
|
|
|
|
vi.mock("../mermaid-diagram", () => ({
|
|
MermaidDiagram: () => null,
|
|
}));
|
|
|
|
vi.mock("../../i18n", () => ({
|
|
useT: () => ({
|
|
t: (sel: (s: Record<string, Record<string, string>>) => string) =>
|
|
sel({
|
|
code_block: {
|
|
copy_code: "Copy code",
|
|
show_preview: "Show preview",
|
|
show_source: "Show source",
|
|
},
|
|
}),
|
|
}),
|
|
}));
|
|
|
|
import { CodeBlockView } from "./code-block-view";
|
|
|
|
function makeProps(language: string, text: string) {
|
|
return {
|
|
node: {
|
|
attrs: { language },
|
|
textContent: text,
|
|
},
|
|
} as unknown as Parameters<typeof CodeBlockView>[0];
|
|
}
|
|
|
|
describe("CodeBlockView — html language toggle", () => {
|
|
// Inner async timers in useDebouncedValue make the iframe srcDoc lag by
|
|
// ~200ms; use fake timers so the test stays deterministic.
|
|
beforeEach(() => {
|
|
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
});
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
it("defaults to preview view: renders an iframe with sandbox='allow-scripts' and keeps the <pre> mounted (hidden)", () => {
|
|
render(<CodeBlockView {...makeProps("html", "<p>hello</p>")} />);
|
|
act(() => {
|
|
vi.advanceTimersByTime(250);
|
|
});
|
|
const frame = document.querySelector("iframe");
|
|
expect(frame).toBeTruthy();
|
|
expect(frame?.getAttribute("sandbox")).toBe("allow-scripts");
|
|
// NodeViewContent (and its enclosing <pre>) MUST remain mounted —
|
|
// unmounting would break Tiptap's bindings and prevent editing.
|
|
const nvc = screen.getByTestId("nvc");
|
|
expect(nvc).toBeTruthy();
|
|
const pre = nvc.closest("pre");
|
|
expect(pre).toBeTruthy();
|
|
expect(pre?.className).toContain("sr-only");
|
|
});
|
|
|
|
it("toggles to source view: iframe is removed and the <pre> is no longer hidden", () => {
|
|
render(<CodeBlockView {...makeProps("html", "<p>hello</p>")} />);
|
|
act(() => {
|
|
vi.advanceTimersByTime(250);
|
|
});
|
|
expect(document.querySelector("iframe")).toBeTruthy();
|
|
const toggle = screen.getByTitle("Show source");
|
|
fireEvent.click(toggle);
|
|
expect(document.querySelector("iframe")).toBeNull();
|
|
const nvc = screen.getByTestId("nvc");
|
|
const pre = nvc.closest("pre")!;
|
|
expect(pre.className).not.toContain("sr-only");
|
|
});
|
|
|
|
it("does not show the toggle or an iframe for a non-html language", () => {
|
|
render(<CodeBlockView {...makeProps("typescript", "const x = 1;")} />);
|
|
expect(screen.queryByTitle("Show source")).toBeNull();
|
|
expect(screen.queryByTitle("Show preview")).toBeNull();
|
|
expect(document.querySelector("iframe")).toBeNull();
|
|
});
|
|
});
|