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:
Naiyuan Qing
2026-05-14 11:33:48 +08:00
committed by GitHub
parent 52d032335a
commit c49c778613
6 changed files with 311 additions and 71 deletions

View File

@@ -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);
});
});

View File

@@ -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} />
)}
/>
);

View File

@@ -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" />

View File

@@ -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

View File

@@ -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";
}

View File

@@ -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>