Compare commits

...

1 Commits

Author SHA1 Message Date
Naiyuan Qing
7cafc96622 feat(editor): add open-in-new-tab to HTML attachment full-screen modal
The inline HtmlAttachmentPreview toolbar carries an "Open in new tab"
button that routes to /{slug}/attachments/{id}/preview. The full-screen
AttachmentPreviewModal was missing the same affordance, so users who
maximized an HTML preview lost the ability to pop it into its own tab.

Mirror the gating exactly: show when kind === 'html' && slug &&
attachmentId. Other PreviewKinds keep the existing header (Download +
Close) — they don't have a corresponding full-page route.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-19 08:57:28 +08:00
2 changed files with 179 additions and 1 deletions

View File

@@ -50,6 +50,37 @@ vi.mock("./use-download-attachment", () => ({
useDownloadAttachment: () => downloadMock,
}));
// Module-level flags toggled per-test: simulate desktop (openInNewTab
// adapter present) vs web (omitted), and the no-slug case where the
// modal sits outside a workspace route.
const { openInNewTabMock, getShareableUrlMock, navState, slugState } =
vi.hoisted(() => ({
openInNewTabMock: vi.fn(),
getShareableUrlMock: vi.fn((p: string) => `https://app.example${p}`),
navState: { hasOpenInNewTab: true },
slugState: { value: "acme" as string | null },
}));
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,
}),
}));
vi.mock("@multica/core/paths", async (importOriginal) => {
const actual = await importOriginal<typeof import("@multica/core/paths")>();
return {
...actual,
useWorkspaceSlug: () => slugState.value,
};
});
// ReadonlyContent has a heavy import surface (lowlight + KaTeX + Mermaid).
// Stub it so the markdown dispatch test only verifies wiring.
vi.mock("./readonly-content", () => ({
@@ -71,6 +102,7 @@ vi.mock("../i18n", () => ({
preview_unsupported: "This file type can't be previewed.",
close: "Close",
download_failed: "",
open_in_new_tab: "Open in new tab",
},
}),
}),
@@ -113,6 +145,8 @@ function makeAttachment(overrides: Partial<Attachment> = {}): Attachment {
beforeEach(() => {
vi.clearAllMocks();
navState.hasOpenInNewTab = true;
slugState.value = "acme";
});
afterEach(() => {
@@ -318,6 +352,114 @@ describe("AttachmentPreviewModal — URL-only source", () => {
});
});
describe("AttachmentPreviewModal — open-in-new-tab (HTML only)", () => {
it("renders the open-in-new-tab button in the header for HTML attachments", async () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>hi</p>",
originalContentType: "text/html",
});
const att = makeAttachment({
filename: "report.html",
content_type: "text/html",
});
render(
<AttachmentPreviewModal
source={{ kind: "full", attachment: att }}
open
onClose={() => {}}
/>,
);
expect(screen.getByTitle("Open in new tab")).toBeTruthy();
});
it("invokes navigation.openInNewTab with the preview path when available (desktop)", async () => {
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>hi</p>",
originalContentType: "text/html",
});
const att = makeAttachment({
filename: "report.html",
content_type: "text/html",
});
render(
<AttachmentPreviewModal
source={{ kind: "full", attachment: att }}
open
onClose={() => {}}
/>,
);
fireEvent.click(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 on web", async () => {
navState.hasOpenInNewTab = false;
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>hi</p>",
originalContentType: "text/html",
});
const windowOpenSpy = vi
.spyOn(window, "open")
.mockImplementation(() => null);
const att = makeAttachment({
filename: "report.html",
content_type: "text/html",
});
render(
<AttachmentPreviewModal
source={{ kind: "full", attachment: att }}
open
onClose={() => {}}
/>,
);
fireEvent.click(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",
);
});
it("does not render the new-tab button for non-HTML kinds", () => {
const att = makeAttachment({
filename: "manual.pdf",
content_type: "application/pdf",
});
render(
<AttachmentPreviewModal
source={{ kind: "full", attachment: att }}
open
onClose={() => {}}
/>,
);
expect(screen.queryByTitle("Open in new tab")).toBeNull();
});
it("does not render the new-tab button when there is no workspace slug", async () => {
slugState.value = null;
getAttachmentTextContentMock.mockResolvedValueOnce({
text: "<p>hi</p>",
originalContentType: "text/html",
});
const att = makeAttachment({
filename: "report.html",
content_type: "text/html",
});
render(
<AttachmentPreviewModal
source={{ kind: "full", attachment: att }}
open
onClose={() => {}}
/>,
);
expect(screen.queryByTitle("Open in new tab")).toBeNull();
});
});
describe("useAttachmentPreview — tryOpen gate", () => {
it("accepts a full attachment for a media kind", () => {
const { result } = renderHook(() => useAttachmentPreview());

View File

@@ -41,9 +41,11 @@ import {
PreviewTooLargeError,
PreviewUnsupportedError,
} from "@multica/core/api";
import { Download, FileText, Loader2, X } from "lucide-react";
import { Download, ExternalLink, FileText, Loader2, X } from "lucide-react";
import type { Attachment } from "@multica/core/types";
import { paths, useWorkspaceSlug } from "@multica/core/paths";
import { useT } from "../i18n";
import { useNavigation } from "../navigation";
import { openExternal } from "../platform";
import { ReadonlyContent } from "./readonly-content";
import {
@@ -178,6 +180,10 @@ export function AttachmentPreviewModal({
const { t } = useT("editor");
const download = useDownloadAttachment();
const state = normalize(source);
// useWorkspaceSlug (not useWorkspacePaths) — returns null outside a
// workspace route instead of throwing, so the new-tab button just hides.
const slug = useWorkspaceSlug();
const navigation = useNavigation();
useEffect(() => {
if (!open) return;
@@ -201,6 +207,25 @@ export function AttachmentPreviewModal({
}
};
// Open-in-new-tab mirrors HtmlAttachmentPreview's inline toolbar: only the
// `html` kind has a dedicated full-page route (/attachments/{id}/preview).
// Gated on slug + attachmentId for the same reason — URL-only sources
// can't address the /content proxy the page relies on.
const canOpenInNewTab = kind === "html" && !!slug && !!state.attachmentId;
const handleOpenInNewTab = () => {
if (!slug || !state.attachmentId) return;
const nameQuery = state.filename
? `?name=${encodeURIComponent(state.filename)}`
: "";
const path = `${paths.workspace(slug).attachmentPreview(state.attachmentId)}${nameQuery}`;
if (navigation.openInNewTab) {
navigation.openInNewTab(path, state.filename);
return;
}
const url = navigation.getShareableUrl(path);
window.open(url, "_blank", "noopener,noreferrer");
};
if (!open || typeof document === "undefined") return null;
return createPortal(
@@ -226,6 +251,17 @@ export function AttachmentPreviewModal({
{state.contentType || "—"}
</span>
<div className="ml-auto flex items-center gap-1">
{canOpenInNewTab && (
<button
type="button"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
title={t(($) => $.attachment.open_in_new_tab)}
aria-label={t(($) => $.attachment.open_in_new_tab)}
onClick={handleOpenInNewTab}
>
<ExternalLink className="size-4" />
</button>
)}
<button
type="button"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"