Files
multica/packages/views/editor/utils/preview.ts
Naiyuan Qing 39f43a9a98 refactor(editor): unify attachment rendering into a single <Attachment> component (#2850)
Collapse the five separate attachment render paths (file-card NodeView,
image NodeView, readonly markdown img/fileCard renderers, AttachmentList
standalone fallback, and the parallel packages/ui/markdown renderer) into
one <Attachment attachment={a} /> dispatcher.

Fixes a P0 visual regression: a PNG attached to a message but not inlined
in the markdown body used to render as a gray "file card" because
getPreviewKind() lacked an "image" branch and image rendering bypassed
the dispatcher entirely. Now every surface routes through <Attachment>,
so the same PNG renders as a real <img> with hover toolbar and
preview-modal everywhere.

Key changes:
- PreviewKind gains "image"; getPreviewKind() detects image/* + common
  extensions before the html/text branches (so svg stays image, not text).
- AttachmentPreviewModal gains case "image" (replaces the standalone
  ImageLightbox, which is deleted).
- New packages/views/editor/attachment.tsx owns all kind-aware routing
  (image | html | file) and dispatches preview modal + download via the
  existing useAttachmentPreview / useDownloadAttachment hooks. Subsumes
  the deleted AttachmentBlock.
- AttachmentInput.url accepts a forceKind hint so callers that *know*
  the structural kind (markdown ![](url), Tiptap image node) skip the
  filename-based autodetect — fixes a regression where empty or
  descriptive alt text would route an image to the file-card chrome.
- Tiptap NodeViews (file-card.tsx, image-view.tsx) shrink to thin
  wrappers that forward editor hints (selected, deleteNode, uploading)
  to <Attachment>.
- ReadonlyContent and AttachmentList each mount their own
  AttachmentDownloadProvider so url → record resolution works outside
  ContentEditor's provider.
- packages/ui/markdown gains optional renderImage / renderFileCard slot
  props; packages/views/common/markdown.tsx injects <Attachment> into
  those slots and threads message attachments through to chat /
  skill-file viewers.
- chat-message-list passes message.attachments to every <Markdown> call
  site and renders a standalone AttachmentList under each bubble for
  attachments not referenced in the body.

Tests: attachment.test.tsx covers 9 scenarios (record image / pdf / html;
url-only image with resolver hit and miss; uploading state; editable
delete; forceKind regression). attachment-preview-modal.test.tsx gains
image-dispatch cases. 652/652 unit tests pass.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-19 16:23:19 +08:00

205 lines
6.2 KiB
TypeScript

/**
* Preview dispatch table for the AttachmentPreviewModal.
*
* Add new previewable kinds here. To add a type:
* 1. Add a new branch returning a new PreviewKind literal.
* 2. Add the corresponding renderer in attachment-preview-modal.tsx's dispatch.
* 3. If the renderer needs the file body as text, also extend isTextPreviewable
* in server/internal/handler/file.go so the proxy endpoint accepts it.
* 4. If the renderer fetches a binary, decide whether to use download_url
* (CloudFront, no auth on the client side) or a new authenticated proxy.
*/
export type PreviewKind =
| "image"
| "pdf"
| "video"
| "audio"
| "markdown"
| "html"
| "text";
const EXT_LANGUAGE_MAP: Record<string, string> = {
// Markdown
md: "markdown",
markdown: "markdown",
// Plain text — left undefined intentionally; lowlight renders the body
// unhighlighted when no language is supplied.
txt: "plaintext",
log: "plaintext",
// Web
html: "xml",
htm: "xml",
xml: "xml",
svg: "xml",
css: "css",
scss: "scss",
sass: "scss",
less: "less",
// Config / data
json: "json",
yml: "yaml",
yaml: "yaml",
toml: "ini",
ini: "ini",
conf: "ini",
// Shell
sh: "bash",
bash: "bash",
zsh: "bash",
// Languages
py: "python",
rb: "ruby",
go: "go",
rs: "rust",
ts: "typescript",
tsx: "typescript",
js: "javascript",
jsx: "javascript",
mjs: "javascript",
cjs: "javascript",
java: "java",
kt: "kotlin",
swift: "swift",
c: "c",
cc: "cpp",
cpp: "cpp",
h: "c",
hpp: "cpp",
cs: "csharp",
php: "php",
lua: "lua",
vim: "vim",
sql: "sql",
csv: "plaintext",
tsv: "plaintext",
};
// Build files that are commonly extension-less.
const BASENAME_LANGUAGE_MAP: Record<string, string> = {
dockerfile: "dockerfile",
makefile: "makefile",
};
// IMPORTANT — KEEP IN SYNC with isTextPreviewable() in
// server/internal/handler/file.go. If an extension lands here but the proxy
// rejects it, the user sees a 415 fallback in the modal. If the proxy accepts
// but this set doesn't, the Eye button doesn't appear at all.
//
// TODO(follow-up): extract to a JSON single-source-of-truth + generator
// (mirror reserved-slugs pattern in server/internal/handler/reserved_slugs.json).
const TEXT_EXTENSIONS = new Set<string>([
"md", "markdown", "txt", "log", "csv", "tsv",
"html", "htm", "json", "xml",
"yml", "yaml", "toml", "ini", "conf",
"sh", "bash", "zsh",
"py", "rb", "go", "rs",
"ts", "tsx", "js", "jsx", "mjs", "cjs",
"css", "scss", "sass", "less",
"sql",
"java", "kt", "swift",
"c", "cc", "cpp", "h", "hpp",
"cs", "php", "lua", "vim",
]);
const TEXT_CONTENT_TYPES = new Set<string>([
"application/json",
"application/javascript",
"application/xml",
"application/x-yaml",
"application/yaml",
"application/toml",
"application/x-sh",
"application/x-httpd-php",
]);
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",
]);
const IMAGE_EXTS = new Set<string>([
"png", "jpg", "jpeg", "gif", "webp", "avif", "bmp", "ico", "svg",
]);
function extOf(filename: string): string {
const base = filename.toLowerCase().split(/[\\/]/).pop() ?? "";
const dot = base.lastIndexOf(".");
if (dot <= 0) return "";
return base.slice(dot + 1);
}
function baseOf(filename: string): string {
return (filename.toLowerCase().split(/[\\/]/).pop() ?? "").trim();
}
function normalizeContentType(contentType: string): string {
const ct = (contentType ?? "").toLowerCase().trim();
const semi = ct.indexOf(";");
return (semi >= 0 ? ct.slice(0, semi) : ct).trim();
}
function isTextLike(contentType: string, filename: string): boolean {
const ct = normalizeContentType(contentType);
if (ct.startsWith("text/")) return true;
if (TEXT_CONTENT_TYPES.has(ct)) return true;
const ext = extOf(filename);
if (ext && TEXT_EXTENSIONS.has(ext)) return true;
return TEXT_BASENAMES.has(baseOf(filename));
}
// Dispatch on PreviewKind. New cases go in attachment-preview-modal.tsx;
// remember that the modal frame (header, close, Download CTA, ESC handling)
// is shared — sub-renderers only own the content area.
export function getPreviewKind(
contentType: string,
filename: string,
): PreviewKind | null {
const ct = normalizeContentType(contentType);
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";
// Image — must come BEFORE the html/text branches because svg is
// text-like (XML), and image/* content-types include text/svg variants
// that isTextLike would otherwise catch.
if (ct.startsWith("image/") || (ext && IMAGE_EXTS.has(ext))) return "image";
// Markdown — covers both the well-typed case and the common
// server-side sniffer fallback (text/plain for .md).
if (ct === "text/markdown" || ext === "md" || ext === "markdown") {
return "markdown";
}
if (ct === "text/html" || ext === "html" || ext === "htm") {
return "html";
}
if (isTextLike(contentType, filename)) return "text";
return null;
}
export function isPreviewable(contentType: string, filename: string): boolean {
return getPreviewKind(contentType, filename) !== null;
}
// Pick the hljs language token for a file. Returns undefined when the file
// doesn't have a recognizable extension — callers can fall back to a plain
// `<pre>` render. Kept tiny and ext-driven on purpose: lowlight's `common`
// pack covers the ~50 languages people upload in practice, anything else
// rendered as plain text is preferable to importing the full pack.
export function extensionToLanguage(filename: string): string | undefined {
const ext = extOf(filename);
if (ext && EXT_LANGUAGE_MAP[ext]) return EXT_LANGUAGE_MAP[ext];
const base = baseOf(filename);
if (BASENAME_LANGUAGE_MAP[base]) return BASENAME_LANGUAGE_MAP[base];
return undefined;
}