Compare commits

...

3 Commits

Author SHA1 Message Date
Naiyuan Qing
b4cf7acf20 fix(editor): bump hast-util-to-html to v9 so lowlight output actually serializes
Source view of fenced ```html (and any other code block falling through to
the lowlight branch in ReadonlyContent) silently rendered as un-highlighted
escaped text. Root cause was a stale dep pin: `hast-util-to-html: ^4.0.1`
predates the package's ESM/named-export rewrite — v4 only exports a CJS
default function, so the `import { toHtml } from "hast-util-to-html"` in
code-block-static.tsx:19 and readonly-content.tsx:32 resolved to
`undefined` at runtime. The try/catch in both call sites caught the
"toHtml is not a function" throw and fell through to escapeHtml plain
text, so no `.hljs-*` spans ever made it to the DOM and the syntax-color
CSS added in 78fa5d3c had nothing to attach to.

Bumping to ^9.0.5 (matches the v9 line that lowlight@3 / remark / rehype
ship in the rest of the tree) makes the named `toHtml` export available
and source-view highlighting works.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:53:23 +08:00
Naiyuan Qing
d4864b0a3e feat(editor): open HTML attachment in new tab + full-page preview route
Adds a third toolbar button to HtmlAttachmentPreview between Maximize and
Download: open the attachment in a new app tab (desktop) or browser tab
(web). The full-screen modal stays — they serve different scenarios:
modal for a quick "see it bigger" without leaving the issue context,
new-tab when the user wants to keep the rendered HTML around while
working on something else.

Components:
- New workspace path: `/{slug}/attachments/{id}/preview?name={filename}`.
  Lives outside the (dashboard) group on web so the iframe gets the full
  viewport — sidebar would defeat the point. Desktop registers the route
  inside `WorkspaceRouteLayout` so workspace context resolution still
  runs (no slug → no path is built).
- `packages/views/attachments/attachment-preview-page.tsx`: shared full-
  page view that reuses `useAttachmentHtmlText` for the iframe srcDoc.
  Sandbox stays `allow-scripts` (no allow-same-origin) — same security
  posture as the inline preview.
- `HtmlAttachmentPreview`: adds Open-in-new-tab button. Routes through
  `useNavigation().openInNewTab` when available (desktop), falls back to
  `window.open(getShareableUrl(path))` on web. Button is hidden when no
  workspace slug is in scope (shouldn't happen in practice, but the
  shared component must not throw outside a workspace route).

Tests cover: desktop openInNewTab call args, web window.open fallback,
and that the failure-mode toolbar still surfaces all three actions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 18:47:20 +08:00
Naiyuan Qing
78fa5d3c98 fix(editor): highlight HTML source view + drop misplaced Copy on attachments
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>
2026-05-18 18:13:21 +08:00
17 changed files with 363 additions and 172 deletions

View File

@@ -0,0 +1,16 @@
import { useParams, useSearchParams } from "react-router-dom";
import { AttachmentPreviewPage } from "@multica/views/attachments";
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
export function AttachmentPreviewRoute() {
const { id } = useParams<{ id: string }>();
const [searchParams] = useSearchParams();
const filename = searchParams.get("name") ?? undefined;
if (!id) return null;
return (
<ErrorBoundary resetKeys={[id]}>
<AttachmentPreviewPage attachmentId={id} filename={filename} />
</ErrorBoundary>
);
}

View File

@@ -13,6 +13,7 @@ import { SkillDetailPage } from "./pages/skill-detail-page";
import { AgentDetailPage } from "./pages/agent-detail-page";
import { MemberDetailPage } from "./pages/member-detail-page";
import { RuntimeDetailPage } from "./pages/runtime-detail-page";
import { AttachmentPreviewRoute } from "./pages/attachment-preview-page";
import { IssuesPage } from "@multica/views/issues/components";
import { ProjectsPage } from "@multica/views/projects/components";
import { DashboardPage } from "@multica/views/dashboard";
@@ -160,6 +161,11 @@ export const appRoutes: RouteObject[] = [
handle: { title: "Squad" },
},
{ path: "inbox", element: <InboxPage />, handle: { title: "Inbox" } },
{
path: "attachments/:id/preview",
element: <AttachmentPreviewRoute />,
handle: { title: "Attachment" },
},
{
path: "usage",
element: <DashboardPage />,

View File

@@ -0,0 +1,26 @@
"use client";
import { use } from "react";
import { useSearchParams } from "next/navigation";
import { AttachmentPreviewPage } from "@multica/views/attachments";
import { ErrorBoundary } from "@multica/ui/components/common/error-boundary";
// Lives at /:slug/attachments/:id/preview — OUTSIDE the (dashboard) group on
// purpose. The dashboard layout adds a left sidebar + top chrome; this page
// wants the full viewport for the HTML iframe. Workspace resolution still
// happens in the parent [workspaceSlug] layout so useWorkspaceId() works.
export default function AttachmentPreviewWebPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const { id } = use(params);
const search = useSearchParams();
const filename = search.get("name") ?? undefined;
return (
<ErrorBoundary resetKeys={[id]}>
<AttachmentPreviewPage attachmentId={id} filename={filename} />
</ErrorBoundary>
);
}

View File

@@ -22,6 +22,7 @@ describe("paths.workspace(slug)", () => {
expect(ws.squads()).toBe("/acme/squads");
expect(ws.squadDetail("sq_1")).toBe("/acme/squads/sq_1");
expect(ws.settings()).toBe("/acme/settings");
expect(ws.attachmentPreview("att_42")).toBe("/acme/attachments/att_42/preview");
});
it("URL-encodes special characters in ids", () => {

View File

@@ -37,6 +37,7 @@ function workspaceScoped(slug: string) {
skills: () => `${ws}/skills`,
skillDetail: (id: string) => `${ws}/skills/${encode(id)}`,
settings: () => `${ws}/settings`,
attachmentPreview: (id: string) => `${ws}/attachments/${encode(id)}/preview`,
};
}

View File

@@ -0,0 +1,72 @@
"use client";
/**
* AttachmentPreviewPage — full-page HTML attachment viewer.
*
* Destination for `openInNewTab` from HtmlAttachmentPreview's toolbar. The
* inline preview (HtmlAttachmentPreview) renders the same content in a 480px
* card with a hover toolbar; this is the same content edge-to-edge so the
* user can resize / interact with the document at full size.
*
* Same security posture as the inline preview: iframe sandbox is
* "allow-scripts" only — no allow-same-origin, no allow-top-navigation. The
* iframe runs in an opaque origin and cannot reach cookies, localStorage,
* parent, or top-level navigation.
*
* The route is workspace-scoped (`/{slug}/attachments/{id}/preview`) for
* tenancy isolation; the `/api/attachments/{id}/content` proxy itself is
* already auth-checked, so the slug is purely a URL contract.
*/
import { useEffect } from "react";
import { useT } from "../i18n";
import { useAttachmentHtmlText } from "../editor/hooks/use-attachment-html-text";
interface AttachmentPreviewPageProps {
attachmentId: string;
/** Optional display name. Falls back to a generic label and is only used
* for the document title — never echoed into the iframe sandbox. */
filename?: string;
}
export function AttachmentPreviewPage({
attachmentId,
filename,
}: AttachmentPreviewPageProps) {
const { t } = useT("editor");
const query = useAttachmentHtmlText(attachmentId);
// Set document.title so desktop's MutationObserver-based tab title picks
// up the filename. Web shows the same string in the browser tab.
useEffect(() => {
if (filename) document.title = filename;
}, [filename]);
const text = query.data?.text;
const isLoading = query.isLoading;
const isError = !isLoading && (!!query.error || !text);
return (
<div className="flex h-full w-full flex-col bg-background">
{isLoading ? (
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
{t(($) => $.attachment.preview_loading)}
</div>
) : isError ? (
<div
className="flex flex-1 items-center justify-center px-4 text-sm text-muted-foreground"
data-testid="attachment-preview-page-error"
>
{t(($) => $.attachment.preview_failed)}
</div>
) : (
<iframe
srcDoc={text}
sandbox="allow-scripts"
title={filename ?? "HTML attachment"}
className="flex-1 w-full border-0 bg-background"
/>
)}
</div>
);
}

View File

@@ -0,0 +1 @@
export { AttachmentPreviewPage } from "./attachment-preview-page";

View File

@@ -22,13 +22,34 @@ vi.mock("../i18n", () => ({
preview: "Preview",
preview_loading: "Loading preview…",
preview_failed: "Couldn't load preview",
open_in_new_tab: "Open in new tab",
},
code_block: { copy_code: "Copy code" },
file_card: { uploading: "Uploading {{filename}}" },
}),
}),
}));
vi.mock("../navigation", () => ({
useNavigation: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
pathname: "/acme/issues",
searchParams: new URLSearchParams(),
openInNewTab: vi.fn(),
getShareableUrl: (p: string) => `https://app.example${p}`,
}),
}));
vi.mock("@multica/core/paths", async (importOriginal) => {
const actual = await importOriginal<typeof import("@multica/core/paths")>();
return {
...actual,
useWorkspaceSlug: () => "acme",
useWorkspacePaths: () => actual.paths.workspace("acme"),
};
});
import { AttachmentBlock } from "./attachment-block";
function renderWithQuery(ui: ReactElement) {
@@ -60,9 +81,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

@@ -35,6 +35,29 @@ vi.mock("../attachment-preview-modal", () => ({
useAttachmentPreview: () => ({ tryOpen: tryOpenMock, open: vi.fn(), modal: null }),
}));
// HtmlAttachmentPreview (the kind="html" route through AttachmentBlock) now
// reads useNavigation() + useWorkspaceSlug() for its Open-in-new-tab button.
// Provide minimal mocks so the component renders without a real provider.
vi.mock("../../navigation", () => ({
useNavigation: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
pathname: "/acme/issues",
searchParams: new URLSearchParams(),
openInNewTab: vi.fn(),
getShareableUrl: (p: string) => `https://app.example${p}`,
}),
}));
vi.mock("@multica/core/paths", async (importOriginal) => {
const actual = await importOriginal<typeof import("@multica/core/paths")>();
return {
...actual,
useWorkspaceSlug: () => "acme",
};
});
vi.mock("../i18n", () => ({
useT: () => ({
t: (sel: (s: Record<string, Record<string, string>>) => string) =>
@@ -44,6 +67,7 @@ vi.mock("../i18n", () => ({
preview: "Preview",
preview_loading: "Loading preview…",
preview_failed: "Couldn't load preview",
open_in_new_tab: "Open in new tab",
},
code_block: { copy_code: "Copy code" },
file_card: { uploading: "Uploading {{filename}}" },

View File

@@ -22,12 +22,45 @@ vi.mock("../i18n", () => ({
preview: "Preview",
preview_loading: "Loading preview…",
preview_failed: "Couldn't load preview",
open_in_new_tab: "Open in new tab",
},
code_block: { copy_code: "Copy code" },
}),
}),
}));
// Module-level flag toggled per-test to simulate desktop (openInNewTab
// present) vs web (omitted) adapters. vi.hoisted so the mock factory can
// close over it.
const { openInNewTabMock, getShareableUrlMock, navState } = vi.hoisted(() => ({
openInNewTabMock: vi.fn(),
getShareableUrlMock: vi.fn((p: string) => `https://app.example${p}`),
navState: { hasOpenInNewTab: true },
}));
vi.mock("../navigation", () => ({
useNavigation: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
pathname: "/acme/issues",
searchParams: new URLSearchParams(),
...(navState.hasOpenInNewTab ? { openInNewTab: openInNewTabMock } : {}),
getShareableUrl: getShareableUrlMock,
}),
}));
// Slug is required for the new-tab path to be built. The component reads
// it from useWorkspaceSlug() on @multica/core/paths — stub to return a
// fixed slug so the tests do not need a WorkspaceSlugProvider tree.
vi.mock("@multica/core/paths", async (importOriginal) => {
const actual = await importOriginal<typeof import("@multica/core/paths")>();
return {
...actual,
useWorkspaceSlug: () => "acme",
useWorkspacePaths: () => actual.paths.workspace("acme"),
};
});
import { HtmlAttachmentPreview } from "./html-attachment-preview";
function renderWithQuery(ui: ReactElement) {
@@ -37,7 +70,10 @@ function renderWithQuery(ui: ReactElement) {
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
beforeEach(() => vi.clearAllMocks());
beforeEach(() => {
vi.clearAllMocks();
navState.hasOpenInNewTab = true;
});
afterEach(() => vi.restoreAllMocks());
describe("HtmlAttachmentPreview — visual shell (does not use file-card chrome)", () => {
@@ -125,19 +161,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 +174,65 @@ 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();
});
it("invokes navigation.openInNewTab with the preview path when available (desktop)", async () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>ok</p>",
originalContentType: "text/html",
});
renderWithQuery(
<HtmlAttachmentPreview
attachmentId="att-1"
filename="report.html"
onPreview={() => {}}
onDownload={() => {}}
/>,
);
await waitFor(() =>
expect(screen.getByTitle("Open in new tab")).toBeTruthy(),
);
fireEvent.mouseDown(screen.getByTitle("Open in new tab"));
expect(openInNewTabMock).toHaveBeenCalledWith(
"/acme/attachments/att-1/preview?name=report.html",
"report.html",
);
});
it("falls back to window.open against the shareable URL when openInNewTab is absent (web)", async () => {
navState.hasOpenInNewTab = false;
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>ok</p>",
originalContentType: "text/html",
});
const windowOpenSpy = vi
.spyOn(window, "open")
.mockImplementation(() => null);
renderWithQuery(
<HtmlAttachmentPreview
attachmentId="att-1"
filename="report.html"
onPreview={() => {}}
onDownload={() => {}}
/>,
);
await waitFor(() =>
expect(screen.getByTitle("Open in new tab")).toBeTruthy(),
);
fireEvent.mouseDown(screen.getByTitle("Open in new tab"));
expect(openInNewTabMock).not.toHaveBeenCalled();
expect(windowOpenSpy).toHaveBeenCalledWith(
"https://app.example/acme/attachments/att-1/preview?name=report.html",
"_blank",
"noopener,noreferrer",
);
});
});
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 +251,18 @@ 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;
const openInNewTabBtn = screen.getByTitle(
"Open in new tab",
) as HTMLButtonElement;
expect(previewBtn.disabled).toBe(false);
expect(downloadBtn.disabled).toBe(false);
expect(copyBtn.disabled).toBe(true);
expect(openInNewTabBtn.disabled).toBe(false);
fireEvent.mouseDown(previewBtn);
expect(onPreview).toHaveBeenCalled();

View File

@@ -4,8 +4,16 @@
* 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)
* / Open-in-new-tab / 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.
*
* Open-in-new-tab routes to `/{slug}/attachments/{id}/preview` — desktop uses
* `openInNewTab` to add an app tab; web falls back to `window.open` against
* the shareable URL.
*
* 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,15 +22,15 @@
* 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 all actions enabled.
*/
import { useState } from "react";
import { Check, Copy, Download, Maximize2 } from "lucide-react";
import { Download, ExternalLink, Maximize2 } from "lucide-react";
import { cn } from "@multica/ui/lib/utils";
import { paths, useWorkspaceSlug } from "@multica/core/paths";
import { useT } from "../i18n";
import { useNavigation } from "../navigation";
import { useAttachmentHtmlText } from "./hooks/use-attachment-html-text";
const PREVIEW_HEIGHT = "h-[480px]";
@@ -43,23 +51,32 @@ export function HtmlAttachmentPreview({
}: HtmlAttachmentPreviewProps) {
const { t } = useT("editor");
const query = useAttachmentHtmlText(attachmentId);
const [copied, setCopied] = useState(false);
// useWorkspaceSlug — NOT useWorkspacePaths. The Paths-bound variant throws
// when there's no slug; we want to render gracefully (just hide the
// new-tab button) when the component is somehow mounted outside a
// workspace route.
const slug = useWorkspaceSlug();
const navigation = useNavigation();
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.
// Only enable the new-tab button when the workspace slug is resolvable —
// outside a workspace context the path is meaningless. Prefer desktop's
// tab system; on web fall back to window.open against the public shareable
// URL (auth is handled by the cookie session on the new page).
const canOpenInNewTab = !!slug && !!attachmentId;
const handleOpenInNewTab = () => {
if (!slug) return;
const nameQuery = filename ? `?name=${encodeURIComponent(filename)}` : "";
const path = `${paths.workspace(slug).attachmentPreview(attachmentId)}${nameQuery}`;
if (navigation.openInNewTab) {
navigation.openInNewTab(path, filename);
return;
}
const url = navigation.getShareableUrl(path);
window.open(url, "_blank", "noopener,noreferrer");
};
return (
@@ -100,8 +117,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",
@@ -120,6 +137,21 @@ export function HtmlAttachmentPreview({
>
<Maximize2 className="h-3.5 w-3.5" />
</button>
{canOpenInNewTab && (
<button
type="button"
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
title={t(($) => $.attachment.open_in_new_tab)}
aria-label={t(($) => $.attachment.open_in_new_tab)}
onMouseDown={(e) => {
e.preventDefault();
e.stopPropagation();
handleOpenInNewTab();
}}
>
<ExternalLink className="h-3.5 w-3.5" />
</button>
)}
<button
type="button"
className="flex h-6 w-6 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
@@ -133,27 +165,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>
);

View File

@@ -16,6 +16,30 @@ vi.mock("@multica/core/api", () => ({
PreviewUnsupportedError: class extends Error {},
}));
// HtmlAttachmentPreview (kind="html" dispatch from AttachmentBlock) reads
// useNavigation() + useWorkspaceSlug() for the Open-in-new-tab button.
// Mock both so the standalone-attachment-routes-to-iframe test does not
// need the surrounding NavigationProvider / WorkspaceSlugProvider tree.
vi.mock("../../navigation", () => ({
useNavigation: () => ({
push: vi.fn(),
replace: vi.fn(),
back: vi.fn(),
pathname: "/acme/issues",
searchParams: new URLSearchParams(),
openInNewTab: vi.fn(),
getShareableUrl: (p: string) => `https://app.example${p}`,
}),
}));
vi.mock("@multica/core/paths", async (importOriginal) => {
const actual = await importOriginal<typeof import("@multica/core/paths")>();
return {
...actual,
useWorkspaceSlug: () => "acme",
};
});
import { AttachmentList } from "./comment-card";
function renderWithQuery(ui: ReactElement) {

View File

@@ -40,7 +40,8 @@
"preview_failed": "Couldn't load preview",
"preview_too_large": "File is too large to preview. Please download.",
"preview_unsupported": "This file type can't be previewed.",
"close": "Close"
"close": "Close",
"open_in_new_tab": "Open in new tab"
},
"link_hover": {
"copy_link": "Copy link",

View File

@@ -40,7 +40,8 @@
"preview_failed": "预览加载失败",
"preview_too_large": "文件太大,无法在线预览,请下载查看。",
"preview_unsupported": "该文件类型暂不支持预览。",
"close": "关闭"
"close": "关闭",
"open_in_new_tab": "在新标签页打开"
},
"link_hover": {
"copy_link": "复制链接",

View File

@@ -46,6 +46,7 @@
"./onboarding": "./onboarding/index.ts",
"./platform": "./platform/index.ts",
"./i18n": "./i18n/index.ts",
"./attachments": "./attachments/index.ts",
"./locales": "./locales/index.ts",
"./locales/*": "./locales/*"
},
@@ -77,7 +78,7 @@
"@tiptap/starter-kit": "^3.22.1",
"@tiptap/suggestion": "^3.22.1",
"cmdk": "^1.1.1",
"hast-util-to-html": "^4.0.1",
"hast-util-to-html": "^9.0.5",
"katex": "catalog:",
"lowlight": "^3.3.0",
"mermaid": "catalog:",

105
pnpm-lock.yaml generated
View File

@@ -779,8 +779,8 @@ importers:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
hast-util-to-html:
specifier: ^4.0.1
version: 4.0.1
specifier: ^9.0.5
version: 9.0.5
i18next:
specifier: 'catalog:'
version: 26.0.8(typescript@5.9.3)
@@ -3583,9 +3583,6 @@ packages:
caniuse-lite@1.0.30001780:
resolution: {integrity: sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ==}
ccount@1.1.0:
resolution: {integrity: sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg==}
ccount@2.0.1:
resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==}
@@ -3601,15 +3598,9 @@ packages:
resolution: {integrity: sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==}
engines: {node: ^12.17.0 || ^14.13 || >=16.0.0}
character-entities-html4@1.1.4:
resolution: {integrity: sha512-HRcDxZuZqMx3/a+qrzxdBKBPUpxWEq9xw2OPZ3a/174ihfrQKVsFhqtthBInFy1zZ9GgZyFXOatNujm8M+El3g==}
character-entities-html4@2.1.0:
resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==}
character-entities-legacy@1.1.4:
resolution: {integrity: sha512-3Xnr+7ZFS1uxeiUDvV02wQ+QDbc55o97tIV5zHScSPJpcLm/r0DFPcoY3tYRp+VZukxuMeKgXYmsXQHO05zQeA==}
character-entities-legacy@3.0.0:
resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==}
@@ -3711,9 +3702,6 @@ packages:
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
engines: {node: '>= 0.8'}
comma-separated-tokens@1.0.8:
resolution: {integrity: sha512-GHuDRO12Sypu2cV70d1dkA2EUmXHgntrzbpvOB+Qy+49ypNfGgFQIC2fhhXbnyrJRynDCAARsT7Ou0M6hirpfw==}
comma-separated-tokens@2.0.3:
resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==}
@@ -4852,9 +4840,6 @@ packages:
hast-util-from-parse5@8.0.3:
resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==}
hast-util-is-element@1.1.0:
resolution: {integrity: sha512-oUmNua0bFbdrD/ELDSSEadRVtWZOf3iF6Lbv81naqsIV99RnSCieTbWuWCY8BAeEfKJTKl0gRdokv+dELutHGQ==}
hast-util-is-element@3.0.0:
resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==}
@@ -4870,9 +4855,6 @@ packages:
hast-util-to-estree@3.1.3:
resolution: {integrity: sha512-48+B/rJWAp0jamNbAAf9M7Uf//UVqAoMmgXhBdxTDJLGKY+LRnZ99qcG+Qjl5HfMpYNzS5v4EAwVEF34LeAj7w==}
hast-util-to-html@4.0.1:
resolution: {integrity: sha512-2emzwyf0xEsc4TBIPmDJmBttIw8R4SXAJiJZoiRR/s47ODYWgOqNoDbf2SJAbMbfNdFWMiCSOrI3OVnX6Qq2Mg==}
hast-util-to-html@9.0.5:
resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==}
@@ -4888,9 +4870,6 @@ packages:
hast-util-to-text@4.0.2:
resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==}
hast-util-whitespace@1.0.4:
resolution: {integrity: sha512-I5GTdSfhYfAPNztx2xJRQpG8cuDSNt599/7YUn7Gx/WxNMsG+a835k97TDkFgk123cwjfwINaZknkKkphx/f2A==}
hast-util-whitespace@3.0.0:
resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==}
@@ -4922,9 +4901,6 @@ packages:
html-url-attributes@3.0.1:
resolution: {integrity: sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==}
html-void-elements@1.0.5:
resolution: {integrity: sha512-uE/TxKuyNIcx44cIWnjr/rfIATDH7ZaOMmstu0CwhFG1Dunhlp4OC6/NMbhiwoq5BpW0ubi303qnEk/PZj614w==}
html-void-elements@3.0.0:
resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==}
@@ -5045,15 +5021,9 @@ packages:
resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==}
engines: {node: '>= 0.10'}
is-alphabetical@1.0.4:
resolution: {integrity: sha512-DwzsA04LQ10FHTZuL0/grVDk4rFoVH1pjAToYwBrHSxcrBIGQuXrQMtD5U1b0U2XVgKZCTLLP8u2Qxqhy3l2Vg==}
is-alphabetical@2.0.1:
resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==}
is-alphanumerical@1.0.4:
resolution: {integrity: sha512-UzoZUr+XfVz3t3v4KyGEniVL9BDRoQtY7tOyrRybkVNjDFWyo1yhXNGrrBTQxp3ib9BLAWs7k2YKBQsFRkZG9A==}
is-alphanumerical@2.0.1:
resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==}
@@ -5092,9 +5062,6 @@ packages:
resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==}
engines: {node: '>= 0.4'}
is-decimal@1.0.4:
resolution: {integrity: sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw==}
is-decimal@2.0.1:
resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==}
@@ -5123,9 +5090,6 @@ packages:
resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==}
engines: {node: '>=0.10.0'}
is-hexadecimal@1.0.4:
resolution: {integrity: sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==}
is-hexadecimal@2.0.1:
resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==}
@@ -6326,9 +6290,6 @@ packages:
proper-lockfile@4.1.2:
resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==}
property-information@4.2.0:
resolution: {integrity: sha512-TlgDPagHh+eBKOnH2VYvk8qbwsCG/TAJdmTL7f1PROUcSO8qt/KSmShEQ/OKvock8X9tFjtqjCScyOkkkvIKVQ==}
property-information@7.1.0:
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
@@ -6926,9 +6887,6 @@ packages:
resolution: {integrity: sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==}
engines: {node: '>= 12'}
space-separated-tokens@1.1.5:
resolution: {integrity: sha512-q/JSVd1Lptzhf5bkYm4ob4iWPjx0KiRe3sRFBNrVqbJkFaBm5vbbowy1mymoPNLRa52+oadOhJ+K49wsSeSjTA==}
space-separated-tokens@2.0.2:
resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==}
@@ -7002,9 +6960,6 @@ packages:
string_decoder@1.3.0:
resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==}
stringify-entities@1.3.2:
resolution: {integrity: sha512-nrBAQClJAPN2p+uGCVJRPIPakKeKWZ9GtBCmormE7pWOSlHat7+x5A8gx85M7HM5Dt0BP3pP5RhVW77WdbJJ3A==}
stringify-entities@4.0.4:
resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==}
@@ -7281,9 +7236,6 @@ packages:
unist-util-find-after@5.0.0:
resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==}
unist-util-is@2.1.3:
resolution: {integrity: sha512-4WbQX2iwfr/+PfM4U3zd2VNXY+dWtZsN1fLnWEi2QQXA4qyDYAZcDMfXUX0Cu6XZUHHAO9q4nyxxLT4Awk1qUA==}
unist-util-is@6.0.1:
resolution: {integrity: sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==}
@@ -10449,8 +10401,6 @@ snapshots:
caniuse-lite@1.0.30001780: {}
ccount@1.1.0: {}
ccount@2.0.1: {}
chai@6.2.2: {}
@@ -10462,12 +10412,8 @@ snapshots:
chalk@5.6.2: {}
character-entities-html4@1.1.4: {}
character-entities-html4@2.1.0: {}
character-entities-legacy@1.1.4: {}
character-entities-legacy@3.0.0: {}
character-entities@2.0.2: {}
@@ -10563,8 +10509,6 @@ snapshots:
dependencies:
delayed-stream: 1.0.0
comma-separated-tokens@1.0.8: {}
comma-separated-tokens@2.0.3: {}
commander@11.1.0: {}
@@ -11986,8 +11930,6 @@ snapshots:
vfile-location: 5.0.3
web-namespaces: 2.0.1
hast-util-is-element@1.1.0: {}
hast-util-is-element@3.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -12039,19 +11981,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
hast-util-to-html@4.0.1:
dependencies:
ccount: 1.1.0
comma-separated-tokens: 1.0.8
hast-util-is-element: 1.1.0
hast-util-whitespace: 1.0.4
html-void-elements: 1.0.5
property-information: 4.2.0
space-separated-tokens: 1.1.5
stringify-entities: 1.3.2
unist-util-is: 2.1.3
xtend: 4.0.2
hast-util-to-html@9.0.5:
dependencies:
'@types/hast': 3.0.4
@@ -12107,8 +12036,6 @@ snapshots:
hast-util-is-element: 3.0.0
unist-util-find-after: 5.0.0
hast-util-whitespace@1.0.4: {}
hast-util-whitespace@3.0.0:
dependencies:
'@types/hast': 3.0.4
@@ -12143,8 +12070,6 @@ snapshots:
html-url-attributes@3.0.1: {}
html-void-elements@1.0.5: {}
html-void-elements@3.0.0: {}
http-cache-semantics@4.2.0: {}
@@ -12247,15 +12172,8 @@ snapshots:
ipaddr.js@1.9.1: {}
is-alphabetical@1.0.4: {}
is-alphabetical@2.0.1: {}
is-alphanumerical@1.0.4:
dependencies:
is-alphabetical: 1.0.4
is-decimal: 1.0.4
is-alphanumerical@2.0.1:
dependencies:
is-alphabetical: 2.0.1
@@ -12303,8 +12221,6 @@ snapshots:
call-bound: 1.0.4
has-tostringtag: 1.0.2
is-decimal@1.0.4: {}
is-decimal@2.0.1: {}
is-docker@3.0.0: {}
@@ -12329,8 +12245,6 @@ snapshots:
dependencies:
is-extglob: 2.1.1
is-hexadecimal@1.0.4: {}
is-hexadecimal@2.0.1: {}
is-in-ssh@1.0.0: {}
@@ -13820,10 +13734,6 @@ snapshots:
retry: 0.12.0
signal-exit: 3.0.7
property-information@4.2.0:
dependencies:
xtend: 4.0.2
property-information@7.1.0: {}
prosemirror-changeset@2.4.0:
@@ -14691,8 +14601,6 @@ snapshots:
source-map@0.7.6: {}
space-separated-tokens@1.1.5: {}
space-separated-tokens@2.0.2: {}
split2@4.2.0: {}
@@ -14787,13 +14695,6 @@ snapshots:
dependencies:
safe-buffer: 5.2.1
stringify-entities@1.3.2:
dependencies:
character-entities-html4: 1.1.4
character-entities-legacy: 1.1.4
is-alphanumerical: 1.0.4
is-hexadecimal: 1.0.4
stringify-entities@4.0.4:
dependencies:
character-entities-html4: 2.1.0
@@ -15074,8 +14975,6 @@ snapshots:
'@types/unist': 3.0.3
unist-util-is: 6.0.1
unist-util-is@2.1.3: {}
unist-util-is@6.0.1:
dependencies:
'@types/unist': 3.0.3