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:
parent
eb826455b6
commit
daa71d0456
13 changed files with 272 additions and 126 deletions
|
|
@ -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}
|
||||
|
|
|
|||
124
web/src/components/MemoEditor/Editor/Mention.ts
Normal file
124
web/src/components/MemoEditor/Editor/Mention.ts
Normal 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) : ""),
|
||||
});
|
||||
|
|
@ -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,
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
30
web/src/utils/mention-grammar.ts
Normal file
30
web/src/utils/mention-grammar.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
96
web/tests/editor-mention.test.ts
Normal file
96
web/tests/editor-mention.test.ts
Normal 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.");
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue