@riducms/plugin-richtext 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,323 @@
1
+ <script lang="ts">
2
+ import { getAdminI18n, type FieldAuthoringHost } from "@riducms/plugin";
3
+ import {
4
+ CommandInput,
5
+ CommandRoot,
6
+ PopoverContent,
7
+ PopoverRoot,
8
+ ToolbarButton,
9
+ ToolbarRoot,
10
+ TooltipContent,
11
+ TooltipRoot,
12
+ TooltipTrigger,
13
+ } from "@riducms/ui";
14
+ import { DraggableBlockPlugin, useLexicalComposerContext } from "@hvniel/lexical-svelte";
15
+ import { mergeRegister } from "@lexical/utils";
16
+ import {
17
+ $createParagraphNode,
18
+ $createTextNode,
19
+ $getNearestNodeFromDOMNode,
20
+ $getNodeByKey,
21
+ $getRoot,
22
+ $getSelection,
23
+ $isParagraphNode,
24
+ $isRangeSelection,
25
+ COMMAND_PRIORITY_LOW,
26
+ KEY_ARROW_DOWN_COMMAND,
27
+ KEY_ARROW_UP_COMMAND,
28
+ type LexicalNode,
29
+ type NodeKey,
30
+ } from "lexical";
31
+ import type { Attachment } from "svelte/attachments";
32
+
33
+ import type { RichTextConfig } from "@plugin-richtext/field/rich-text-config";
34
+ import RichTextMenu from "@plugin-richtext/menu/rich-text-menu.svelte";
35
+ import {
36
+ buildRichTextOptions,
37
+ filterRichTextOptions,
38
+ type RichTextMenuOption,
39
+ } from "@plugin-richtext/menu/rich-text-options";
40
+
41
+ type PickerState = {
42
+ insertBefore: boolean;
43
+ targetNodeKey: NodeKey;
44
+ };
45
+
46
+ let {
47
+ anchorElement,
48
+ authoring,
49
+ config,
50
+ }: {
51
+ anchorElement: HTMLElement;
52
+ authoring: FieldAuthoringHost | undefined;
53
+ config: RichTextConfig;
54
+ } = $props();
55
+ const i18n = getAdminI18n();
56
+ const editor = useLexicalComposerContext()[0];
57
+ let draggableElement = $state.raw<HTMLElement | null>(null);
58
+ let pickerState = $state.raw<PickerState | null>(null);
59
+ let pickerOpen = $state(false);
60
+ let query = $state("");
61
+ let menuElement = $state.raw<HTMLElement | null>(null);
62
+ let movementAnnouncement = $state("");
63
+ let restorePickerFocus = true;
64
+ // The mounted field's authoring surface and feature set are stable.
65
+ // svelte-ignore state_referenced_locally
66
+ const baseOptions = buildRichTextOptions(editor, config, i18n, authoring);
67
+ const options = $derived(filterRichTextOptions(baseOptions, query, i18n.language));
68
+
69
+ function isOnToolbar(element: HTMLElement) {
70
+ return element.closest(".ridu-richtext-block-toolbar") !== null;
71
+ }
72
+
73
+ function closePicker() {
74
+ pickerOpen = false;
75
+ pickerState = null;
76
+ }
77
+
78
+ function handlePickerOpenChange(open: boolean) {
79
+ if (!open) closePicker();
80
+ }
81
+
82
+ function restoreEditorFocus(event: Event) {
83
+ event.preventDefault();
84
+ if (restorePickerFocus) queueMicrotask(() => editor.focus());
85
+ restorePickerFocus = true;
86
+ }
87
+
88
+ function preserveOutsideFocus(event: Event) {
89
+ restorePickerFocus = false;
90
+ const target =
91
+ event.target instanceof Element
92
+ ? event.target.closest<HTMLElement>(
93
+ "button, a[href], input, select, textarea, [tabindex]:not([tabindex='-1'])"
94
+ )
95
+ : null;
96
+ if (target !== null) window.setTimeout(() => target.focus());
97
+ }
98
+
99
+ function closePickerFromEscape() {
100
+ restorePickerFocus = true;
101
+ closePicker();
102
+ }
103
+
104
+ function handlePickerKeydown(event: KeyboardEvent) {
105
+ if (event.key !== "Escape") return;
106
+ event.preventDefault();
107
+ event.stopPropagation();
108
+ closePickerFromEscape();
109
+ }
110
+
111
+ function openPicker(event: MouseEvent) {
112
+ event.preventDefault();
113
+ event.stopPropagation();
114
+ if (draggableElement === null || menuElement === null) return;
115
+
116
+ let targetNodeKey: NodeKey | null = null;
117
+ editor.read(() => {
118
+ targetNodeKey = $getNearestNodeFromDOMNode(draggableElement!)?.getKey() ?? null;
119
+ });
120
+ if (targetNodeKey === null) return;
121
+
122
+ pickerState = {
123
+ insertBefore: event.altKey || event.ctrlKey,
124
+ targetNodeKey,
125
+ };
126
+ query = "";
127
+ restorePickerFocus = true;
128
+ pickerOpen = true;
129
+ }
130
+
131
+ // @lexical-scope
132
+ function $moveBlock(block: LexicalNode, direction: -1 | 1) {
133
+ const siblings = $getRoot().getChildren();
134
+ const index = siblings.findIndex((candidate) => candidate.is(block));
135
+ if (index < 0) return undefined;
136
+ const targetIndex = index + direction;
137
+ if (targetIndex < 0 || targetIndex >= siblings.length) {
138
+ return i18n.t(
139
+ direction < 0
140
+ ? "plugin.richtext:editor.blockAlreadyFirst"
141
+ : "plugin.richtext:editor.blockAlreadyLast"
142
+ );
143
+ }
144
+ const target = siblings[targetIndex];
145
+ if (direction < 0) target.insertBefore(block);
146
+ else target.insertAfter(block);
147
+ return i18n.t("plugin.richtext:editor.blockMovedPosition", {
148
+ position: i18n.formatNumber(targetIndex + 1),
149
+ total: i18n.formatNumber(siblings.length),
150
+ });
151
+ }
152
+
153
+ function announceMovement(message: string | undefined) {
154
+ if (message === undefined) return;
155
+ movementAnnouncement = "";
156
+ queueMicrotask(() => {
157
+ movementAnnouncement = message;
158
+ editor.focus();
159
+ });
160
+ }
161
+
162
+ function selectOption(option: RichTextMenuOption) {
163
+ const state = pickerState;
164
+ restorePickerFocus = option.restoreEditorFocus;
165
+ closePicker();
166
+ if (state === null) return;
167
+
168
+ editor.update(() => {
169
+ const target = $getNodeByKey(state.targetNodeKey);
170
+ if (target === null) return;
171
+ const placeholder = $createParagraphNode();
172
+ const text = $createTextNode("");
173
+ placeholder.append(text);
174
+ if (state.insertBefore) target.insertBefore(placeholder);
175
+ else target.insertAfter(placeholder);
176
+ text.select();
177
+ option.select();
178
+
179
+ if (option.preservePlaceholder) return;
180
+ const latestPlaceholder = placeholder.getLatest();
181
+ if (!$isParagraphNode(latestPlaceholder)) return;
182
+ if (latestPlaceholder.getTextContentSize() === 0) {
183
+ latestPlaceholder.remove();
184
+ }
185
+ });
186
+ }
187
+
188
+ const attachMenuElement: Attachment<HTMLElement> = (element) => {
189
+ menuElement = element;
190
+ return () => {
191
+ if (menuElement === element) menuElement = null;
192
+ };
193
+ };
194
+
195
+ const focusSearchInput: Attachment<HTMLInputElement> = (element) => {
196
+ element.focus();
197
+ };
198
+
199
+ $effect(() =>
200
+ mergeRegister(
201
+ editor.registerCommand(
202
+ KEY_ARROW_UP_COMMAND,
203
+ (event) => {
204
+ if (event === null || !event.altKey || !event.shiftKey) return false;
205
+ const selection = $getSelection();
206
+ if (!$isRangeSelection(selection)) return false;
207
+ event.preventDefault();
208
+ announceMovement($moveBlock(selection.anchor.getNode().getTopLevelElementOrThrow(), -1));
209
+ return true;
210
+ },
211
+ COMMAND_PRIORITY_LOW
212
+ ),
213
+ editor.registerCommand(
214
+ KEY_ARROW_DOWN_COMMAND,
215
+ (event) => {
216
+ if (event === null || !event.altKey || !event.shiftKey) return false;
217
+ const selection = $getSelection();
218
+ if (!$isRangeSelection(selection)) return false;
219
+ event.preventDefault();
220
+ announceMovement($moveBlock(selection.anchor.getNode().getTopLevelElementOrThrow(), 1));
221
+ return true;
222
+ },
223
+ COMMAND_PRIORITY_LOW
224
+ )
225
+ )
226
+ );
227
+ </script>
228
+
229
+ <PopoverRoot open={pickerOpen} onOpenChange={handlePickerOpenChange}>
230
+ <DraggableBlockPlugin
231
+ {anchorElement}
232
+ isOnMenu={isOnToolbar}
233
+ onElementChanged={(element) => (draggableElement = element)}
234
+ >
235
+ {#snippet menu(attach)}
236
+ <ToolbarRoot
237
+ class="ridu-richtext-block-toolbar absolute top-0 start-0 z-2 flex! h-6 cursor-grab items-center gap-[0.2rem] text-foreground-faint opacity-0 transition-[opacity,color] duration-120 will-change-[transform,opacity] hover:text-foreground-muted focus-within:text-foreground-muted active:cursor-grabbing"
238
+ aria-label={i18n.t("plugin.richtext:editor.blockActions")}
239
+ {@attach attach}
240
+ {@attach attachMenuElement}
241
+ >
242
+ <span
243
+ class="ridu-richtext-block-grip grid h-5 w-[0.8rem] grid-cols-[repeat(2,2px)] grid-rows-[repeat(3,2px)] place-content-center gap-[2px]"
244
+ title={i18n.t("plugin.richtext:editor.dragToMoveBlock")}
245
+ aria-hidden="true"
246
+ >
247
+ <span></span>
248
+ <span></span>
249
+ <span></span>
250
+ <span></span>
251
+ <span></span>
252
+ <span></span>
253
+ </span>
254
+ <TooltipRoot>
255
+ <TooltipTrigger>
256
+ {#snippet child({ props })}
257
+ <ToolbarButton
258
+ {...props}
259
+ class="grid size-5 cursor-pointer place-items-center rounded-[3px] border border-transparent bg-transparent p-0 text-sm leading-none text-foreground-muted hover:border-control-border hover:bg-control-hover hover:text-foreground focus-visible:border-control-border focus-visible:bg-control-hover focus-visible:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/45"
260
+ type="button"
261
+ aria-label={i18n.t("plugin.richtext:editor.addBlock")}
262
+ aria-haspopup="dialog"
263
+ aria-expanded={pickerOpen}
264
+ aria-controls={pickerOpen ? "ridu-richtext-block-picker" : undefined}
265
+ onclick={openPicker}
266
+ >
267
+ <span aria-hidden="true">+</span>
268
+ </ToolbarButton>
269
+ {/snippet}
270
+ </TooltipTrigger>
271
+ <TooltipContent>{i18n.t("plugin.richtext:editor.addBlock")}</TooltipContent>
272
+ </TooltipRoot>
273
+ </ToolbarRoot>
274
+ {/snippet}
275
+
276
+ {#snippet targetLine(attach)}
277
+ <div
278
+ class="pointer-events-none absolute top-0 left-0 z-2 h-0.5 rounded-full bg-primary opacity-0 will-change-transform"
279
+ {@attach attach}
280
+ ></div>
281
+ {/snippet}
282
+ </DraggableBlockPlugin>
283
+
284
+ {#if pickerOpen && menuElement !== null}
285
+ <PopoverContent
286
+ id="ridu-richtext-block-picker"
287
+ class="w-[min(19rem,calc(100vw-2rem))] animate-[ridu-richtext-menu-in_150ms_ease] motion-reduce:animate-none"
288
+ role="dialog"
289
+ aria-label={i18n.t("plugin.richtext:editor.insertBlock")}
290
+ customAnchor={menuElement}
291
+ side={i18n.direction === "rtl" ? "left" : "right"}
292
+ align="start"
293
+ sideOffset={8}
294
+ strategy="fixed"
295
+ trapFocus
296
+ onCloseAutoFocus={restoreEditorFocus}
297
+ onInteractOutside={preserveOutsideFocus}
298
+ >
299
+ <CommandRoot label={i18n.t("plugin.richtext:editor.filterBlocks")} shouldFilter={false} loop>
300
+ <CommandInput
301
+ type="search"
302
+ class="box-border h-[2.6rem] border-0 border-b border-control-border bg-control px-[0.8rem] text-[13px] text-foreground outline-none placeholder:text-foreground-faint focus:border-b-primary/48"
303
+ aria-label={i18n.t("plugin.richtext:editor.filterBlocks")}
304
+ placeholder={i18n.t("plugin.richtext:editor.filterBlocksPlaceholder")}
305
+ bind:value={query}
306
+ onkeydown={handlePickerKeydown}
307
+ {@attach focusSearchInput}
308
+ />
309
+ {#if options.length > 0}
310
+ <RichTextMenu {options} onSelect={selectOption} embedded command />
311
+ {:else}
312
+ <p class="p-4 text-xs text-foreground-faint">
313
+ {i18n.t("plugin.richtext:editor.noMatchingBlocks")}
314
+ </p>
315
+ {/if}
316
+ </CommandRoot>
317
+ </PopoverContent>
318
+ {/if}
319
+ </PopoverRoot>
320
+
321
+ <p class="sr-only" aria-live="polite" aria-atomic="true" data-richtext-movement-status>
322
+ {movementAnnouncement}
323
+ </p>
@@ -0,0 +1,32 @@
1
+ import { createCommand, type LexicalCommand, type NodeKey } from "lexical";
2
+
3
+ export interface OpenUploadBrowserPayload {
4
+ collectionSlug: string;
5
+ documentID?: string;
6
+ mode?: "edit" | "insert" | "replace";
7
+ nodeKey?: NodeKey;
8
+ }
9
+
10
+ export type OpenRelationshipBrowserPayload = OpenUploadBrowserPayload;
11
+
12
+ export interface UpdateUploadCaptionPayload {
13
+ caption: string;
14
+ nodeKey: NodeKey;
15
+ }
16
+
17
+ export const OPEN_UPLOAD_BROWSER_COMMAND: LexicalCommand<OpenUploadBrowserPayload> = createCommand(
18
+ "OPEN_UPLOAD_BROWSER_COMMAND"
19
+ );
20
+
21
+ export const OPEN_RELATIONSHIP_BROWSER_COMMAND: LexicalCommand<OpenRelationshipBrowserPayload> =
22
+ createCommand("OPEN_RELATIONSHIP_BROWSER_COMMAND");
23
+
24
+ export const REMOVE_UPLOAD_COMMAND: LexicalCommand<NodeKey> =
25
+ createCommand("REMOVE_UPLOAD_COMMAND");
26
+
27
+ export const REMOVE_RELATIONSHIP_COMMAND: LexicalCommand<NodeKey> = createCommand(
28
+ "REMOVE_RELATIONSHIP_COMMAND"
29
+ );
30
+
31
+ export const UPDATE_UPLOAD_CAPTION_COMMAND: LexicalCommand<UpdateUploadCaptionPayload> =
32
+ createCommand("UPDATE_UPLOAD_CAPTION_COMMAND");
@@ -0,0 +1,107 @@
1
+ <script lang="ts">
2
+ import { getAdminI18n } from "@riducms/plugin";
3
+ import {
4
+ CommandGroup,
5
+ CommandGroupHeading,
6
+ CommandGroupItems,
7
+ CommandItem,
8
+ CommandList,
9
+ } from "@riducms/ui";
10
+
11
+ import type { RichTextMenuOption } from "@plugin-richtext/menu/rich-text-options";
12
+
13
+ let {
14
+ options,
15
+ selectedIndex = null,
16
+ onHighlight = () => undefined,
17
+ onSelect,
18
+ embedded = false,
19
+ command = false,
20
+ idPrefix = "richtext-menu-item",
21
+ }: {
22
+ options: readonly RichTextMenuOption[];
23
+ selectedIndex?: number | null;
24
+ onHighlight?: (index: number) => void;
25
+ onSelect: (option: RichTextMenuOption) => void;
26
+ embedded?: boolean;
27
+ command?: boolean;
28
+ idPrefix?: string;
29
+ } = $props();
30
+ const i18n = getAdminI18n();
31
+ const menuClasses = $derived([
32
+ "ridu-richtext-menu max-h-80 w-[min(19rem,calc(100vw-2rem))] overflow-y-auto rounded-[4px] border border-control-border bg-popover p-[0.35rem] shadow-[var(--shadow-popover)] animate-[ridu-richtext-menu-in_150ms_ease] motion-reduce:animate-none",
33
+ embedded && "max-h-68 w-full rounded-0 border-0 shadow-none animate-none",
34
+ ]);
35
+ const optionClasses =
36
+ "grid min-h-10 cursor-pointer grid-cols-[1.85rem_minmax(0,1fr)_auto] items-center gap-[0.6rem] rounded-[3px] px-[0.55rem] py-[0.35rem] text-foreground-muted outline-none hover:bg-control-hover hover:text-foreground data-[selected]:bg-control-hover data-[selected]:text-foreground";
37
+ </script>
38
+
39
+ {#snippet optionContent(option: RichTextMenuOption)}
40
+ <span
41
+ class="grid size-7 place-items-center rounded-[3px] border border-control-border bg-control font-mono text-[10px] text-foreground-muted"
42
+ aria-hidden="true"
43
+ >
44
+ {option.glyph}
45
+ </span>
46
+ <span class="grid min-w-0 gap-[0.1rem]">
47
+ <strong class="text-[13px] font-medium">{option.label}</strong>
48
+ <small class="text-[11px] text-foreground-faint">{option.description}</small>
49
+ </span>
50
+ <span class="font-mono text-[11px] text-foreground-faint">{option.keyHint}</span>
51
+ {/snippet}
52
+
53
+ {#if command}
54
+ <CommandList class={menuClasses}>
55
+ <CommandGroup>
56
+ <CommandGroupHeading
57
+ class="px-[0.6rem] pt-[0.45rem] pb-[0.35rem] font-mono text-[10px] tracking-[0.13em] text-foreground-faint uppercase"
58
+ >
59
+ {i18n.t("plugin.richtext:editor.insertBlock")}
60
+ </CommandGroupHeading>
61
+ <CommandGroupItems>
62
+ {#each options as option, index (option.key)}
63
+ <CommandItem
64
+ class={optionClasses}
65
+ id={`${idPrefix}-${index}`}
66
+ value={option.label}
67
+ keywords={[...option.keywords]}
68
+ onSelect={() => onSelect(option)}
69
+ >
70
+ {@render optionContent(option)}
71
+ </CommandItem>
72
+ {/each}
73
+ </CommandGroupItems>
74
+ </CommandGroup>
75
+ </CommandList>
76
+ {:else}
77
+ <div class={menuClasses} data-richtext-menu-surface>
78
+ <p
79
+ class="px-[0.6rem] pt-[0.45rem] pb-[0.35rem] font-mono text-[10px] tracking-[0.13em] text-foreground-faint uppercase"
80
+ >
81
+ {i18n.t("plugin.richtext:editor.insertBlock")}
82
+ </p>
83
+ <ul class="grid list-none gap-[0.1rem] p-0" role="presentation">
84
+ {#each options as option, index (option.key)}
85
+ <li
86
+ id={`${idPrefix}-${index}`}
87
+ class={[optionClasses, selectedIndex === index && "bg-control-hover text-foreground"]}
88
+ role="option"
89
+ aria-selected={selectedIndex === index}
90
+ tabindex="-1"
91
+ {@attach option.attach}
92
+ onmouseenter={() => onHighlight(index)}
93
+ onmousedown={(event) => event.preventDefault()}
94
+ onclick={() => onSelect(option)}
95
+ onkeydown={(event) => {
96
+ if (event.key === "Enter" || event.key === " ") {
97
+ event.preventDefault();
98
+ onSelect(option);
99
+ }
100
+ }}
101
+ >
102
+ {@render optionContent(option)}
103
+ </li>
104
+ {/each}
105
+ </ul>
106
+ </div>
107
+ {/if}
@@ -0,0 +1,244 @@
1
+ import { $createCodeNode } from "@lexical/code";
2
+ import {
3
+ INSERT_CHECK_LIST_COMMAND,
4
+ INSERT_ORDERED_LIST_COMMAND,
5
+ INSERT_UNORDERED_LIST_COMMAND,
6
+ } from "@lexical/list";
7
+ import { $createHeadingNode, $createQuoteNode } from "@lexical/rich-text";
8
+ import { $setBlocksType } from "@lexical/selection";
9
+ import type { AdminI18n, FieldAuthoringHost } from "@riducms/plugin";
10
+ import { INSERT_HORIZONTAL_RULE_COMMAND, MenuOption } from "@hvniel/lexical-svelte";
11
+ import {
12
+ $createParagraphNode,
13
+ $getSelection,
14
+ $isRangeSelection,
15
+ type ElementNode,
16
+ type LexicalEditor,
17
+ } from "lexical";
18
+
19
+ import {
20
+ OPEN_RELATIONSHIP_BROWSER_COMMAND,
21
+ OPEN_UPLOAD_BROWSER_COMMAND,
22
+ } from "@plugin-richtext/menu/rich-text-commands";
23
+ import { hasRichTextFeature, type RichTextConfig } from "@plugin-richtext/field/rich-text-config";
24
+
25
+ export class RichTextMenuOption extends MenuOption {
26
+ readonly label: string;
27
+ readonly description: string;
28
+ readonly glyph: string;
29
+ readonly keywords: readonly string[];
30
+ readonly select: () => void;
31
+ readonly keyHint: string;
32
+ readonly preservePlaceholder: boolean;
33
+ readonly restoreEditorFocus: boolean;
34
+
35
+ constructor(
36
+ key: string,
37
+ label: string,
38
+ description: string,
39
+ glyph: string,
40
+ keywords: readonly string[],
41
+ select: () => void,
42
+ options: {
43
+ keyHint?: string;
44
+ preservePlaceholder?: boolean;
45
+ restoreEditorFocus?: boolean;
46
+ } = {}
47
+ ) {
48
+ super(key);
49
+ this.label = label;
50
+ this.description = description;
51
+ this.glyph = glyph;
52
+ this.keywords = keywords;
53
+ this.select = select;
54
+ this.keyHint = options.keyHint ?? "↵";
55
+ this.preservePlaceholder = options.preservePlaceholder ?? true;
56
+ this.restoreEditorFocus = options.restoreEditorFocus ?? true;
57
+ }
58
+ }
59
+
60
+ export function buildRichTextOptions(
61
+ editor: LexicalEditor,
62
+ config: RichTextConfig,
63
+ i18n: AdminI18n,
64
+ authoring?: FieldAuthoringHost
65
+ ): RichTextMenuOption[] {
66
+ const options = [
67
+ new RichTextMenuOption(
68
+ "paragraph",
69
+ i18n.t("plugin.richtext:option.paragraph.label"),
70
+ i18n.t("plugin.richtext:option.paragraph.description"),
71
+ "¶",
72
+ keywords(i18n.t("plugin.richtext:option.paragraph.keywords")),
73
+ () => $setBlock($createParagraphNode)
74
+ ),
75
+ new RichTextMenuOption(
76
+ "heading-2",
77
+ i18n.t("plugin.richtext:option.heading2.label"),
78
+ i18n.t("plugin.richtext:option.heading2.description"),
79
+ "h2",
80
+ keywords(i18n.t("plugin.richtext:option.heading2.keywords")),
81
+ () => $setBlock(() => $createHeadingNode("h2"))
82
+ ),
83
+ new RichTextMenuOption(
84
+ "heading-3",
85
+ i18n.t("plugin.richtext:option.heading3.label"),
86
+ i18n.t("plugin.richtext:option.heading3.description"),
87
+ "h3",
88
+ keywords(i18n.t("plugin.richtext:option.heading3.keywords")),
89
+ () => $setBlock(() => $createHeadingNode("h3"))
90
+ ),
91
+ new RichTextMenuOption(
92
+ "quote",
93
+ i18n.t("plugin.richtext:option.quote.label"),
94
+ i18n.t("plugin.richtext:option.quote.description"),
95
+ '"',
96
+ keywords(i18n.t("plugin.richtext:option.quote.keywords")),
97
+ () => $setBlock($createQuoteNode)
98
+ ),
99
+ ];
100
+
101
+ if (hasRichTextFeature(config, "lists")) {
102
+ options.push(
103
+ new RichTextMenuOption(
104
+ "bulleted-list",
105
+ i18n.t("plugin.richtext:option.bulletedList.label"),
106
+ i18n.t("plugin.richtext:option.bulletedList.description"),
107
+ "•",
108
+ keywords(i18n.t("plugin.richtext:option.bulletedList.keywords")),
109
+ () => editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined)
110
+ ),
111
+ new RichTextMenuOption(
112
+ "numbered-list",
113
+ i18n.t("plugin.richtext:option.numberedList.label"),
114
+ i18n.t("plugin.richtext:option.numberedList.description"),
115
+ "1.",
116
+ keywords(i18n.t("plugin.richtext:option.numberedList.keywords")),
117
+ () => editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined)
118
+ ),
119
+ new RichTextMenuOption(
120
+ "checklist",
121
+ i18n.t("plugin.richtext:option.checklist.label"),
122
+ i18n.t("plugin.richtext:option.checklist.description"),
123
+ "☑",
124
+ keywords(i18n.t("plugin.richtext:option.checklist.keywords")),
125
+ () => editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined)
126
+ )
127
+ );
128
+ }
129
+
130
+ if (hasRichTextFeature(config, "code")) {
131
+ options.push(
132
+ new RichTextMenuOption(
133
+ "code-block",
134
+ i18n.t("plugin.richtext:option.codeBlock.label"),
135
+ i18n.t("plugin.richtext:option.codeBlock.description"),
136
+ "</>",
137
+ keywords(i18n.t("plugin.richtext:option.codeBlock.keywords")),
138
+ () => $setBlock($createCodeNode)
139
+ )
140
+ );
141
+ }
142
+
143
+ if (hasRichTextFeature(config, "horizontal-rule")) {
144
+ options.push(
145
+ new RichTextMenuOption(
146
+ "divider",
147
+ i18n.t("plugin.richtext:option.divider.label"),
148
+ i18n.t("plugin.richtext:option.divider.description"),
149
+ "—",
150
+ keywords(i18n.t("plugin.richtext:option.divider.keywords")),
151
+ () => editor.dispatchCommand(INSERT_HORIZONTAL_RULE_COMMAND, undefined),
152
+ { preservePlaceholder: false }
153
+ )
154
+ );
155
+ }
156
+
157
+ if (hasRichTextFeature(config, "uploads")) {
158
+ const allowed = new Set(config.uploadCollections);
159
+ const uploadOptions: RichTextMenuOption[] = [];
160
+ for (const collection of authoring?.collections ?? []) {
161
+ if (!collection.capabilities.upload || (allowed.size > 0 && !allowed.has(collection.slug))) {
162
+ continue;
163
+ }
164
+ uploadOptions.push(
165
+ new RichTextMenuOption(
166
+ `upload-${collection.slug}`,
167
+ collection.labels.singular,
168
+ i18n.t("plugin.richtext:option.upload.description", {
169
+ label: collection.labels.singular.toLocaleLowerCase(i18n.language),
170
+ }),
171
+ "ref",
172
+ [...keywords(i18n.t("plugin.richtext:option.upload.keywords")), collection.slug],
173
+ () =>
174
+ queueMicrotask(() =>
175
+ editor.dispatchCommand(OPEN_UPLOAD_BROWSER_COMMAND, {
176
+ collectionSlug: collection.slug,
177
+ })
178
+ ),
179
+ { restoreEditorFocus: false }
180
+ )
181
+ );
182
+ }
183
+ options.splice(4, 0, ...uploadOptions);
184
+ }
185
+
186
+ if (hasRichTextFeature(config, "relationships")) {
187
+ const allowed = new Set(config.relationshipCollections);
188
+ const relationshipOptions: RichTextMenuOption[] = [];
189
+ for (const collection of authoring?.collections ?? []) {
190
+ if (allowed.size > 0 && !allowed.has(collection.slug)) continue;
191
+ relationshipOptions.push(
192
+ new RichTextMenuOption(
193
+ `relationship-${collection.slug}`,
194
+ i18n.t("plugin.richtext:option.relationship.label", {
195
+ label: collection.labels.singular,
196
+ }),
197
+ i18n.t("plugin.richtext:option.relationship.description", {
198
+ label: collection.labels.singular.toLocaleLowerCase(i18n.language),
199
+ }),
200
+ "rel",
201
+ [...keywords(i18n.t("plugin.richtext:option.relationship.keywords")), collection.slug],
202
+ () =>
203
+ queueMicrotask(() =>
204
+ editor.dispatchCommand(OPEN_RELATIONSHIP_BROWSER_COMMAND, {
205
+ collectionSlug: collection.slug,
206
+ })
207
+ ),
208
+ { restoreEditorFocus: false }
209
+ )
210
+ );
211
+ }
212
+ // Upload and relationship labels can intentionally overlap (for example,
213
+ // Asset and Related Asset). Preserve the established upload result first
214
+ // for keyboard selection and append the explicit relationship commands.
215
+ options.push(...relationshipOptions);
216
+ }
217
+
218
+ return options;
219
+ }
220
+
221
+ export function filterRichTextOptions(
222
+ options: readonly RichTextMenuOption[],
223
+ query: string,
224
+ language?: string
225
+ ): RichTextMenuOption[] {
226
+ const normalized = query.trim().toLocaleLowerCase(language);
227
+ if (normalized === "") return [...options];
228
+ return options.filter(
229
+ (option) =>
230
+ option.label.toLocaleLowerCase(language).includes(normalized) ||
231
+ option.description.toLocaleLowerCase(language).includes(normalized) ||
232
+ option.keywords.some((keyword) => keyword.toLocaleLowerCase(language).includes(normalized))
233
+ );
234
+ }
235
+
236
+ function keywords(value: string) {
237
+ return value.split(/\s+/).filter(Boolean);
238
+ }
239
+
240
+ // @lexical-scope
241
+ function $setBlock(createBlock: () => ElementNode) {
242
+ const selection = $getSelection();
243
+ if ($isRangeSelection(selection)) $setBlocksType(selection, createBlock);
244
+ }