Skip to content

Commit 7ab9ea2

Browse files
committed
feat(editor): add headless mention plugin
Add a configurable mention node with command/query support and keyboard interaction hooks. Wire the sandbox-owned suggestions overlay with lazy/eager sources, keyboard navigation, adaptive placement, and mention behavior coverage.
1 parent 71a0c51 commit 7ab9ea2

8 files changed

Lines changed: 1088 additions & 4 deletions

File tree

apps/sandbox-e2e/src/example.spec.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,35 @@ test('cleans pasted HTML links', async ({ page }) => {
6262
);
6363
});
6464

65+
test('inserts mentions from the consumer-owned overlay', async ({ page }) => {
66+
await page.goto('/');
67+
68+
const editor = page.locator('.ProseMirror');
69+
70+
await editor.click();
71+
await page.keyboard.press(
72+
process.platform === 'darwin' ? 'Meta+A' : 'Control+A',
73+
);
74+
await page.keyboard.press('Backspace');
75+
await editor.pressSequentially('@');
76+
77+
const suggestions = page.getByRole('listbox', {
78+
name: 'Mention suggestions',
79+
});
80+
81+
await expect(suggestions).toBeVisible();
82+
await expect(suggestions.getByRole('option')).toContainText([
83+
'Ada Lovelace',
84+
'Grace Hopper',
85+
]);
86+
87+
await page.keyboard.press('ArrowDown');
88+
await page.keyboard.press('Space');
89+
await expect(page.locator('pre')).toContainText(
90+
'<p><span data-rte-mention="" data-mention-id="grace-hopper" data-mention-label="Grace Hopper" data-mention-trigger="@" contenteditable="false">@Grace Hopper</span> </p>',
91+
);
92+
});
93+
6594
test('renders the configured plugin toolbar', async ({ page }) => {
6695
await page.goto('/');
6796

apps/sandbox/src/app/app.spec.ts

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,9 @@ import {
1515
LINK_PLUGIN_DEFAULT_OPTIONS,
1616
LinkPlugin,
1717
ListsPlugin,
18+
MENTION_PLUGIN_DEFAULT_OPTIONS,
19+
MentionPlugin,
20+
MentionState,
1821
PASTE_RULES_PLUGIN_DEFAULT_OPTIONS,
1922
PasteRulesPlugin,
2023
PLACEHOLDER_PLUGIN_DEFAULT_OPTIONS,
@@ -106,6 +109,9 @@ describe('App', () => {
106109
'.ProseMirror span[style*="color: rgb(14, 116, 144)"]',
107110
)?.textContent,
108111
).toContain('color');
112+
expect(
113+
compiled.querySelector('.ProseMirror [data-rte-mention]')?.textContent,
114+
).toBe('@Ada Lovelace');
109115
expect(compiled.querySelector('.ProseMirror mark')?.textContent).toContain(
110116
'highlight',
111117
);
@@ -972,6 +978,113 @@ describe('App', () => {
972978
);
973979
});
974980

981+
it('should expose mention queries and insertion through the public plugin', () => {
982+
const editor = createRteEditor({
983+
content: '<p>Hello @gr</p>',
984+
plugins: [MentionPlugin],
985+
});
986+
const host = document.createElement('div');
987+
988+
editor.mount(host);
989+
selectEditorRange(editor, 10, 10);
990+
991+
expect(MentionPlugin.key).toBe('mention');
992+
expect(editor.query<MentionState>('mention')).toEqual({
993+
from: 7,
994+
to: 10,
995+
query: 'gr',
996+
trigger: '@',
997+
});
998+
expect(
999+
editor.execute('insertMention', {
1000+
id: 'grace-hopper',
1001+
label: 'Grace Hopper',
1002+
}),
1003+
).toBeTrue();
1004+
expect(editor.html()).toBe(
1005+
'<p>Hello <span data-rte-mention="" data-mention-id="grace-hopper" data-mention-label="Grace Hopper" data-mention-trigger="@" contenteditable="false">@Grace Hopper</span> </p>',
1006+
);
1007+
expect(getEditorSelectionFrom(editor)).toBe(8);
1008+
1009+
editor.unmount(host);
1010+
});
1011+
1012+
it('should keep mentions disabled inside code blocks', () => {
1013+
const editor = createRteEditor({
1014+
content: '<pre><code>@gr</code></pre>',
1015+
plugins: [CodeBlockPlugin, MentionPlugin],
1016+
});
1017+
const host = document.createElement('div');
1018+
1019+
editor.mount(host);
1020+
selectEditorRange(editor, 4, 4);
1021+
1022+
expect(editor.query<MentionState>('mention')).toBeNull();
1023+
expect(
1024+
editor.canExecute('insertMention', {
1025+
id: 'grace-hopper',
1026+
label: 'Grace Hopper',
1027+
}),
1028+
).toBeFalse();
1029+
expect(
1030+
editor.execute('insertMention', {
1031+
id: 'grace-hopper',
1032+
label: 'Grace Hopper',
1033+
}),
1034+
).toBeFalse();
1035+
expect(editor.html()).toBe(
1036+
'<pre><code class="language-plaintext">@gr</code></pre>',
1037+
);
1038+
1039+
editor.unmount(host);
1040+
});
1041+
1042+
it('should parse serialized mentions and validate configurable defaults', () => {
1043+
const configured = MentionPlugin.configure({
1044+
trigger: '#',
1045+
minQueryLength: 1,
1046+
maxQueryLength: 24,
1047+
appendSpaceOnInsert: false,
1048+
});
1049+
const editor = createRteEditor({
1050+
content:
1051+
'<p>Ask <span data-rte-mention data-mention-id="ada-lovelace" data-mention-label="Ada Lovelace" data-mention-trigger="@">@Ada Lovelace</span>.</p>',
1052+
plugins: [MentionPlugin],
1053+
});
1054+
1055+
expect(MENTION_PLUGIN_DEFAULT_OPTIONS).toEqual({
1056+
trigger: '@',
1057+
minQueryLength: 0,
1058+
maxQueryLength: 64,
1059+
appendSpaceOnInsert: true,
1060+
});
1061+
expect(MentionPlugin.options).toEqual(MENTION_PLUGIN_DEFAULT_OPTIONS);
1062+
expect(configured.options).toEqual({
1063+
trigger: '#',
1064+
minQueryLength: 1,
1065+
maxQueryLength: 24,
1066+
appendSpaceOnInsert: false,
1067+
});
1068+
expect(editor.html()).toBe(
1069+
'<p>Ask <span data-rte-mention="" data-mention-id="ada-lovelace" data-mention-label="Ada Lovelace" data-mention-trigger="@" contenteditable="false">@Ada Lovelace</span>.</p>',
1070+
);
1071+
expect(() =>
1072+
MentionPlugin.configure({
1073+
trigger: '::',
1074+
}),
1075+
).toThrowError(
1076+
'MentionPlugin trigger must be a single non-whitespace character.',
1077+
);
1078+
expect(() =>
1079+
MentionPlugin.configure({
1080+
minQueryLength: 3,
1081+
maxQueryLength: 2,
1082+
}),
1083+
).toThrowError(
1084+
'MentionPlugin maxQueryLength must be greater than or equal to minQueryLength.',
1085+
);
1086+
});
1087+
9751088
it('should autolink plain text URLs on paste through the public plugin', () => {
9761089
const editor = createRteEditor({
9771090
content: '<p></p>',
@@ -1091,6 +1204,17 @@ function selectEditorRange(
10911204
);
10921205
}
10931206

1207+
function getEditorSelectionFrom(editor: RteEditorController): number {
1208+
const view = (editor as unknown as { editorView: EditorView | undefined })
1209+
.editorView;
1210+
1211+
if (!view) {
1212+
throw new Error('Editor view is not mounted.');
1213+
}
1214+
1215+
return view.state.selection.from;
1216+
}
1217+
10941218
function pastePlainText(editor: RteEditorController, text: string): void {
10951219
pasteClipboard(editor, { text });
10961220
}

apps/sandbox/src/app/sandbox-editor.ts

Lines changed: 68 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { Component } from '@angular/core';
1+
import {
2+
Component,
3+
DestroyRef,
4+
ElementRef,
5+
afterNextRender,
6+
inject,
7+
viewChild,
8+
} from '@angular/core';
29
import {
310
BlockquotePlugin,
411
ClearFormattingPlugin,
@@ -10,6 +17,7 @@ import {
1017
HistoryPlugin,
1118
LinkPlugin,
1219
ListsPlugin,
20+
MentionPlugin,
1321
PasteRulesPlugin,
1422
PlaceholderPlugin,
1523
RteContent,
@@ -28,10 +36,21 @@ import {
2836
} from './sandbox-code-block';
2937
import { SandboxCodeHighlightPlugin } from './sandbox-code-highlight-plugin';
3038
import { SandboxLinkPopover } from './sandbox-link-popover';
39+
import {
40+
createSandboxMentionSource,
41+
SandboxMentionController,
42+
} from './sandbox-mention';
43+
import { SandboxMentionMenu } from './sandbox-mention-menu';
3144
import { SandboxToolbar } from './sandbox-toolbar';
3245

3346
@Component({
34-
imports: [RteContent, RteEditor, SandboxLinkPopover, SandboxToolbar],
47+
imports: [
48+
RteContent,
49+
RteEditor,
50+
SandboxLinkPopover,
51+
SandboxMentionMenu,
52+
SandboxToolbar,
53+
],
3554
selector: 'app-sandbox-editor',
3655
template: `
3756
<rte-editor
@@ -44,16 +63,30 @@ import { SandboxToolbar } from './sandbox-toolbar';
4463
/>
4564
4665
<rte-content
66+
#mentionSurface
4767
class="block min-h-64 p-4 [&_.ProseMirror]:min-h-56 [&_.ProseMirror]:break-words [&_.ProseMirror]:whitespace-pre-wrap [&_.ProseMirror]:outline-none [&_.ProseMirror_.hljs-attr]:text-sky-300 [&_.ProseMirror_.hljs-built_in]:text-cyan-300 [&_.ProseMirror_.hljs-comment]:text-slate-500 [&_.ProseMirror_.hljs-keyword]:text-violet-300 [&_.ProseMirror_.hljs-literal]:text-orange-300 [&_.ProseMirror_.hljs-meta]:text-slate-400 [&_.ProseMirror_.hljs-number]:text-orange-300 [&_.ProseMirror_.hljs-params]:text-slate-200 [&_.ProseMirror_.hljs-string]:text-emerald-300 [&_.ProseMirror_.hljs-title]:text-amber-200 [&_.ProseMirror_.hljs-type]:text-cyan-200 [&_.ProseMirror_.hljs-variable]:text-sky-200 [&_.ProseMirror_blockquote]:mb-3 [&_.ProseMirror_blockquote]:border-l-4 [&_.ProseMirror_blockquote]:border-slate-300 [&_.ProseMirror_blockquote]:pl-4 [&_.ProseMirror_blockquote]:text-slate-700 [&_.ProseMirror_h1]:mb-3 [&_.ProseMirror_h1]:text-3xl [&_.ProseMirror_h1]:font-extrabold [&_.ProseMirror_h2]:mb-3 [&_.ProseMirror_h2]:text-2xl [&_.ProseMirror_h2]:font-bold [&_.ProseMirror_h3]:mb-2.5 [&_.ProseMirror_h3]:text-xl [&_.ProseMirror_h3]:font-bold [&_.ProseMirror_li>p]:mb-1 [&_.ProseMirror_ol]:mb-3 [&_.ProseMirror_ol]:list-decimal [&_.ProseMirror_ol]:pl-6 [&_.ProseMirror_p]:mb-3 [&_.ProseMirror_pre]:mb-3 [&_.ProseMirror_pre]:overflow-x-auto [&_.ProseMirror_pre]:rounded-md [&_.ProseMirror_pre]:bg-slate-950 [&_.ProseMirror_pre]:p-3 [&_.ProseMirror_pre]:font-mono [&_.ProseMirror_pre]:text-sm [&_.ProseMirror_pre]:leading-6 [&_.ProseMirror_pre]:text-slate-100 [&_.ProseMirror_ul]:mb-3 [&_.ProseMirror_ul]:list-disc [&_.ProseMirror_ul]:pl-6"
4868
(mouseover)="linkPopover.showPreview($event)"
4969
(mouseout)="linkPopover.scheduleHideFromEvent($event)"
50-
(focus)="linkPopover.showPreview($event)"
70+
(focus)="linkPopover.showPreview($event); mentionController.refresh()"
5171
(blur)="linkPopover.scheduleHideFromEvent($event)"
5272
(focusin)="linkPopover.showPreview($event)"
5373
(focusout)="linkPopover.scheduleHideFromEvent($event)"
74+
(click)="mentionController.refresh()"
5475
/>
5576
</rte-editor>
5677
78+
@if (mentionController.open()) {
79+
<app-sandbox-mention-menu
80+
[placement]="mentionController.placement()"
81+
[suggestions]="mentionController.suggestions()"
82+
[loading]="mentionController.loading()"
83+
[activeIndex]="mentionController.activeIndex()"
84+
(activate)="mentionController.setActiveIndex($event)"
85+
(pick)="mentionController.insert($event)"
86+
(dismiss)="mentionController.hide()"
87+
/>
88+
}
89+
5790
<app-sandbox-link-popover
5891
[popover]="linkPopover.popover()"
5992
[href]="linkPopover.href()"
@@ -75,9 +108,13 @@ import { SandboxToolbar } from './sandbox-toolbar';
75108
`,
76109
})
77110
export class SandboxEditor {
111+
private readonly destroyRef = inject(DestroyRef);
112+
private readonly mentionSurface =
113+
viewChild.required<ElementRef<HTMLElement>>('mentionSurface');
114+
78115
protected readonly editor = createRteEditor({
79116
content:
80-
'<h1><strong>Angular RTE</strong></h1><p style="text-align: center;">Build headless editing primitives with a plugin stack that remains fully selected by the consumer.</p><blockquote><p>Quote important passages without taking ownership away from the consuming app.</p></blockquote><p>Use the toolbar to shape content without surrendering UI ownership: try <em>italic</em>, <u>underline</u>, <s>strikethrough</s>, <mark>highlight</mark>, <span style="color: rgb(14, 116, 144); background-color: rgb(254, 240, 138);">color</span>, formulas like H<sub>2</sub>O and E=mc<sup>2</sup>, and <a href="https://angular.dev" target="_blank" rel="noopener noreferrer">links</a>.</p><pre><code class="language-typescript">import { createRteEditor } from "@angular-rte/editor";&#10;&#10;const editor = createRteEditor({&#10; plugins: [CodeBlockPlugin],&#10;});&#10;&#10;editor.execute("setCodeBlockLanguage", "typescript");</code></pre><pre><code class="language-go">package main&#10;&#10;import "fmt"&#10;&#10;func main() {&#10; fmt.Println("Angular RTE")&#10;}</code></pre><ul><li><p>Compose plugins in TypeScript.</p></li><li><p>Keep toolbar markup in the consuming app.</p></li></ul><ol><li><p>Pick capabilities for the current product surface.</p></li><li><p>Render controls with Angular templates and rteCommand.</p></li></ol><p>Switch paragraphs into lists, nest items with Tab, and lift them back out with Shift+Tab.</p>',
117+
'<h1><strong>Angular RTE</strong></h1><p style="text-align: center;">Build headless editing primitives with a plugin stack that remains fully selected by the consumer.</p><blockquote><p>Quote important passages without taking ownership away from the consuming app.</p></blockquote><p>Use the toolbar to shape content without surrendering UI ownership: try <em>italic</em>, <u>underline</u>, <s>strikethrough</s>, <mark>highlight</mark>, <span style="color: rgb(14, 116, 144); background-color: rgb(254, 240, 138);">color</span>, formulas like H<sub>2</sub>O and E=mc<sup>2</sup>, <span data-rte-mention data-mention-id="ada-lovelace" data-mention-label="Ada Lovelace" data-mention-trigger="@">@Ada Lovelace</span>, and <a href="https://angular.dev" target="_blank" rel="noopener noreferrer">links</a>.</p><pre><code class="language-typescript">import { createRteEditor } from "@angular-rte/editor";&#10;&#10;const editor = createRteEditor({&#10; plugins: [CodeBlockPlugin],&#10;});&#10;&#10;editor.execute("setCodeBlockLanguage", "typescript");</code></pre><pre><code class="language-go">package main&#10;&#10;import "fmt"&#10;&#10;func main() {&#10; fmt.Println("Angular RTE")&#10;}</code></pre><ul><li><p>Compose plugins in TypeScript.</p></li><li><p>Keep toolbar markup in the consuming app.</p></li></ul><ol><li><p>Pick capabilities for the current product surface.</p></li><li><p>Render controls with Angular templates and rteCommand.</p></li></ol><p>Switch paragraphs into lists, nest items with Tab, and lift them back out with Shift+Tab.</p>',
81118
placeholder: 'Start writing...',
82119
plugins: [
83120
HeadingsPlugin,
@@ -90,6 +127,9 @@ export class SandboxEditor {
90127
HighlightPlugin,
91128
ColorPlugin,
92129
LinkPlugin,
130+
MentionPlugin.configure({
131+
trigger: '@',
132+
}),
93133
PasteRulesPlugin,
94134
ListsPlugin,
95135
BlockquotePlugin,
@@ -109,4 +149,28 @@ export class SandboxEditor {
109149
});
110150

111151
protected readonly linkPopover = new LinkPopoverController(this.editor);
152+
protected readonly mentionController = new SandboxMentionController(
153+
this.editor,
154+
createSandboxMentionSource('lazy'),
155+
);
156+
157+
constructor() {
158+
afterNextRender(() => {
159+
const surface = this.mentionSurface().nativeElement;
160+
const refreshMentions = () => this.mentionController.refresh();
161+
const handleMentionKeydown = (event: Event) =>
162+
this.mentionController.handleMentionKeydown(event);
163+
164+
surface.addEventListener('rte-mention-update', refreshMentions);
165+
surface.addEventListener('rte-mention-keydown', handleMentionKeydown);
166+
167+
this.destroyRef.onDestroy(() => {
168+
surface.removeEventListener('rte-mention-update', refreshMentions);
169+
surface.removeEventListener(
170+
'rte-mention-keydown',
171+
handleMentionKeydown,
172+
);
173+
});
174+
});
175+
}
112176
}

0 commit comments

Comments
 (0)