fix(editor): highlight HTML source view + drop misplaced Copy on attachments (#2808)

Two issues from #2790's HTML inline preview work:

1. HTML source view rendered as default-colored text. lowlight emits
   `.hljs-tag` / `.hljs-name` for `<...>` brackets and element names, but
   content-editor.css only styled the keyword / string / attr / etc.
   classes — so toggling an inline ```html``` block to "source" showed
   attributes colored and everything else plain. Adds the two missing
   classes in light + dark.

2. HtmlAttachmentPreview carried a "Copy code" button. An HTML attachment
   is a file (view + download), not an inline source snippet. The inline
   ```html``` fenced block (HtmlBlockPreview) is where reading / copying
   source belongs. Drops the button, its state, and the useAttachmentHtmlText
   `canCopy` branch — the hook is still needed for the iframe srcDoc.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Naiyuan Qing
2026-05-18 18:27:28 +08:00
committed by GitHub
parent d9ae891064
commit e6cf5a6eca
4 changed files with 28 additions and 69 deletions

View File

@@ -23,7 +23,6 @@ vi.mock("../i18n", () => ({
preview_loading: "Loading preview…",
preview_failed: "Couldn't load preview",
},
code_block: { copy_code: "Copy code" },
file_card: { uploading: "Uploading {{filename}}" },
}),
}),
@@ -60,9 +59,11 @@ describe("AttachmentBlock — dispatcher", () => {
// 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.
// Toolbar shows Preview + Download only — attachments are files, not
// inline source snippets, so there is no Copy code button.
expect(screen.getByTitle("Preview")).toBeTruthy();
expect(screen.getByTitle("Copy code")).toBeTruthy();
expect(screen.getByTitle("Download")).toBeTruthy();
expect(screen.queryByTitle("Copy code")).toBeNull();
});
it("routes html WITHOUT attachmentId to AttachmentCard (URL-only is chrome-only)", () => {

View File

@@ -315,6 +315,12 @@
.rich-text-editor .hljs-meta { color: var(--muted-foreground); }
/* XML / HTML — lowlight emits .hljs-tag for `<` `>` brackets and .hljs-name
for the element name. Without these rules, HTML source renders mostly in
the default text color and looks unhighlighted. */
.rich-text-editor .hljs-tag { color: var(--muted-foreground); }
.rich-text-editor .hljs-name { color: oklch(0.55 0.16 255); }
/* Dark mode overrides */
.dark .rich-text-editor .hljs-keyword,
.dark .rich-text-editor .hljs-selector-tag,
@@ -341,6 +347,8 @@
.dark .rich-text-editor .hljs-deletion { color: oklch(0.7 0.18 25); }
.dark .rich-text-editor .hljs-name { color: oklch(0.72 0.14 255); }
/* Tables */
.rich-text-editor .tableWrapper {
overflow-x: auto;

View File

@@ -23,7 +23,6 @@ vi.mock("../i18n", () => ({
preview_loading: "Loading preview…",
preview_failed: "Couldn't load preview",
},
code_block: { copy_code: "Copy code" },
}),
}),
}));
@@ -125,19 +124,11 @@ describe("HtmlAttachmentPreview — toolbar actions", () => {
expect(onDownload).toHaveBeenCalled();
});
it("writes the loaded text to the clipboard when Copy code is clicked", async () => {
it("does not render a Copy code button — attachments are files, not source snippets", async () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>chart source</p>",
text: "<p>ok</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"
@@ -146,19 +137,13 @@ describe("HtmlAttachmentPreview — toolbar actions", () => {
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>");
});
expect(screen.queryByTitle("Copy code")).toBeNull();
});
});
describe("HtmlAttachmentPreview — failure mode does not unmount the toolbar", () => {
it("keeps Open and Download enabled and disables Copy code when fetch errors", async () => {
it("keeps Preview and Download enabled when fetch errors", async () => {
getAttachmentTextContentMock.mockRejectedValueOnce(new Error("nope"));
const onPreview = vi.fn();
const onDownload = vi.fn();
@@ -177,16 +162,14 @@ describe("HtmlAttachmentPreview — failure mode does not unmount the toolbar",
).toBeTruthy();
});
// Critical: the figure does NOT collapse, and the chrome row is NOT
// rendered as a fallback. Open and Download stay reachable.
// rendered as a fallback. Preview 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();

View File

@@ -4,8 +4,12 @@
* 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).
* floating right-top toolbar reveals on hover with Preview (full-screen modal)
* and Download. No file-card chrome (icon + filename row).
*
* No "Copy code" button: this is a FILE, not an inline source snippet. The
* inline ```html``` fenced block (HtmlBlockPreview) is the surface for reading
* / copying HTML source; an attachment's contract is view + download.
*
* Mounted by AttachmentBlock when the attachment is HTML and the caller can
* supply an `attachmentId` (the /content proxy is ID-keyed). For other kinds,
@@ -14,13 +18,11 @@
* 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).
* user's only Preview/Download entry point. Instead the body collapses to an
* 80px placeholder and the toolbar pins itself open with both actions enabled.
*/
import { useState } from "react";
import { Check, Copy, Download, Maximize2 } from "lucide-react";
import { Download, Maximize2 } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { useT } from "../i18n";
import { useAttachmentHtmlText } from "./hooks/use-attachment-html-text";
@@ -43,24 +45,10 @@ export function HtmlAttachmentPreview({
}: 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
@@ -100,8 +88,8 @@ export function HtmlAttachmentPreview({
<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.
// Error state pins the toolbar open — Preview / Download are the
// only user-reachable escape hatches when inline render fails.
isError
? "opacity-100"
: "opacity-0 group-hover/html-preview:opacity-100",
@@ -133,27 +121,6 @@ export function HtmlAttachmentPreview({
>
<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>
);