mirror of
https://github.com/multica-ai/multica.git
synced 2026-08-03 19:20:07 +02:00
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:
@@ -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)", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user