Files
multica/packages/views/common/markdown.tsx
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

103 lines
2.8 KiB
TypeScript

"use client";
import * as React from "react";
import {
Markdown as MarkdownBase,
type MarkdownProps as MarkdownBaseProps,
type RenderMode,
} from "@multica/ui/markdown";
import { useConfigStore } from "@multica/core/config";
import type { Attachment as AttachmentRecord } from "@multica/core/types";
import { IssueMentionCard } from "../issues/components/issue-mention-card";
import {
Attachment as AttachmentRenderer,
AttachmentDownloadProvider,
} from "../editor";
export type { RenderMode };
export interface MarkdownProps extends MarkdownBaseProps {
/**
* Attachments associated with the surrounding entity (chat message, skill
* file). When passed, the renderer resolves inline image / file-card URLs
* to full attachment records via AttachmentDownloadProvider, unlocking the
* unified hover toolbar / lightbox / preview-modal behavior used in
* editor surfaces.
*/
attachments?: AttachmentRecord[];
}
/**
* Default renderMention that delegates to IssueMentionCard for issue mentions
* and renders a styled span for other mention types.
*/
function defaultRenderMention({
type,
id,
}: {
type: string;
id: string;
}): React.ReactNode {
if (type === "issue") {
return <IssueMentionCard issueId={id} />;
}
return null;
}
function renderImage({ src, alt }: { src: string; alt: string }): React.ReactNode {
return (
<AttachmentRenderer
attachment={{
kind: "url",
url: src,
filename: alt,
// chat / skill markdown `![]()` is structurally an image. Without
// forceKind, empty/descriptive alt strings would route to the
// file-card chrome via getPreviewKind autodetect.
forceKind: "image",
}}
/>
);
}
function renderFileCard({
href,
filename,
}: {
href: string;
filename: string;
}): React.ReactNode {
return (
<AttachmentRenderer
attachment={{ kind: "url", url: href, filename }}
/>
);
}
/**
* App-level Markdown wrapper. Injects:
* - IssueMentionCard for issue mentions
* - cdnDomain from the config store (drives fileCard preprocessing)
* - unified <Attachment> as the image / file-card renderer
* - AttachmentDownloadProvider so url → record resolution works inside
* the injected <Attachment> components
*/
export function Markdown(props: MarkdownProps): React.JSX.Element {
const cdnDomain = useConfigStore((s) => s.cdnDomain);
const { attachments, ...rest } = props;
return (
<AttachmentDownloadProvider attachments={attachments}>
<MarkdownBase
renderMention={defaultRenderMention}
renderImage={renderImage}
renderFileCard={renderFileCard}
cdnDomain={cdnDomain}
{...rest}
/>
</AttachmentDownloadProvider>
);
}
export const MemoizedMarkdown = React.memo(Markdown);
MemoizedMarkdown.displayName = "MemoizedMarkdown";