mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-30 10:59:31 +02:00
Compare commits
3 Commits
agent/lamb
...
fix/html-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b4cf7acf20 | ||
|
|
d4864b0a3e | ||
|
|
78fa5d3c98 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />,
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
72
packages/views/attachments/attachment-preview-page.tsx
Normal file
72
packages/views/attachments/attachment-preview-page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
packages/views/attachments/index.ts
Normal file
1
packages/views/attachments/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { AttachmentPreviewPage } from "./attachment-preview-page";
|
||||
@@ -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)", () => {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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}}" },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -40,7 +40,8 @@
|
||||
"preview_failed": "预览加载失败",
|
||||
"preview_too_large": "文件太大,无法在线预览,请下载查看。",
|
||||
"preview_unsupported": "该文件类型暂不支持预览。",
|
||||
"close": "关闭"
|
||||
"close": "关闭",
|
||||
"open_in_new_tab": "在新标签页打开"
|
||||
},
|
||||
"link_hover": {
|
||||
"copy_link": "复制链接",
|
||||
|
||||
@@ -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
105
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user