feat(editor): recognize and style @mentions in the rich editor

Mirror the #tag mark so the TipTap editor styles @username while typing
and on load, matching the read-only view. A shared mention grammar
(utils/mention-grammar.ts) feeds both the editor tokenizer and the
read-only remark renderer so they can't drift.

Styling only -- no autocomplete dropdown (ListUsers is admin-only and
there is no user-search RPC). Bare emails still autolink to mailto: and
are never treated as mentions: the tokenizer's start() skips email-glued
@s so marked's GFM autolinker keeps seeing the whole address.
This commit is contained in:
boojack 2026-06-26 09:28:41 +08:00
parent eb826455b6
commit daa71d0456
13 changed files with 272 additions and 126 deletions

View file

@ -1,4 +1,5 @@
import type { Element } from "hast";
import { mentionStyles } from "@/lib/markdownStyles";
import { cn } from "@/lib/utils";
interface MentionProps extends React.HTMLAttributes<HTMLSpanElement> {
@ -29,7 +30,7 @@ export const Mention: React.FC<MentionProps> = ({
return (
<a
href={`/u/${username}`}
className={cn("text-primary underline-offset-2 hover:underline", className)}
className={cn(mentionStyles.base, "hover:underline", className)}
data-mention={username}
title={`@${username}`}
{...props}

View file

@ -0,0 +1,124 @@
import type { MarkdownToken } from "@tiptap/core";
import { InputRule, Mark, mergeAttributes } from "@tiptap/core";
import { mentionStyles } from "@/lib/markdownStyles";
import { isMentionChar, MENTION_RUN } from "@/utils/mention-grammar";
// Default mention accent, shared with the read-only view (MemoContent/Mention.tsx).
// Computed once — renderHTML runs on every view update.
const MENTION_CLASS = mentionStyles.base;
// Built from the shared MENTION_RUN (@/utils/mention-grammar) so the editor's
// input rule and tokenizer can't drift from the read-only renderer's lexer
// (web/src/utils/remark-plugins/remark-mention.ts). The `(?<![A-Za-z0-9-])`
// lookbehind in the input rule keeps `support@example` (and any `@` glued to a
// preceding word/username char) from forming a mention while typing.
const MENTION_INPUT_RULE = new RegExp(`(?<![A-Za-z0-9-])@(${MENTION_RUN})\\s$`);
const MENTION_TOKEN_RULE = new RegExp(`^@(${MENTION_RUN})`);
/**
* Mark for memos `@mentions`: styled in the editor, serialized back to
* `@username` verbatim. Modeled exactly like the Tag mark — a `code: true` text
* mark (the PreservedInline pattern) so a mention inside `**bold**` or a link
* label round-trips byte-identically and the literal `@a-b` text is emitted
* unescaped. See Tag.ts for the full rationale on why this is a mark, not an
* inline atom, and on going through the @tiptap/markdown manager.
*
* Unlike tags, mentions need no serialize-time escaping: an unintended `@word`
* is byte-stable as plain text either way, and the read-only view already
* decides styling by whether the username resolves to a real user, not by an
* escape. Mentions inside link labels are likewise kept (the read-only renderer
* keeps them too), so there is no strip-in-links pass.
*/
export const Mention = Mark.create({
name: "mention",
// Typing immediately after a mention must not extend it.
inclusive: false,
// Serializer emits the inner text verbatim, without escaping.
code: true,
addAttributes() {
return {
// The username without `@`. Stored as `username` but rendered/parsed as
// `data-mention`, matching the read-only renderer's attribute.
username: {
default: "",
parseHTML: (element) => element.getAttribute("data-mention") ?? "",
renderHTML: (attributes) => ({ "data-mention": attributes.username }),
},
};
},
parseHTML() {
return [{ tag: "span[data-mention]" }];
},
renderHTML({ HTMLAttributes }) {
return [
"span",
mergeAttributes(HTMLAttributes, {
class: MENTION_CLASS,
}),
0,
];
},
addInputRules() {
return [
new InputRule({
find: MENTION_INPUT_RULE,
handler: ({ state, range, match }) => {
// Mark only from the `@`; the trailing space stays outside the mark
// and is re-inserted explicitly (the handler's steps suppress the
// typed space), mirroring the Tag input rule.
const start = range.from + match[0].indexOf("@");
state.tr
.addMark(start, range.to, this.type.create({ username: match[1] }))
.insertText(" ", range.to)
.removeStoredMark(this.type);
},
}),
];
},
markdownTokenizer: {
name: "memoMention",
level: "inline",
// Point marked only at an `@` that could *begin* a mention — one at a
// boundary (string start, or a non-mention char before it). An `@` glued to
// a preceding word/username char belongs to an email (`support@example.com`)
// or the tail of `@a@b`; surfacing its index here would make marked split
// the text at the `@` and rob its GFM autolinker of the contiguous email,
// suppressing the mailto link. Skipping such `@`s leaves emails to autolink.
start: (src: string) => {
for (let i = src.indexOf("@"); i !== -1; i = src.indexOf("@", i + 1)) {
if (i === 0 || !isMentionChar(src[i - 1])) {
return i;
}
}
return -1;
},
tokenize: (src: string, tokens: MarkdownToken[]) => {
const match = MENTION_TOKEN_RULE.exec(src);
if (!match) {
return undefined;
}
// A mention only starts at a boundary. The previous token's last source
// character is exactly the character before this `@`; if it is a mention
// character the `@` is glued to a word or username (`support@example`,
// the second `@` in `@a@b`) and must stay literal — mirroring the
// read-only renderer's isMentionBoundary check (remark-mention.ts).
const prevChar = (tokens[tokens.length - 1]?.raw ?? "").slice(-1);
if (prevChar && isMentionChar(prevChar)) {
return undefined;
}
return { type: "memoMention", raw: match[0], username: match[1] };
},
},
markdownTokenName: "memoMention",
parseMarkdown: (token, helpers) => {
const t = token as MarkdownToken & { username?: string };
return helpers.createTextNode(t.raw ?? "", [{ type: "mention", attrs: { username: t.username ?? "" } }]);
},
// No delimiters: the literal `@username` text carries the syntax.
renderMarkdown: (node, helpers) => (node.content ? helpers.renderChildren(node.content) : ""),
});

View file

@ -3,6 +3,7 @@ import { Heading } from "@tiptap/extension-heading";
import { TaskItem, TaskList } from "@tiptap/extension-list";
import StarterKit from "@tiptap/starter-kit";
import { type HeadingLevel, headingClass, markdownStyles } from "@/lib/markdownStyles";
import { Mention } from "./Mention";
import { preservedExtensions } from "./PreservedBlock";
import { Tag } from "./Tag";
import { TagAwareMarkdown } from "./tagMarkdown";
@ -49,5 +50,6 @@ export function buildExtensions(): AnyExtension[] {
TagAwareMarkdown,
...preservedExtensions,
Tag,
Mention,
];
}

View file

@ -194,7 +194,7 @@ const InsertMenu = (props: InsertMenuProps) => {
<>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="shadow-none" disabled={isUploading}>
<Button variant="secondary" size="icon" disabled={isUploading}>
{isUploading ? <LoaderIcon className="size-4 animate-spin" /> : <PlusIcon className="size-4" />}
</Button>
</DropdownMenuTrigger>

View file

@ -20,13 +20,13 @@ const VisibilitySelector = (props: VisibilitySelectorProps) => {
return (
<DropdownMenu onOpenChange={props.onOpenChange}>
<DropdownMenuTrigger asChild>
<button className="inline-flex items-center px-2 text-sm text-muted-foreground opacity-80 hover:opacity-100 transition-colors">
<button className="inline-flex items-center h-8 px-2 rounded-md text-sm text-muted-foreground hover:bg-accent transition-colors">
<VisibilityIcon visibility={value} className="opacity-60 mr-1.5" />
<span>{currentLabel}</span>
<ChevronDownIcon className="ml-0.5 w-4 h-4 opacity-60" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuContent align="start">
{visibilityOptions.map((option) => (
<DropdownMenuItem key={option.value} className="cursor-pointer gap-2" onClick={() => onChange(option.value)}>
<VisibilityIcon visibility={option.value} />

View file

@ -34,7 +34,7 @@ export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoNa
return (
<div className="w-full flex flex-row justify-between items-center mb-2">
<div className="flex flex-row justify-start items-center">
<div className="flex flex-row justify-start items-center gap-1">
<InsertMenu
isUploading={isUploading}
location={location}
@ -43,11 +43,10 @@ export const EditorToolbar: FC<EditorToolbarProps> = ({ onSave, onCancel, memoNa
memoName={memoName}
onAudioRecorderClick={onAudioRecorderClick}
/>
<VisibilitySelector value={visibility} onChange={handleVisibilityChange} />
</div>
<div className="flex flex-row justify-end items-center gap-2">
<VisibilitySelector value={visibility} onChange={handleVisibilityChange} />
{onCancel && (
<Button variant="ghost" onClick={onCancel} disabled={isSaving}>
{t("common.cancel")}

View file

@ -11,7 +11,7 @@ const buttonVariants = cva(
default: "bg-primary text-primary-foreground shadow-xs hover:bg-primary/90",
destructive: "bg-destructive text-destructive-foreground shadow-xs hover:bg-destructive/90",
outline: "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary: "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
secondary: "bg-secondary text-secondary-foreground hover:shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},

View file

@ -67,3 +67,14 @@ export const tagStyles = {
/** Default theme color, used when no custom tag color is set. */
defaultColor: "border-primary text-primary bg-primary/15",
} as const;
/**
* `@mention` styling, shared by the read-only memo view (MemoContent/Mention.tsx)
* and the editor's mention mark (MemoEditor/Editor/Mention.ts) so a mention looks
* identical while typing and after saving. Unlike a tag this is not a pill — it
* is the same primary-colored accent the resolved read-only mention uses in its
* resting state (the read-only view adds `hover:underline` for its link).
*/
export const mentionStyles = {
base: "text-primary underline-offset-2",
} as const;

View file

@ -1,5 +1,4 @@
import { ExternalLinkIcon } from "lucide-react";
import { useEffect, useState } from "react";
import TileSpriteStrip from "@/components/Placeholder/TileSpriteStrip";
import { TILE_SPRITES, type TileSprite } from "@/components/Placeholder/tileSprites";
import SettingGroup from "@/components/Settings/SettingGroup";
@ -16,36 +15,6 @@ const PRODUCT_LINKS = [
const PRODUCT_POINTS = ["Open. Write. Done.", "Markdown-native.", "Fully yours."];
const SPONSORS = [
{
label: "CodeRabbit",
href: "https://coderabbit.link/usememos",
description: "Cut code review time & bugs in half, instantly.",
lightLogo: "https://victorious-bubble-f69a016683.media.strapiapp.com/Orange_Typemark_43bf516c9d.svg",
darkLogo: "https://victorious-bubble-f69a016683.media.strapiapp.com/White_Typemark_79b9189d19.svg",
},
{
label: "Warp",
href: "https://go.warp.dev/memos",
description: "The agentic development environment.",
lightLogo: "https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Logos/Warp-Wordmark-Black.png",
darkLogo: "https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Logos/Warp-Wordmark-White.png",
},
];
type Sponsor = (typeof SPONSORS)[number];
const isDarkThemeName = (theme: string | null): boolean => {
return theme?.endsWith("-dark") || theme?.endsWith(".dark") || false;
};
const getCurrentThemeUsesDarkLogo = (): boolean => {
if (typeof document === "undefined") {
return false;
}
return isDarkThemeName(document.documentElement.getAttribute("data-theme"));
};
const BirdSprite = ({ sprite }: { sprite: TileSprite }) => {
return (
<figure className="flex w-auto min-w-28 flex-none flex-col items-center gap-3 rounded-xl border border-border bg-muted/20 px-4 py-4 text-center">
@ -57,29 +26,6 @@ const BirdSprite = ({ sprite }: { sprite: TileSprite }) => {
);
};
const SponsorLogo = ({ sponsor }: { sponsor: Sponsor }) => {
const [usesDarkLogo, setUsesDarkLogo] = useState(getCurrentThemeUsesDarkLogo);
useEffect(() => {
const updateLogoTheme = () => setUsesDarkLogo(getCurrentThemeUsesDarkLogo());
updateLogoTheme();
const observer = new MutationObserver(updateLogoTheme);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
return () => observer.disconnect();
}, []);
return (
<img
className="h-9 max-w-44 object-contain object-left"
src={usesDarkLogo ? sponsor.darkLogo : sponsor.lightLogo}
alt={sponsor.label}
/>
);
};
const About = () => {
return (
<section className="mx-auto w-full max-w-5xl min-h-full flex flex-col justify-start items-start sm:pt-3 md:pt-6 pb-8">
@ -125,31 +71,6 @@ const About = () => {
</div>
</SettingGroup>
<SettingGroup showSeparator title="Sponsors" description="Featured sponsors helping keep Memos open-source and independent.">
<section aria-label="Sponsors" className="grid gap-3 sm:grid-cols-2">
{SPONSORS.map((sponsor) => (
<a
key={sponsor.href}
href={sponsor.href}
target="_blank"
rel="noreferrer"
className="group flex min-h-32 min-w-0 flex-col justify-between gap-5 rounded-lg border border-border bg-muted/20 px-4 py-4 text-muted-foreground transition-colors hover:border-primary/40 hover:bg-muted/40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
>
<div className="flex min-w-0 items-start justify-between gap-4">
<SponsorLogo sponsor={sponsor} />
<ExternalLinkIcon className="mt-0.5 size-4 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground" />
</div>
<div className="flex min-w-0 flex-col gap-3">
<p className="text-sm leading-6 text-muted-foreground">{sponsor.description}</p>
<span className="text-xs font-medium text-foreground transition-colors group-hover:text-primary">
Visit {sponsor.label}
</span>
</div>
</a>
))}
</section>
</SettingGroup>
<SettingGroup showSeparator title="Birds" description="Pixel tile strips used by empty states.">
<section aria-label="Birds" className="flex flex-row flex-wrap gap-3">
{TILE_SPRITES.map((sprite) => (

View file

@ -0,0 +1,30 @@
/**
* The single source of truth for memos' `@mention` lexing grammar, shared by the
* editor tokenizer (components/MemoEditor/Editor/Mention.ts) and the read-only
* renderer (utils/remark-plugins/remark-mention.ts) so they can't drift.
*
* A mention character is an ASCII letter, digit, or hyphen. A username is capped
* at MAX_MENTION_LENGTH characters and must contain at least one letter or digit
* (a run of only hyphens is not a mention). This matches the Go backend parser
* (internal/markdown/parser/mention.go).
*/
export const MENTION_CHAR_CLASS = "[A-Za-z0-9-]";
export const MAX_MENTION_LENGTH = 32;
/**
* Regex source for a username run: 1..MAX_MENTION_LENGTH mention characters that
* include at least one letter or digit. Unlike TAG_RUN this does not refuse an
* over-long run — a 33rd mention character simply falls outside the capture and
* becomes ordinary text, mirroring the read-only parser's truncation. The
* leading lookahead enforces the "must contain an alphanumeric" rule so `@---`
* is left as plain text. Embed after an `@`, e.g. `^@(${MENTION_RUN})`.
*/
export const MENTION_RUN = `(?=${MENTION_CHAR_CLASS}{0,${MAX_MENTION_LENGTH - 1}}[A-Za-z0-9])${MENTION_CHAR_CLASS}{1,${MAX_MENTION_LENGTH}}`;
const SINGLE_MENTION_CHAR = /^[A-Za-z0-9-]$/;
/** Whether a single character is allowed inside a username. */
export function isMentionChar(char: string): boolean {
return SINGLE_MENTION_CHAR.test(char);
}

View file

@ -2,12 +2,7 @@ import type { Root, Text } from "mdast";
import type { Node as UnistNode } from "unist";
import { visit } from "unist-util-visit";
import type { MentionNode, MentionNodeData } from "@/types/markdown";
const MAX_MENTION_LENGTH = 32;
function isMentionChar(char: string): boolean {
return /[A-Za-z0-9-]/.test(char);
}
import { isMentionChar, MAX_MENTION_LENGTH } from "@/utils/mention-grammar";
function isMentionBoundary(char: string): boolean {
if (!char) return true;

View file

@ -15,24 +15,6 @@ describe("<About>", () => {
expect(screen.getByText(/Capture first/i)).toBeInTheDocument();
expect(screen.getByText(/quick capture/i)).toBeInTheDocument();
const sponsors = screen.getByRole("region", { name: "Sponsors" });
expect(within(sponsors).getByRole("link", { name: /CodeRabbit/i })).toHaveAttribute("href", "https://coderabbit.link/usememos");
expect(within(sponsors).getByRole("link", { name: /Warp/i })).toHaveAttribute("href", "https://go.warp.dev/memos");
expect(within(sponsors).getByText(/Cut code review time/i)).toBeInTheDocument();
expect(within(sponsors).getByText(/agentic development environment/i)).toBeInTheDocument();
expect(within(sponsors).getByAltText("CodeRabbit").closest("a")).toHaveAttribute("href", "https://coderabbit.link/usememos");
expect(
within(sponsors)
.getByText(/Cut code review time/i)
.closest("a"),
).toHaveAttribute("href", "https://coderabbit.link/usememos");
expect(within(sponsors).getByAltText("Warp").closest("a")).toHaveAttribute("href", "https://go.warp.dev/memos");
expect(
within(sponsors)
.getByText(/agentic development environment/i)
.closest("a"),
).toHaveAttribute("href", "https://go.warp.dev/memos");
const birds = screen.getByRole("region", { name: "Birds" });
expect(within(birds).getAllByTestId("about-bird-sprite")).toHaveLength(TILE_SPRITES.length);
@ -49,19 +31,4 @@ describe("<About>", () => {
expect(contentWrapper).toHaveClass("w-full");
expect(contentWrapper).not.toHaveClass("px-4");
});
it("uses dark sponsor logos when the app theme is dark", () => {
document.documentElement.setAttribute("data-theme", "default-dark");
render(<About />);
expect(screen.getByAltText("CodeRabbit")).toHaveAttribute(
"src",
"https://victorious-bubble-f69a016683.media.strapiapp.com/White_Typemark_79b9189d19.svg",
);
expect(screen.getByAltText("Warp")).toHaveAttribute(
"src",
"https://raw.githubusercontent.com/warpdotdev/brand-assets/refs/heads/main/Logos/Warp-Wordmark-White.png",
);
});
});

View file

@ -0,0 +1,96 @@
import type { JSONContent } from "@tiptap/core";
import { describe, expect, it } from "vitest";
import { parseMarkdown, roundTripMarkdown } from "@/components/MemoEditor/Editor/markdownCodec";
function firstParagraphChildren(markdown: string) {
return parseMarkdown(markdown).content?.[0]?.content ?? [];
}
function hasMentionMark(node: JSONContent) {
return (node.marks ?? []).some((m) => m.type === "mention");
}
describe("Mention mark", () => {
it("parses @username into mention-marked text", () => {
const mentioned = firstParagraphChildren("hey @alice there").find(hasMentionMark);
expect(mentioned).toMatchObject({ type: "text", text: "@alice", marks: [{ type: "mention", attrs: { username: "alice" } }] });
});
it("serializes mention-marked text back to @username verbatim", () => {
expect(roundTripMarkdown("ping @bob now").trim()).toBe("ping @bob now");
});
it("preserves username case in the editor model (no lowercasing)", () => {
const mentioned = firstParagraphChildren("@Alice").find(hasMentionMark);
expect(mentioned).toMatchObject({ text: "@Alice", marks: [{ type: "mention", attrs: { username: "Alice" } }] });
expect(roundTripMarkdown("@Alice").trim()).toBe("@Alice");
});
it("supports hyphenated usernames", () => {
const username = firstParagraphChildren("@jane-doe ok")
.find(hasMentionMark)
?.marks?.find((m) => m.type === "mention")?.attrs?.username;
expect(username).toBe("jane-doe");
expect(roundTripMarkdown("@jane-doe ok").trim()).toBe("@jane-doe ok");
});
it("autolinks a bare email to mailto and never treats it as a mention", () => {
// The `@` in an email must not become a mention, and the email itself must
// still autolink to a mailto: link (GFM, same as the read-only view) — for
// a standalone email and one mid-sentence alike. The mention tokenizer must
// stay out of the email's way rather than splitting the text at its `@`.
for (const input of ["support@example.com", "mail support@example.com please"]) {
expect(firstParagraphChildren(input).some(hasMentionMark)).toBe(false);
expect(roundTripMarkdown(input)).toContain("[support@example.com](mailto:support@example.com)");
}
});
it("stops at a mention-char boundary: @a@b is mention `a` then literal `@b`", () => {
const mentioned = firstParagraphChildren("@a@b").filter(hasMentionMark);
expect(mentioned).toHaveLength(1);
expect(mentioned[0]).toMatchObject({ text: "@a", marks: [{ type: "mention", attrs: { username: "a" } }] });
expect(roundTripMarkdown("@a@b").trim()).toBe("@a@b");
});
it("carries both bold and mention marks on **@bob** and round-trips", () => {
const input = "**hi @bob**";
expect(roundTripMarkdown(input).trim()).toBe(input);
const markTypes = (firstParagraphChildren(input).find((n) => n.text === "@bob")?.marks ?? []).map((m) => m.type);
expect(markTypes).toContain("bold");
expect(markTypes).toContain("mention");
});
it("keeps a mention inside a link label and round-trips byte-identically", () => {
const input = "[ping @bob](https://example.com)";
expect(roundTripMarkdown(input).trim()).toBe(input);
});
it("does not create a mention from an all-hyphen run", () => {
const children = firstParagraphChildren("@--- nope");
expect(children.some(hasMentionMark)).toBe(false);
expect(roundTripMarkdown("@--- nope").trim()).toBe("@--- nope");
});
it("caps the username at 32 chars, leaving the overflow as plain text", () => {
const input = `@${"a".repeat(40)}`;
const username = firstParagraphChildren(input)
.find(hasMentionMark)
?.marks?.find((m) => m.type === "mention")?.attrs?.username;
expect(username).toBe("a".repeat(32));
expect(roundTripMarkdown(input).trim()).toBe(input);
});
it("marks the mentions, autolinks the email, in a mixed line", () => {
// Mentions stay mentions (editor preserves case, unlike backend extraction
// which lowercases), the email autolinks to mailto, and the two never
// collide. Based on the backend's canonical case (markdown_test.go).
const input = "Hi @Alice and @bob. Email support@example.com today.";
const usernames = firstParagraphChildren(input)
.filter(hasMentionMark)
.map((n) => n.marks?.find((m) => m.type === "mention")?.attrs?.username);
expect(usernames).toEqual(["Alice", "bob"]);
const out = roundTripMarkdown(input).trim();
expect(out).toBe("Hi @Alice and @bob. Email [support@example.com](mailto:support@example.com) today.");
});
});