mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-17 03:38:32 +02:00
fix(editor): align Preview gate with Download — survive URL-only sources (#2566)
The Eye button required a fully resolved Attachment record (URL-lookup
via `resolveAttachment(href)`) before showing. Download only required
the URL, falling back to `openExternal(href)` when the lookup missed.
Result: any case where the URL in markdown couldn't be reverse-matched
to the entity's `attachments` prop (cross-comment copy-paste, stale
caches) silently hid the Preview button while Download kept working —
edit and readonly surfaces diverged for the same content.
Widen the Preview gate to mirror Download: show the Eye whenever the
filename indicates a previewable type. Introduce a `PreviewSource`
tagged union — `{ kind: "full", attachment }` for the existing path,
`{ kind: "url", url, filename }` for the fallback. Media kinds
(pdf/video/audio) render directly from the URL; text kinds still
require an attachment id because the /content proxy is ID-keyed, so
`tryOpen` rejects URL+text combinations and PreviewContent has a
defensive fallback for direct mounts.
Side effects:
- `getPreviewKind` gains filename-extension fallbacks for video/audio
(was PDF-only); without these the URL-only path can't infer kind
when content_type is empty.
- AttachmentList in comment-card.tsx unchanged behaviorally — only the
tryOpen call site is updated to the new signature.
Pre-existing architectural issues (AttachmentList readonly-only,
URL-based attachment lookup, per-entity ownership) are intentionally
out of scope.
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,6 +4,12 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactElement } from "react";
|
||||
import type { Attachment } from "@multica/core/types";
|
||||
|
||||
const openExternalMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("../platform", () => ({
|
||||
openExternal: openExternalMock,
|
||||
}));
|
||||
|
||||
// vi.hoisted: factories run before module evaluation, letting us name mocks
|
||||
// referenced from inside vi.mock factories below. The Error classes must be
|
||||
// hoisted too because vi.mock is itself hoisted above the top-level `class`
|
||||
@@ -70,7 +76,11 @@ vi.mock("../i18n", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
import { AttachmentPreviewModal } from "./attachment-preview-modal";
|
||||
import {
|
||||
AttachmentPreviewModal,
|
||||
useAttachmentPreview,
|
||||
} from "./attachment-preview-modal";
|
||||
import { renderHook, act as hookAct } from "@testing-library/react";
|
||||
|
||||
// Fresh QueryClient per render — no retries (preview errors are typed,
|
||||
// not transient) and no caching across tests so each scenario is hermetic.
|
||||
@@ -112,7 +122,7 @@ afterEach(() => {
|
||||
describe("AttachmentPreviewModal — dispatch", () => {
|
||||
it("renders a PDF iframe pointing at the signed download URL", () => {
|
||||
const att = makeAttachment({ filename: "manual.pdf", content_type: "application/pdf" });
|
||||
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
|
||||
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
|
||||
const iframe = document.querySelector("iframe");
|
||||
expect(iframe).toBeTruthy();
|
||||
expect(iframe?.getAttribute("src")).toBe(att.download_url);
|
||||
@@ -120,7 +130,7 @@ describe("AttachmentPreviewModal — dispatch", () => {
|
||||
|
||||
it("renders a <video> for video/* content types", () => {
|
||||
const att = makeAttachment({ filename: "clip.mp4", content_type: "video/mp4" });
|
||||
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
|
||||
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
|
||||
const video = document.querySelector("video");
|
||||
expect(video).toBeTruthy();
|
||||
expect(video?.getAttribute("src")).toBe(att.download_url);
|
||||
@@ -128,7 +138,7 @@ describe("AttachmentPreviewModal — dispatch", () => {
|
||||
|
||||
it("renders an <audio> for audio/* content types", () => {
|
||||
const att = makeAttachment({ filename: "note.mp3", content_type: "audio/mpeg" });
|
||||
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
|
||||
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
|
||||
const audio = document.querySelector("audio");
|
||||
expect(audio).toBeTruthy();
|
||||
});
|
||||
@@ -139,7 +149,7 @@ describe("AttachmentPreviewModal — dispatch", () => {
|
||||
originalContentType: "text/markdown",
|
||||
});
|
||||
const att = makeAttachment({ filename: "README.md", content_type: "text/markdown" });
|
||||
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
|
||||
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
|
||||
|
||||
expect(getAttachmentTextContentMock).toHaveBeenCalledWith("att-1");
|
||||
|
||||
@@ -155,7 +165,7 @@ describe("AttachmentPreviewModal — dispatch", () => {
|
||||
originalContentType: "text/html",
|
||||
});
|
||||
const att = makeAttachment({ filename: "page.html", content_type: "text/html" });
|
||||
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
|
||||
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const frame = document.querySelector("iframe[sandbox]") as HTMLIFrameElement | null;
|
||||
@@ -171,7 +181,7 @@ describe("AttachmentPreviewModal — dispatch", () => {
|
||||
originalContentType: "text/plain",
|
||||
});
|
||||
const att = makeAttachment({ filename: "main.go", content_type: "text/plain" });
|
||||
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
|
||||
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const code = document.querySelector("code.hljs");
|
||||
@@ -182,7 +192,7 @@ describe("AttachmentPreviewModal — dispatch", () => {
|
||||
|
||||
it("shows unsupported fallback when no PreviewKind matches", () => {
|
||||
const att = makeAttachment({ filename: "blob.zip", content_type: "application/zip" });
|
||||
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
|
||||
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
|
||||
expect(screen.getByText("This file type can't be previewed.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -191,7 +201,7 @@ describe("AttachmentPreviewModal — error states", () => {
|
||||
it("shows the too-large fallback on PreviewTooLargeError", async () => {
|
||||
getAttachmentTextContentMock.mockRejectedValueOnce(new FakePreviewTooLargeError());
|
||||
const att = makeAttachment({ filename: "huge.txt", content_type: "text/plain" });
|
||||
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
|
||||
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("File is too large to preview. Please download.")).toBeTruthy();
|
||||
});
|
||||
@@ -200,7 +210,7 @@ describe("AttachmentPreviewModal — error states", () => {
|
||||
it("shows the unsupported fallback on PreviewUnsupportedError (server/client drift)", async () => {
|
||||
getAttachmentTextContentMock.mockRejectedValueOnce(new FakePreviewUnsupportedError());
|
||||
const att = makeAttachment({ filename: "weird.txt", content_type: "text/plain" });
|
||||
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
|
||||
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("This file type can't be previewed.")).toBeTruthy();
|
||||
});
|
||||
@@ -209,7 +219,7 @@ describe("AttachmentPreviewModal — error states", () => {
|
||||
it("shows the generic failed fallback on a transport error", async () => {
|
||||
getAttachmentTextContentMock.mockRejectedValueOnce(new Error("network down"));
|
||||
const att = makeAttachment({ filename: "x.md", content_type: "text/markdown" });
|
||||
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
|
||||
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Couldn't load preview")).toBeTruthy();
|
||||
});
|
||||
@@ -220,7 +230,7 @@ describe("AttachmentPreviewModal — controls", () => {
|
||||
it("ESC closes the modal", () => {
|
||||
const onClose = vi.fn();
|
||||
const att = makeAttachment({ filename: "manual.pdf", content_type: "application/pdf" });
|
||||
render(<AttachmentPreviewModal attachment={att} open onClose={onClose} />);
|
||||
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={onClose} />);
|
||||
act(() => {
|
||||
document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape" }));
|
||||
});
|
||||
@@ -229,7 +239,7 @@ describe("AttachmentPreviewModal — controls", () => {
|
||||
|
||||
it("Download button invokes useDownloadAttachment with the attachment id", () => {
|
||||
const att = makeAttachment({ filename: "manual.pdf", content_type: "application/pdf" });
|
||||
render(<AttachmentPreviewModal attachment={att} open onClose={() => {}} />);
|
||||
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={() => {}} />);
|
||||
// Two Download CTAs may exist (header + unsupported fallback). The header
|
||||
// button is always present, look it up by aria-label/title.
|
||||
const buttons = screen.getAllByTitle("Download");
|
||||
@@ -241,9 +251,117 @@ describe("AttachmentPreviewModal — controls", () => {
|
||||
it("clicking the backdrop closes the modal", () => {
|
||||
const onClose = vi.fn();
|
||||
const att = makeAttachment({ filename: "manual.pdf", content_type: "application/pdf" });
|
||||
render(<AttachmentPreviewModal attachment={att} open onClose={onClose} />);
|
||||
render(<AttachmentPreviewModal source={{ kind: "full", attachment: att }} open onClose={onClose} />);
|
||||
const dialog = screen.getByRole("dialog");
|
||||
fireEvent.click(dialog);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("AttachmentPreviewModal — URL-only source", () => {
|
||||
it("renders a PDF iframe from the URL when no attachment record is available", () => {
|
||||
const url = "https://cdn.example.test/orphan.pdf?Signature=s";
|
||||
render(
|
||||
<AttachmentPreviewModal
|
||||
source={{ kind: "url", url, filename: "orphan.pdf" }}
|
||||
open
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
const iframe = document.querySelector("iframe");
|
||||
expect(iframe).toBeTruthy();
|
||||
expect(iframe?.getAttribute("src")).toBe(url);
|
||||
});
|
||||
|
||||
it("renders <video> from the URL when no attachment record is available", () => {
|
||||
const url = "https://cdn.example.test/clip.mp4?Signature=s";
|
||||
render(
|
||||
<AttachmentPreviewModal
|
||||
source={{ kind: "url", url, filename: "clip.mp4" }}
|
||||
open
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
const video = document.querySelector("video");
|
||||
expect(video?.getAttribute("src")).toBe(url);
|
||||
});
|
||||
|
||||
it("falls back to unsupported when a text kind is forced through a URL source", () => {
|
||||
// The tryOpen gate normally prevents this; direct mount tests the
|
||||
// defensive branch inside PreviewContent.
|
||||
render(
|
||||
<AttachmentPreviewModal
|
||||
source={{ kind: "url", url: "https://x/y.md", filename: "y.md" }}
|
||||
open
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByText("This file type can't be previewed.")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("Download button opens the raw URL externally when no attachment id is available", () => {
|
||||
const url = "https://cdn.example.test/orphan.pdf?Signature=s";
|
||||
render(
|
||||
<AttachmentPreviewModal
|
||||
source={{ kind: "url", url, filename: "orphan.pdf" }}
|
||||
open
|
||||
onClose={() => {}}
|
||||
/>,
|
||||
);
|
||||
const button = screen.getAllByTitle("Download")[0]!;
|
||||
fireEvent.click(button);
|
||||
expect(openExternalMock).toHaveBeenCalledWith(url);
|
||||
expect(downloadMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAttachmentPreview — tryOpen gate", () => {
|
||||
it("accepts a full attachment for a media kind", () => {
|
||||
const { result } = renderHook(() => useAttachmentPreview());
|
||||
const att = makeAttachment({ filename: "x.pdf", content_type: "application/pdf" });
|
||||
let opened = false;
|
||||
hookAct(() => {
|
||||
opened = result.current.tryOpen({ kind: "full", attachment: att });
|
||||
});
|
||||
expect(opened).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts a URL source for a media kind", () => {
|
||||
const { result } = renderHook(() => useAttachmentPreview());
|
||||
let opened = false;
|
||||
hookAct(() => {
|
||||
opened = result.current.tryOpen({
|
||||
kind: "url",
|
||||
url: "https://x/y.pdf",
|
||||
filename: "y.pdf",
|
||||
});
|
||||
});
|
||||
expect(opened).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects a URL source for a text kind — /content proxy needs an id", () => {
|
||||
const { result } = renderHook(() => useAttachmentPreview());
|
||||
let opened = true;
|
||||
hookAct(() => {
|
||||
opened = result.current.tryOpen({
|
||||
kind: "url",
|
||||
url: "https://x/y.md",
|
||||
filename: "y.md",
|
||||
});
|
||||
});
|
||||
expect(opened).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects a source whose filename isn't a previewable type", () => {
|
||||
const { result } = renderHook(() => useAttachmentPreview());
|
||||
let opened = true;
|
||||
hookAct(() => {
|
||||
opened = result.current.tryOpen({
|
||||
kind: "url",
|
||||
url: "https://x/y.zip",
|
||||
filename: "y.zip",
|
||||
});
|
||||
});
|
||||
expect(opened).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
} from "@multica/core/api";
|
||||
import type { Attachment } from "@multica/core/types";
|
||||
import { useT } from "../i18n";
|
||||
import { openExternal } from "../platform";
|
||||
import { ReadonlyContent } from "./readonly-content";
|
||||
import {
|
||||
extensionToLanguage,
|
||||
@@ -56,12 +57,61 @@ import {
|
||||
} from "./utils/preview";
|
||||
import { useDownloadAttachment } from "./use-download-attachment";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Preview source — full attachment, or URL-only (media types only)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// `full` carries the resolved Attachment record and supports every PreviewKind
|
||||
// (text types require the attachment id to call /api/attachments/{id}/content).
|
||||
//
|
||||
// `url` carries just the signed URL + filename. It is what NodeViews fall back
|
||||
// to when `resolveAttachment(href)` returns undefined — typical when the URL
|
||||
// was copy-pasted across comments so the attachment record isn't reachable
|
||||
// from the current entity's `attachments` prop. Only media kinds (pdf / video
|
||||
// / audio) can be opened from a `url` source because those render directly
|
||||
// from the URL without hitting the text-content proxy.
|
||||
|
||||
export type PreviewSource =
|
||||
| { kind: "full"; attachment: Attachment }
|
||||
| { kind: "url"; url: string; filename: string };
|
||||
|
||||
// PreviewKinds that can render from a URL-only source. Text-based kinds
|
||||
// (markdown / html / text) need the /content proxy which is ID-keyed.
|
||||
const URL_ONLY_KINDS = new Set<PreviewKind>(["pdf", "video", "audio"]);
|
||||
|
||||
// Normalized view used everywhere downstream of `useAttachmentPreview`.
|
||||
// `attachmentId === null` signals URL-only mode (download falls back to
|
||||
// `openExternal`, text rendering branches are unreachable by the gate).
|
||||
interface PreviewState {
|
||||
filename: string;
|
||||
contentType: string;
|
||||
mediaUrl: string;
|
||||
attachmentId: string | null;
|
||||
}
|
||||
|
||||
function normalize(source: PreviewSource): PreviewState {
|
||||
if (source.kind === "full") {
|
||||
return {
|
||||
filename: source.attachment.filename,
|
||||
contentType: source.attachment.content_type,
|
||||
mediaUrl: source.attachment.download_url,
|
||||
attachmentId: source.attachment.id,
|
||||
};
|
||||
}
|
||||
return {
|
||||
filename: source.filename,
|
||||
contentType: "",
|
||||
mediaUrl: source.url,
|
||||
attachmentId: null,
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public props
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface AttachmentPreviewModalProps {
|
||||
attachment: Attachment;
|
||||
source: PreviewSource;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
@@ -79,12 +129,14 @@ interface AttachmentPreviewModalProps {
|
||||
// only one preview is open per user click.
|
||||
|
||||
export interface AttachmentPreviewHandle {
|
||||
/** Try to open a preview for the attachment. Returns false when the file
|
||||
* type isn't previewable so the caller can fall back to a download flow. */
|
||||
tryOpen: (attachment: Attachment) => boolean;
|
||||
/** Force-open a preview, skipping the isPreviewable() guard. Use for cases
|
||||
/** Try to open a preview for the source. Returns false when the file type
|
||||
* isn't previewable, OR when the source is URL-only but the kind requires
|
||||
* a full attachment (text/markdown/html). Callers can fall back to a
|
||||
* download flow. */
|
||||
tryOpen: (source: PreviewSource) => boolean;
|
||||
/** Force-open a preview, skipping the previewable() guard. Use for cases
|
||||
* where the caller has already filtered. */
|
||||
open: (attachment: Attachment) => void;
|
||||
open: (source: PreviewSource) => void;
|
||||
/** Modal node to render somewhere in the caller's tree. Resolves to `null`
|
||||
* when no preview is active. Safe to render inside any container — the
|
||||
* modal portals to document.body. */
|
||||
@@ -92,18 +144,22 @@ export interface AttachmentPreviewHandle {
|
||||
}
|
||||
|
||||
export function useAttachmentPreview(): AttachmentPreviewHandle {
|
||||
const [current, setCurrent] = useState<Attachment | null>(null);
|
||||
const [current, setCurrent] = useState<PreviewSource | null>(null);
|
||||
|
||||
const open = useCallback((att: Attachment) => setCurrent(att), []);
|
||||
const tryOpen = useCallback((att: Attachment) => {
|
||||
if (!getPreviewKind(att.content_type, att.filename)) return false;
|
||||
setCurrent(att);
|
||||
const open = useCallback((source: PreviewSource) => setCurrent(source), []);
|
||||
const tryOpen = useCallback((source: PreviewSource) => {
|
||||
const state = normalize(source);
|
||||
const kind = getPreviewKind(state.contentType, state.filename);
|
||||
if (!kind) return false;
|
||||
// URL-only sources cannot drive text kinds — the /content proxy is ID-keyed.
|
||||
if (source.kind === "url" && !URL_ONLY_KINDS.has(kind)) return false;
|
||||
setCurrent(source);
|
||||
return true;
|
||||
}, []);
|
||||
|
||||
const modal = current ? (
|
||||
<AttachmentPreviewModal
|
||||
attachment={current}
|
||||
source={current}
|
||||
open
|
||||
onClose={() => setCurrent(null)}
|
||||
/>
|
||||
@@ -117,12 +173,13 @@ export function useAttachmentPreview(): AttachmentPreviewHandle {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function AttachmentPreviewModal({
|
||||
attachment,
|
||||
source,
|
||||
open,
|
||||
onClose,
|
||||
}: AttachmentPreviewModalProps) {
|
||||
const { t } = useT("editor");
|
||||
const download = useDownloadAttachment();
|
||||
const state = normalize(source);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -133,7 +190,18 @@ export function AttachmentPreviewModal({
|
||||
return () => document.removeEventListener("keydown", handler);
|
||||
}, [open, onClose]);
|
||||
|
||||
const kind = getPreviewKind(attachment.content_type, attachment.filename);
|
||||
const kind = getPreviewKind(state.contentType, state.filename);
|
||||
|
||||
// Download dispatcher: re-sign through `getAttachment` when an id is
|
||||
// available; otherwise fall back to opening the (possibly stale) URL
|
||||
// externally — same tradeoff as the file-card NodeView's download path.
|
||||
const handleDownload = () => {
|
||||
if (state.attachmentId) {
|
||||
download(state.attachmentId);
|
||||
} else {
|
||||
openExternal(state.mediaUrl);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open || typeof document === "undefined") return null;
|
||||
|
||||
@@ -143,7 +211,7 @@ export function AttachmentPreviewModal({
|
||||
onClick={onClose}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={attachment.filename}
|
||||
aria-label={state.filename}
|
||||
>
|
||||
{/* Larger than the create-issue dialog (max-w-4xl, manualDialogContentClass)
|
||||
because PDF / video previews want more room. Capped to viewport
|
||||
@@ -155,9 +223,9 @@ export function AttachmentPreviewModal({
|
||||
>
|
||||
<div className="flex items-center gap-2 border-b border-border bg-muted/30 px-4 py-2">
|
||||
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
||||
<p className="truncate text-sm font-medium">{attachment.filename}</p>
|
||||
<p className="truncate text-sm font-medium">{state.filename}</p>
|
||||
<span className="ml-1 shrink-0 text-xs text-muted-foreground">
|
||||
{attachment.content_type || "—"}
|
||||
{state.contentType || "—"}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-1">
|
||||
<button
|
||||
@@ -165,7 +233,7 @@ export function AttachmentPreviewModal({
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title={t(($) => $.image.download)}
|
||||
aria-label={t(($) => $.image.download)}
|
||||
onClick={() => download(attachment.id)}
|
||||
onClick={handleDownload}
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</button>
|
||||
@@ -183,8 +251,9 @@ export function AttachmentPreviewModal({
|
||||
<div className="min-h-0 flex-1 overflow-auto bg-background">
|
||||
<PreviewContent
|
||||
kind={kind}
|
||||
attachment={attachment}
|
||||
onDownload={() => download(attachment.id)}
|
||||
source={source}
|
||||
state={state}
|
||||
onDownload={handleDownload}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,11 +271,13 @@ export function AttachmentPreviewModal({
|
||||
// own the content area.
|
||||
function PreviewContent({
|
||||
kind,
|
||||
attachment,
|
||||
source,
|
||||
state,
|
||||
onDownload,
|
||||
}: {
|
||||
kind: PreviewKind | null;
|
||||
attachment: Attachment;
|
||||
source: PreviewSource;
|
||||
state: PreviewState;
|
||||
onDownload: () => void;
|
||||
}) {
|
||||
const { t } = useT("editor");
|
||||
@@ -220,20 +291,37 @@ function PreviewContent({
|
||||
);
|
||||
}
|
||||
|
||||
// Text kinds need the attachment id for the /content proxy. The tryOpen
|
||||
// gate prevents URL-only sources from reaching here for text kinds, but
|
||||
// be defensive — a direct mount of <AttachmentPreviewModal> with a URL
|
||||
// source whose filename later resolves to a text kind would otherwise
|
||||
// crash on a null id.
|
||||
if (
|
||||
(kind === "markdown" || kind === "html" || kind === "text") &&
|
||||
!state.attachmentId
|
||||
) {
|
||||
return (
|
||||
<UnsupportedFallback
|
||||
message={t(($) => $.attachment.preview_unsupported)}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
switch (kind) {
|
||||
case "pdf":
|
||||
return (
|
||||
<iframe
|
||||
src={attachment.download_url}
|
||||
src={state.mediaUrl}
|
||||
className="h-full w-full bg-background"
|
||||
title={attachment.filename}
|
||||
title={state.filename}
|
||||
/>
|
||||
);
|
||||
case "video":
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center bg-black">
|
||||
<video
|
||||
src={attachment.download_url}
|
||||
src={state.mediaUrl}
|
||||
controls
|
||||
className="max-h-full max-w-full"
|
||||
/>
|
||||
@@ -242,19 +330,19 @@ function PreviewContent({
|
||||
case "audio":
|
||||
return (
|
||||
<div className="flex h-full w-full items-center justify-center p-8">
|
||||
<audio src={attachment.download_url} controls className="w-full max-w-xl" />
|
||||
<audio src={state.mediaUrl} controls className="w-full max-w-xl" />
|
||||
</div>
|
||||
);
|
||||
case "markdown":
|
||||
return (
|
||||
<TextBackedPreview
|
||||
attachmentId={attachment.id}
|
||||
attachmentId={state.attachmentId!}
|
||||
onDownload={onDownload}
|
||||
render={(text) => (
|
||||
<ReadonlyContent
|
||||
content={text}
|
||||
className="px-6 py-4"
|
||||
attachments={[attachment]}
|
||||
attachments={source.kind === "full" ? [source.attachment] : []}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -262,14 +350,14 @@ function PreviewContent({
|
||||
case "html":
|
||||
return (
|
||||
<TextBackedPreview
|
||||
attachmentId={attachment.id}
|
||||
attachmentId={state.attachmentId!}
|
||||
onDownload={onDownload}
|
||||
render={(text) => (
|
||||
<iframe
|
||||
srcDoc={text}
|
||||
sandbox=""
|
||||
className="h-full w-full bg-background"
|
||||
title={attachment.filename}
|
||||
title={state.filename}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
@@ -277,10 +365,10 @@ function PreviewContent({
|
||||
case "text":
|
||||
return (
|
||||
<TextBackedPreview
|
||||
attachmentId={attachment.id}
|
||||
attachmentId={state.attachmentId!}
|
||||
onDownload={onDownload}
|
||||
render={(text) => (
|
||||
<CodeBlock language={extensionToLanguage(attachment.filename)} body={text} />
|
||||
<CodeBlock language={extensionToLanguage(state.filename)} body={text} />
|
||||
)}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@ import { Eye, FileText, Loader2, Download } from "lucide-react";
|
||||
import { useT } from "../../i18n";
|
||||
import { useAttachmentDownloadResolver } from "../attachment-download-context";
|
||||
import { useAttachmentPreview } from "../attachment-preview-modal";
|
||||
import { isPreviewable } from "../utils/preview";
|
||||
import { getPreviewKind } from "../utils/preview";
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -44,15 +44,27 @@ function FileCardView({ node }: NodeViewProps) {
|
||||
openByUrl(href);
|
||||
};
|
||||
|
||||
// The NodeView only holds href + filename. The full Attachment (with
|
||||
// content_type / download_url) lives in the surrounding
|
||||
// AttachmentDownloadProvider — resolve it lazily at click time so the
|
||||
// eye button is only offered when we both know the record and the
|
||||
// dispatcher recognizes the type.
|
||||
// Preview gate mirrors the Download gate (href is enough). We attempt
|
||||
// to resolve the full Attachment from the surrounding provider, but its
|
||||
// absence is no longer fatal — media kinds (pdf/video/audio) only need
|
||||
// the URL, so they remain previewable even when `resolveAttachment`
|
||||
// misses (e.g. the URL was copy-pasted across comments and isn't in the
|
||||
// current entity's attachments). Text kinds still require the id because
|
||||
// the preview proxy is ID-keyed.
|
||||
const attachment = href ? resolveAttachment(href) : undefined;
|
||||
const previewable = attachment
|
||||
? isPreviewable(attachment.content_type, attachment.filename)
|
||||
: false;
|
||||
const kind = filename
|
||||
? getPreviewKind(attachment?.content_type ?? "", filename)
|
||||
: null;
|
||||
const isMediaKind = kind === "pdf" || kind === "video" || kind === "audio";
|
||||
const canPreview = !!href && kind !== null && (!!attachment || isMediaKind);
|
||||
|
||||
const openPreview = () => {
|
||||
if (attachment) {
|
||||
preview.tryOpen({ kind: "full", attachment });
|
||||
} else if (href) {
|
||||
preview.tryOpen({ kind: "url", url: href, filename });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<NodeViewWrapper as="div" className="file-card-node" data-type="fileCard">
|
||||
@@ -69,7 +81,7 @@ function FileCardView({ node }: NodeViewProps) {
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm">{uploading ? t(($) => $.file_card.uploading, { filename }) : filename}</p>
|
||||
</div>
|
||||
{!uploading && href && previewable && attachment && (
|
||||
{!uploading && canPreview && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
@@ -78,7 +90,7 @@ function FileCardView({ node }: NodeViewProps) {
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
preview.tryOpen(attachment);
|
||||
openPreview();
|
||||
}}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
|
||||
@@ -45,8 +45,8 @@ import { openLink, isMentionHref } from "./utils/link-handler";
|
||||
import { preprocessMarkdown } from "./utils/preprocess";
|
||||
import { MermaidDiagram } from "./mermaid-diagram";
|
||||
import { useDownloadAttachment } from "./use-download-attachment";
|
||||
import { useAttachmentPreview } from "./attachment-preview-modal";
|
||||
import { isPreviewable } from "./utils/preview";
|
||||
import { useAttachmentPreview, type PreviewSource } from "./attachment-preview-modal";
|
||||
import { getPreviewKind } from "./utils/preview";
|
||||
import "katex/dist/katex.min.css";
|
||||
import "./content-editor.css";
|
||||
|
||||
@@ -249,13 +249,18 @@ function ReadonlyFileCard({
|
||||
filename: string;
|
||||
resolveAttachment: (url: string) => Attachment | undefined;
|
||||
onDownload: (attachmentId: string) => void;
|
||||
onPreview: (att: Attachment) => boolean;
|
||||
onPreview: (source: PreviewSource) => boolean;
|
||||
}) {
|
||||
const { t } = useT("editor");
|
||||
const attachment = href ? resolveAttachment(href) : undefined;
|
||||
const previewable = attachment
|
||||
? isPreviewable(attachment.content_type, attachment.filename)
|
||||
: false;
|
||||
// Mirror file-card.tsx (NodeView) — preview gate widens to "anything that
|
||||
// can be downloaded AND whose filename is a previewable type". Media kinds
|
||||
// fall through to URL-only when the attachment record isn't reachable.
|
||||
const kind = filename
|
||||
? getPreviewKind(attachment?.content_type ?? "", filename)
|
||||
: null;
|
||||
const isMediaKind = kind === "pdf" || kind === "video" || kind === "audio";
|
||||
const canPreview = !!href && kind !== null && (!!attachment || isMediaKind);
|
||||
const handleDownloadClick = () => {
|
||||
if (attachment) {
|
||||
onDownload(attachment.id);
|
||||
@@ -263,19 +268,26 @@ function ReadonlyFileCard({
|
||||
}
|
||||
openExternal(href);
|
||||
};
|
||||
const handlePreviewClick = () => {
|
||||
if (attachment) {
|
||||
onPreview({ kind: "full", attachment });
|
||||
} else if (href) {
|
||||
onPreview({ kind: "url", url: href, filename });
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="my-1 flex items-center gap-2 rounded-md border border-border bg-muted/50 px-2.5 py-1 transition-colors hover:bg-muted">
|
||||
<FileText className="size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm">{filename}</p>
|
||||
</div>
|
||||
{href && previewable && attachment && (
|
||||
{canPreview && (
|
||||
<button
|
||||
type="button"
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title={t(($) => $.attachment.preview)}
|
||||
aria-label={t(($) => $.attachment.preview)}
|
||||
onClick={() => onPreview(attachment)}
|
||||
onClick={handlePreviewClick}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
</button>
|
||||
@@ -299,7 +311,7 @@ function buildComponents(
|
||||
resolveAttachmentId: (url: string) => string | undefined,
|
||||
resolveAttachment: (url: string) => Attachment | undefined,
|
||||
onDownload: (attachmentId: string) => void,
|
||||
onPreview: (att: Attachment) => boolean,
|
||||
onPreview: (source: PreviewSource) => boolean,
|
||||
): Partial<Components> {
|
||||
return {
|
||||
// Links — route mention:// to mention components, others show preview card
|
||||
|
||||
@@ -114,6 +114,15 @@ const TEXT_CONTENT_TYPES = new Set<string>([
|
||||
|
||||
const TEXT_BASENAMES = new Set<string>(["dockerfile", "makefile"]);
|
||||
|
||||
// Extension fallbacks for media kinds — used when contentType is empty
|
||||
// (URL-only preview source, no server-side metadata available).
|
||||
const VIDEO_EXTS = new Set<string>([
|
||||
"mp4", "m4v", "mov", "webm", "mkv", "avi", "ogv",
|
||||
]);
|
||||
const AUDIO_EXTS = new Set<string>([
|
||||
"mp3", "wav", "m4a", "ogg", "oga", "flac", "aac", "opus",
|
||||
]);
|
||||
|
||||
function extOf(filename: string): string {
|
||||
const base = filename.toLowerCase().split(/[\\/]/).pop() ?? "";
|
||||
const dot = base.lastIndexOf(".");
|
||||
@@ -149,13 +158,14 @@ export function getPreviewKind(
|
||||
): PreviewKind | null {
|
||||
const ct = normalizeContentType(contentType);
|
||||
|
||||
if (ct === "application/pdf" || extOf(filename) === "pdf") return "pdf";
|
||||
if (ct.startsWith("video/")) return "video";
|
||||
if (ct.startsWith("audio/")) return "audio";
|
||||
const ext = extOf(filename);
|
||||
|
||||
if (ct === "application/pdf" || ext === "pdf") return "pdf";
|
||||
if (ct.startsWith("video/") || (ext && VIDEO_EXTS.has(ext))) return "video";
|
||||
if (ct.startsWith("audio/") || (ext && AUDIO_EXTS.has(ext))) return "audio";
|
||||
|
||||
// Markdown — covers both the well-typed case and the common
|
||||
// server-side sniffer fallback (text/plain for .md).
|
||||
const ext = extOf(filename);
|
||||
if (ct === "text/markdown" || ext === "md" || ext === "markdown") {
|
||||
return "markdown";
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ function AttachmentList({ attachments, content, className }: { attachments?: Att
|
||||
className="shrink-0 rounded-md p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
|
||||
title={t(($) => $.attachment.preview)}
|
||||
aria-label={t(($) => $.attachment.preview)}
|
||||
onClick={() => preview.tryOpen(a)}
|
||||
onClick={() => preview.tryOpen({ kind: "full", attachment: a })}
|
||||
>
|
||||
<Eye className="size-3.5" />
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user