@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.
- package/LICENSE +21 -0
- package/README.md +26 -0
- package/package.json +52 -0
- package/src/field/rich-text-config.ts +47 -0
- package/src/field/rich-text-context.svelte.ts +8 -0
- package/src/field/rich-text-document.ts +20 -0
- package/src/field/rich-text-field.svelte +187 -0
- package/src/field/rich-text-footer.svelte +49 -0
- package/src/index.ts +33 -0
- package/src/menu/rich-text-block-toolbar.svelte +323 -0
- package/src/menu/rich-text-commands.ts +32 -0
- package/src/menu/rich-text-menu.svelte +107 -0
- package/src/menu/rich-text-options.ts +244 -0
- package/src/menu/rich-text-slash-menu.svelte +124 -0
- package/src/messages.ts +240 -0
- package/src/relationship/rich-text-relationship-node.svelte +141 -0
- package/src/relationship/rich-text-relationship-node.ts +105 -0
- package/src/relationship/rich-text-relationship-plugin.svelte +174 -0
- package/src/styles/integration.css +83 -0
- package/src/toolbar/rich-text-floating-toolbar.svelte +322 -0
- package/src/toolbar/toolbar-icon.svelte +47 -0
- package/src/upload/rich-text-upload-node.svelte +282 -0
- package/src/upload/rich-text-upload-node.ts +144 -0
- package/src/upload/rich-text-upload-plugin.svelte +183 -0
- package/src/vite-env.d.ts +1 -0
|
@@ -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
|
+
}
|