v3: src/components sorted into feature folders, the four largest files split, and the component tests mirror the new layout — 48 components sat in one flat folder, ChatModal ran to 1301 lines, DumpCreateModal to 983, and the tests were grouped by theme, so there was no telling which component had none. Components now live in category/, chat/, comment/, dump/ (with dump/create/ for the new-dump wizard), editor/, form/, layout/ (header, page shell, floating buttons), player/, playlist/, profile/, social/ (vote, like, follow, the who-voted popover) and ui/; FeedTabBar and PresenceRow, used by the index page alone, move to pages/index/. ChatModal gives up ChatMessageItem, PendingItem, ChatEditBox and ReplyRef, the message limits and text helpers (chat/message.ts), the day and clock labels (chat/time.ts) and the draft storage (utils/chatDraft.ts, next to dumpDraft.ts). DumpCreateModal gives up StepIndicator, LocalFilePreview, TitleField and DuplicateNotice; CommentThread gives up CommentForm and CommentNode; TextEditor gives up the emoji picker popup and its skin-tone storage; MediaPlayer gives up its icons (which FilePreview imported from it), Waveform, SeekBar and the remembered volume. NewPlaylistForm, a button that opens a modal holding the form, is NewPlaylistButton now, and Thumbnail, ThumbnailPlaceholder, FilePreview and RichContentCard use named exports like everything else. The tests are split one file per component under src/test/components/<folder>/ (and src/test/pages/index/), the same 625 of them. No behaviour changes; the catalogues only move their source references.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
158
src/components/editor/Markdown.tsx
Normal file
158
src/components/editor/Markdown.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { memo } from "react";
|
||||
import { Link } from "react-router";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
interface MarkdownProps {
|
||||
children: string;
|
||||
className?: string;
|
||||
inline?: boolean;
|
||||
}
|
||||
|
||||
// The mention rule the server notifies by (see utils/chatText.ts): an `@` not
|
||||
// preceded by a word character, so an email address isn't a mention.
|
||||
const MENTION_RE = /(?<!\w)@(\w+)/g;
|
||||
|
||||
interface MdNode {
|
||||
type: string;
|
||||
value?: string;
|
||||
url?: string;
|
||||
children?: MdNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns bare @username into a link to that user. Works on the parsed tree
|
||||
* rather than the source text, so code keeps its @s verbatim and a URL that
|
||||
* contains one (`https://mastodon.social/@bob`) isn't split in two.
|
||||
*/
|
||||
function remarkMentions() {
|
||||
const walk = (node: MdNode) => {
|
||||
if (!node.children) return;
|
||||
// Link text is already a link, whatever it says.
|
||||
if (node.type === "link" || node.type === "linkReference") return;
|
||||
node.children = node.children.flatMap((child) => {
|
||||
if (child.type !== "text" || !child.value?.includes("@")) {
|
||||
walk(child);
|
||||
return [child];
|
||||
}
|
||||
const out: MdNode[] = [];
|
||||
let last = 0;
|
||||
for (const m of child.value.matchAll(MENTION_RE)) {
|
||||
if (m.index > last) {
|
||||
out.push({ type: "text", value: child.value.slice(last, m.index) });
|
||||
}
|
||||
out.push({
|
||||
type: "link",
|
||||
url: `/users/${m[1]}`,
|
||||
children: [{ type: "text", value: m[0] }],
|
||||
});
|
||||
last = m.index + m[0].length;
|
||||
}
|
||||
if (last === 0) return [child];
|
||||
if (last < child.value.length) {
|
||||
out.push({ type: "text", value: child.value.slice(last) });
|
||||
}
|
||||
return out;
|
||||
});
|
||||
};
|
||||
return (tree: MdNode) => walk(tree);
|
||||
}
|
||||
|
||||
/**
|
||||
* A single newline is a line break, as it is in chat everywhere: markdown's
|
||||
* own rule folds it into a space, so a message typed on three lines with
|
||||
* Shift+Enter came out as one. (Code keeps its newlines; it has no text
|
||||
* nodes.)
|
||||
*/
|
||||
function remarkBreaks() {
|
||||
const walk = (node: MdNode) => {
|
||||
if (!node.children) return;
|
||||
node.children = node.children.flatMap((child) => {
|
||||
if (child.type !== "text" || !child.value?.includes("\n")) {
|
||||
walk(child);
|
||||
return [child];
|
||||
}
|
||||
return child.value.split("\n").flatMap((part, i) =>
|
||||
i === 0
|
||||
? [{ type: "text", value: part }]
|
||||
: [{ type: "break" }, { type: "text", value: part }]
|
||||
);
|
||||
});
|
||||
};
|
||||
return (tree: MdNode) => walk(tree);
|
||||
}
|
||||
|
||||
const REMARK_PLUGINS = [remarkGfm, remarkMentions, remarkBreaks];
|
||||
|
||||
/** The path of a link into this app — relative, or absolute to this origin —
|
||||
* or null for anywhere else. */
|
||||
function internalPath(href: string | undefined): string | null {
|
||||
if (!href) return null;
|
||||
if (href.startsWith("/") && !href.startsWith("//")) {
|
||||
// Files and attachments are the API's, not routes: a new tab, as before.
|
||||
return href.startsWith("/api/") ? null : href;
|
||||
}
|
||||
try {
|
||||
const url = new URL(href);
|
||||
if (url.origin !== globalThis.location.origin) return null;
|
||||
if (url.pathname.startsWith("/api/")) return null;
|
||||
return url.pathname + url.search + url.hash;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
type Components = React.ComponentProps<typeof ReactMarkdown>["components"];
|
||||
|
||||
// Static components objects — defined once at module scope to avoid recreation on every render
|
||||
const MARKDOWN_COMPONENTS: Components = {
|
||||
// Mentions and links to other pages here stay in the app; the rest open
|
||||
// in a new tab.
|
||||
a: ({ href, children: linkChildren }) => {
|
||||
const path = internalPath(href);
|
||||
if (path) return <Link to={path}>{linkChildren}</Link>;
|
||||
return (
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">
|
||||
{linkChildren}
|
||||
</a>
|
||||
);
|
||||
},
|
||||
img: ({ src, alt }) => (
|
||||
<img src={src} alt={alt ?? ""} loading="lazy" decoding="async" />
|
||||
),
|
||||
// A wide GFM table scrolls inside its own box instead of the page.
|
||||
table: ({ children: tableChildren }) => (
|
||||
<div className="md-table-wrap">
|
||||
<table>{tableChildren}</table>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
// Inline renderings are line-clamped teasers inside cards, where an embedded
|
||||
// image blows the layout apart. Drop images there — the full description is
|
||||
// one click away on the detail page.
|
||||
const INLINE_MARKDOWN_COMPONENTS: Components = {
|
||||
...MARKDOWN_COMPONENTS,
|
||||
img: () => null,
|
||||
};
|
||||
|
||||
// Memoized: parsing runs the whole remark pipeline, and the text rarely
|
||||
// changes when the component around it re-renders.
|
||||
export const Markdown = memo(function Markdown(
|
||||
{ children, className, inline = false }: MarkdownProps,
|
||||
) {
|
||||
return (
|
||||
<div
|
||||
className={`md${className ? ` ${className}` : ""}${
|
||||
inline ? " md--inline" : ""
|
||||
}`}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={REMARK_PLUGINS}
|
||||
components={inline ? INLINE_MARKDOWN_COMPONENTS : MARKDOWN_COMPONENTS}
|
||||
>
|
||||
{children}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user