@riducms/plugin-richtext 0.2.3 → 0.4.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.
Files changed (43) hide show
  1. package/README.md +7 -1
  2. package/package.json +7 -5
  3. package/src/block/rich-text-block-card.scss +139 -0
  4. package/src/block/rich-text-block-card.svelte +42 -21
  5. package/src/block/rich-text-block-clipboard.ts +1 -1
  6. package/src/block/rich-text-block-node.ts +1 -1
  7. package/src/block/rich-text-blocks-plugin.svelte +5 -2
  8. package/src/document-validation.ts +7 -5
  9. package/src/field/reference-node-placement.ts +49 -0
  10. package/src/field/rich-text-blocks.ts +1 -5
  11. package/src/field/rich-text-config.ts +1 -4
  12. package/src/field/rich-text-document.ts +1 -4
  13. package/src/field/rich-text-editor.svelte +105 -57
  14. package/src/field/rich-text-footer.svelte +7 -12
  15. package/src/field/rich-text-markdown.ts +62 -0
  16. package/src/link/link-url.ts +30 -0
  17. package/src/link/rich-text-link-plugin.svelte +528 -0
  18. package/src/link/rich-text-link.scss +144 -0
  19. package/src/link/safe-link-transform.ts +20 -0
  20. package/src/link-url-policy.ts +14 -0
  21. package/src/menu/rich-text-block-toolbar.svelte +403 -70
  22. package/src/menu/rich-text-commands.ts +10 -1
  23. package/src/menu/rich-text-menu.scss +92 -0
  24. package/src/menu/rich-text-menu.svelte +62 -69
  25. package/src/menu/rich-text-option-filter.ts +30 -0
  26. package/src/menu/rich-text-options.ts +81 -84
  27. package/src/menu/rich-text-slash-menu.svelte +15 -11
  28. package/src/messages.ts +72 -27
  29. package/src/relationship/rich-text-relationship-node.scss +98 -0
  30. package/src/relationship/rich-text-relationship-node.svelte +38 -32
  31. package/src/relationship/rich-text-relationship-plugin.svelte +24 -40
  32. package/src/render/rich-text.svelte +10 -5
  33. package/src/render.ts +3 -8
  34. package/src/styles/integration.scss +384 -0
  35. package/src/toolbar/PAYLOAD-LICENSE.md +22 -0
  36. package/src/toolbar/rich-text-floating-toolbar.svelte +331 -169
  37. package/src/toolbar/rich-text-toolbar.scss +80 -0
  38. package/src/toolbar/toolbar-icon.svelte +200 -30
  39. package/src/upload/rich-text-upload-node.scss +308 -0
  40. package/src/upload/rich-text-upload-node.svelte +96 -125
  41. package/src/upload/rich-text-upload-node.ts +1 -0
  42. package/src/upload/rich-text-upload-plugin.svelte +10 -33
  43. package/src/styles/integration.css +0 -83
@@ -1,69 +1,101 @@
1
1
  <script lang="ts">
2
+ import { $createHeadingNode, $createQuoteNode, type HeadingTagType } from "@lexical/rich-text";
3
+ import { $setBlocksType } from "@lexical/selection";
4
+ import {
5
+ INSERT_ORDERED_LIST_COMMAND,
6
+ INSERT_UNORDERED_LIST_COMMAND,
7
+ INSERT_CHECK_LIST_COMMAND,
8
+ } from "@lexical/list";
9
+ import { hasRichTextFeature, type RichTextConfig } from "@plugin-richtext/field/rich-text-config";
10
+ import ChevronDownIcon from "~icons/lucide/chevron-down";
2
11
  import { $isLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link";
3
- import { mergeRegister } from "@lexical/utils";
12
+ import { $findMatchingParent, mergeRegister } from "@lexical/utils";
4
13
  import { getAdminI18n } from "@riducms/plugin";
5
14
  import {
6
- Button,
7
- Input,
8
- PopoverContent,
9
- PopoverRoot,
10
- PopoverTrigger,
11
15
  ToolbarButton,
12
16
  ToolbarRoot,
17
+ MenuRoot,
18
+ MenuTrigger,
19
+ MenuContent,
20
+ MenuItem,
13
21
  } from "@riducms/ui";
14
22
  import { Portal, useLexicalComposerContext, useLexicalEditable } from "@hvniel/lexical-svelte";
23
+ import type { Attachment } from "svelte/attachments";
15
24
  import {
16
25
  $getSelection,
26
+ $createParagraphNode,
27
+ $isElementNode,
28
+ $isTextNode,
29
+ FORMAT_ELEMENT_COMMAND,
30
+ INDENT_CONTENT_COMMAND,
31
+ OUTDENT_CONTENT_COMMAND,
32
+ type ElementFormatType,
33
+ type ElementNode,
17
34
  $isRangeSelection,
18
35
  COMMAND_PRIORITY_LOW,
36
+ KEY_TAB_COMMAND,
19
37
  SELECTION_CHANGE_COMMAND,
20
38
  type TextFormatType,
21
39
  } from "lexical";
22
- import { tick } from "svelte";
23
-
24
- import ToolbarIcon from "@plugin-richtext/toolbar/toolbar-icon.svelte";
40
+ import "@plugin-richtext/toolbar/rich-text-toolbar.scss";
41
+ import { OPEN_LINK_EDITOR_COMMAND } from "@plugin-richtext/menu/rich-text-commands";
42
+ import { $isHeadingNode, $isQuoteNode } from "@lexical/rich-text";
43
+ import { $isListNode } from "@lexical/list";
44
+ import ToolbarIcon, { type RichTextIconName } from "@plugin-richtext/toolbar/toolbar-icon.svelte";
25
45
 
46
+ let { config }: { config: RichTextConfig } = $props();
26
47
  const editor = useLexicalComposerContext()[0];
27
48
  const isEditable = useLexicalEditable();
28
49
  const i18n = getAdminI18n();
29
- const formatButtons: readonly {
30
- format: TextFormatType;
31
- icon: "bold" | "code" | "italic" | "strike" | "subscript" | "superscript" | "underline";
32
- label: string;
33
- }[] = [
34
- { format: "bold", icon: "bold", label: i18n.t("plugin.richtext:editor.bold") },
35
- { format: "italic", icon: "italic", label: i18n.t("plugin.richtext:editor.italic") },
36
- { format: "underline", icon: "underline", label: i18n.t("plugin.richtext:editor.underline") },
50
+ const formatButtons = [
51
+ { format: "bold", icon: "bold", labelKey: "plugin.richtext:editor.bold" },
52
+ { format: "italic", icon: "italic", labelKey: "plugin.richtext:editor.italic" },
53
+ { format: "underline", icon: "underline", labelKey: "plugin.richtext:editor.underline" },
37
54
  {
38
55
  format: "strikethrough",
39
56
  icon: "strike",
40
- label: i18n.t("plugin.richtext:editor.strikethrough"),
57
+ labelKey: "plugin.richtext:editor.strikethrough",
58
+ },
59
+ {
60
+ format: "subscript",
61
+ icon: "subscript",
62
+ labelKey: "plugin.richtext:editor.subscript",
41
63
  },
42
- { format: "subscript", icon: "subscript", label: i18n.t("plugin.richtext:editor.subscript") },
43
64
  {
44
65
  format: "superscript",
45
66
  icon: "superscript",
46
- label: i18n.t("plugin.richtext:editor.superscript"),
67
+ labelKey: "plugin.richtext:editor.superscript",
47
68
  },
48
- { format: "code", icon: "code", label: i18n.t("plugin.richtext:editor.inlineCode") },
49
- ];
50
- const toolbarButtonClass =
51
- "size-[28.8px] rounded-[3px] p-0 text-foreground-muted hover:bg-control-hover hover:text-foreground focus-visible:bg-control-hover focus-visible:text-foreground focus-visible:outline-none data-[active]:bg-primary/12 data-[active]:text-brand-primary-soft";
69
+ { format: "code", icon: "code", labelKey: "plugin.richtext:editor.inlineCode" },
70
+ ] as const;
71
+ const toolbarButtonClass = "ridu-richtext-toolbar-button";
72
+ let textMenuOpen = $state(false);
73
+ let alignMenuOpen = $state(false);
74
+ let canOutdent = $state(false);
52
75
  let visible = $state(false);
53
76
  let formats = $state.raw<Set<TextFormatType>>(new Set());
54
77
  let link = $state(false);
55
- let linkURL = $state("");
56
- let linkOpen = $state(false);
57
- let linkDraft = $state("");
78
+ let blockType = $state<RichTextIconName>("paragraph");
79
+ let alignment = $state<ElementFormatType>("left");
58
80
  let left = $state(0);
59
81
  let top = $state(0);
60
- let toolbar = $state<HTMLDivElement | null>(null);
61
- let linkInput = $state<HTMLInputElement | null>(null);
62
- let restoreLinkFocus = true;
82
+ let toolbar: HTMLElement | null = null;
83
+ let restoreMenuFocus = true;
84
+ let selectingPointer: number | undefined;
85
+ function isOwnEditableNode(node: EventTarget | null, root: HTMLElement) {
86
+ const element =
87
+ node instanceof Element ? node : node instanceof Node ? node.parentElement : null;
88
+ return element?.closest("[contenteditable]") === root;
89
+ }
63
90
 
64
91
  // @lexical-scope
65
92
  function $updateToolbar() {
66
- if (linkOpen) return;
93
+ // Keep the portal out of the pointer path until the native selection gesture ends.
94
+ if (selectingPointer !== undefined) {
95
+ visible = false;
96
+ return;
97
+ }
98
+ if (textMenuOpen || alignMenuOpen) return;
67
99
  const selection = $getSelection();
68
100
  const domSelection = editor._window?.getSelection() ?? window.getSelection();
69
101
  const root = editor.getRootElement();
@@ -76,21 +108,51 @@
76
108
  domSelection === null ||
77
109
  domSelection.rangeCount === 0 ||
78
110
  root === null ||
79
- !root.contains(domSelection.anchorNode)
111
+ !isOwnEditableNode(domSelection.anchorNode, root) ||
112
+ (!root.contains(document.activeElement) && !toolbar?.contains(document.activeElement))
80
113
  ) {
81
114
  visible = false;
82
115
  return;
83
116
  }
84
117
 
118
+ const block = $findMatchingParent(
119
+ selection.anchor.getNode(),
120
+ (node): node is ElementNode => $isElementNode(node) && !node.isInline()
121
+ );
122
+ canOutdent = (block?.getIndent() ?? 0) > 0;
123
+ alignment = block?.getFormatType() || "left";
124
+ const list = $findMatchingParent(selection.anchor.getNode(), $isListNode);
125
+ blockType = $isHeadingNode(block)
126
+ ? block.getTag()
127
+ : $isQuoteNode(block)
128
+ ? "quote"
129
+ : $isListNode(list)
130
+ ? list.getListType() === "number"
131
+ ? "ordered"
132
+ : list.getListType() === "check"
133
+ ? "check"
134
+ : "unordered"
135
+ : "paragraph";
85
136
  formats = new Set(
86
137
  formatButtons.map(({ format }) => format).filter((format) => selection.hasFormat(format))
87
138
  );
88
- const anchorNode = selection.anchor.getNode();
89
- let linkNode = $isLinkNode(anchorNode) ? anchorNode : undefined;
90
- const parent = anchorNode.getParent();
91
- if (linkNode === undefined && $isLinkNode(parent)) linkNode = parent;
92
- link = linkNode !== undefined;
93
- linkURL = linkNode?.getURL() ?? "";
139
+ const [start, end] = selection.isBackward()
140
+ ? [selection.focus, selection.anchor]
141
+ : [selection.anchor, selection.focus];
142
+ // Boundary nodes can be included without contributing any selected characters.
143
+ const selectedText = selection
144
+ .getNodes()
145
+ .filter($isTextNode)
146
+ .filter((node) => {
147
+ const size = node.getTextContentSize();
148
+ if (size === 0) return false;
149
+ if (start.type === "text" && start.key === node.getKey() && start.offset === size)
150
+ return false;
151
+ return !(end.type === "text" && end.key === node.getKey() && end.offset === 0);
152
+ });
153
+ link =
154
+ selectedText.length > 0 &&
155
+ selectedText.every((node) => $findMatchingParent(node, $isLinkNode) !== null);
94
156
  visible = true;
95
157
  positionToolbar(domSelection.getRangeAt(0).getBoundingClientRect());
96
158
  }
@@ -105,15 +167,52 @@
105
167
  selectionRect.left + selectionRect.width / 2 - width / 2
106
168
  )
107
169
  );
108
- const preferredTop = selectionRect.top - height - 9;
170
+ const preferredTop = selectionRect.bottom + 6;
109
171
  const maximumTop = window.innerHeight - height - 12;
110
- top = Math.max(12, Math.min(maximumTop, preferredTop));
172
+ top = Math.max(12, preferredTop <= maximumTop ? preferredTop : selectionRect.top - height - 6);
111
173
  }
112
174
 
113
175
  function updateFromEditor() {
114
176
  editor.getEditorState().read($updateToolbar, { editor });
115
177
  }
116
178
 
179
+ const attachToolbar: Attachment<HTMLElement> = (element) => {
180
+ toolbar = element;
181
+ updateFromEditor();
182
+ return () => {
183
+ if (toolbar === element) toolbar = null;
184
+ };
185
+ };
186
+
187
+ function focusToolbar(event: KeyboardEvent) {
188
+ if (event.shiftKey || !visible || toolbar === null) return false;
189
+ const firstControl = toolbar.querySelector<HTMLElement>(
190
+ "button:not([disabled]), a[href], input:not([disabled]), [tabindex]:not([tabindex='-1'])"
191
+ );
192
+ if (firstControl === null) return false;
193
+ event.preventDefault();
194
+ firstControl.focus();
195
+ return true;
196
+ }
197
+
198
+ function beginPointerSelection(event: PointerEvent) {
199
+ const root = editor.getRootElement();
200
+ if (event.button !== 0 || root === null || !isOwnEditableNode(event.target, root)) return;
201
+ selectingPointer = event.pointerId;
202
+ visible = false;
203
+ }
204
+
205
+ function endPointerSelection(event: PointerEvent) {
206
+ if (event.pointerId !== selectingPointer) return;
207
+ selectingPointer = undefined;
208
+ updateFromEditor();
209
+ }
210
+
211
+ function resetPointerSelection() {
212
+ selectingPointer = undefined;
213
+ updateFromEditor();
214
+ }
215
+
117
216
  function preserveSelection(event: MouseEvent) {
118
217
  if (event.target instanceof HTMLInputElement) return;
119
218
  event.preventDefault();
@@ -128,67 +227,15 @@
128
227
  queueMicrotask(updateFromEditor);
129
228
  }
130
229
 
131
- async function openLinkEditor() {
132
- linkDraft = linkURL || "https://";
133
- restoreLinkFocus = true;
134
- linkOpen = true;
135
- await tick();
136
- window.setTimeout(() => {
137
- linkInput?.focus();
138
- linkInput?.select();
139
- });
140
- }
141
-
142
- async function handleLinkOpenChange(open: boolean) {
143
- if (open) await openLinkEditor();
144
- else closeLinkEditor();
145
- }
146
-
147
- function sanitizeURL(value: string) {
148
- const trimmed = value.trim();
149
- if (/^(?:https?:|mailto:|tel:)/i.test(trimmed)) return trimmed;
150
- return `https://${trimmed}`;
151
- }
152
-
153
- function applyLink() {
154
- if (linkDraft.trim() === "" || linkDraft === "https://") return;
155
- editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeURL(linkDraft));
156
- closeLinkEditor();
157
- queueMicrotask(() => editor.focus());
158
- }
159
-
160
- function removeLink() {
161
- editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
162
- closeLinkEditor();
163
- queueMicrotask(() => editor.focus());
164
- }
165
-
166
- function closeLinkEditor() {
167
- linkOpen = false;
168
- }
169
-
170
- function handleLinkKeydown(event: KeyboardEvent) {
171
- if (event.key === "Enter") {
172
- event.preventDefault();
173
- applyLink();
174
- }
175
- }
176
-
177
- function focusLinkInput(event: Event) {
178
- event.preventDefault();
179
- linkInput?.focus();
180
- linkInput?.select();
181
- }
182
-
183
230
  function restoreEditorFocus(event: Event) {
184
231
  event.preventDefault();
185
- const shouldRestore = restoreLinkFocus;
186
- restoreLinkFocus = true;
232
+ const shouldRestore = restoreMenuFocus;
233
+ restoreMenuFocus = true;
187
234
  if (shouldRestore) queueMicrotask(() => editor.focus());
188
235
  }
189
236
 
190
237
  function preserveOutsideFocus(event: Event) {
191
- restoreLinkFocus = false;
238
+ restoreMenuFocus = false;
192
239
  const target =
193
240
  event.target instanceof Element
194
241
  ? event.target.closest<HTMLElement>(
@@ -198,17 +245,53 @@
198
245
  if (target !== null) window.setTimeout(() => target.focus());
199
246
  }
200
247
 
201
- $effect(() => {
202
- if (toolbar === null) return;
203
- updateFromEditor();
204
- });
248
+ function changeBlock(type: string) {
249
+ if (!editor.isEditable()) return;
250
+ editor.update(() => {
251
+ const selection = $getSelection();
252
+ if (!$isRangeSelection(selection)) return;
253
+ if (type === "ordered") editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
254
+ else if (type === "unordered")
255
+ editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
256
+ else if (type === "check") editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined);
257
+ else
258
+ $setBlocksType(selection, () =>
259
+ type === "paragraph"
260
+ ? $createParagraphNode()
261
+ : type === "quote"
262
+ ? $createQuoteNode()
263
+ : $createHeadingNode(type as HeadingTagType)
264
+ );
265
+ });
266
+ textMenuOpen = false;
267
+ editor.focus();
268
+ }
269
+
270
+ function alignText(format: ElementFormatType) {
271
+ if (!editor.isEditable()) return;
272
+ editor.dispatchCommand(FORMAT_ELEMENT_COMMAND, format);
273
+ alignMenuOpen = false;
274
+ editor.focus();
275
+ }
276
+
277
+ function toggleLink() {
278
+ if (!editor.isEditable()) return;
279
+ if (link) editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
280
+ else editor.dispatchCommand(OPEN_LINK_EDITOR_COMMAND, undefined);
281
+ }
205
282
 
206
283
  $effect(() => {
284
+ document.addEventListener("pointerdown", beginPointerSelection, true);
285
+ document.addEventListener("pointerup", endPointerSelection, true);
286
+ document.addEventListener("pointercancel", endPointerSelection, true);
207
287
  document.addEventListener("selectionchange", updateFromEditor);
288
+ document.addEventListener("focusin", updateFromEditor);
208
289
  document.addEventListener("scroll", updateFromEditor, true);
290
+ window.addEventListener("blur", resetPointerSelection);
209
291
  window.addEventListener("resize", updateFromEditor);
210
292
  const unregister = mergeRegister(
211
293
  editor.registerUpdateListener(({ editorState }) => editorState.read($updateToolbar)),
294
+ editor.registerCommand(KEY_TAB_COMMAND, focusToolbar, COMMAND_PRIORITY_LOW),
212
295
  editor.registerCommand(
213
296
  SELECTION_CHANGE_COMMAND,
214
297
  () => {
@@ -220,8 +303,13 @@
220
303
  );
221
304
  return () => {
222
305
  unregister();
306
+ document.removeEventListener("pointerdown", beginPointerSelection, true);
307
+ document.removeEventListener("pointerup", endPointerSelection, true);
308
+ document.removeEventListener("pointercancel", endPointerSelection, true);
223
309
  document.removeEventListener("selectionchange", updateFromEditor);
310
+ document.removeEventListener("focusin", updateFromEditor);
224
311
  document.removeEventListener("scroll", updateFromEditor, true);
312
+ window.removeEventListener("blur", resetPointerSelection);
225
313
  window.removeEventListener("resize", updateFromEditor);
226
314
  };
227
315
  });
@@ -230,93 +318,167 @@
230
318
  {#if visible}
231
319
  <Portal>
232
320
  <ToolbarRoot
233
- bind:ref={toolbar}
234
- class="fixed z-80 gap-[0.1rem] rounded-[4px] border border-control-border bg-popover p-1 shadow-[var(--shadow-popover)] animate-[ridu-richtext-toolbar-in_150ms_ease] motion-reduce:animate-none"
321
+ {@attach attachToolbar}
322
+ class="ridu-richtext-floating-toolbar"
235
323
  aria-label={i18n.t("plugin.richtext:editor.formatText")}
236
324
  tabindex={-1}
237
325
  style={`left: ${left}px; top: ${top}px;`}
238
326
  onmousedown={preserveSelection}
239
327
  >
240
- {#each formatButtons as button (button.format)}
241
- <ToolbarButton
242
- class={toolbarButtonClass}
243
- type="button"
244
- active={formats.has(button.format)}
245
- aria-label={button.label}
246
- aria-pressed={formats.has(button.format)}
247
- onclick={() => formatText(button.format)}
248
- >
249
- <ToolbarIcon name={button.icon} />
250
- </ToolbarButton>
251
- {/each}
252
- <span class="mx-[0.15rem] h-[1.15rem] w-px bg-border" aria-hidden="true"></span>
253
- <PopoverRoot open={linkOpen} onOpenChange={handleLinkOpenChange}>
254
- <PopoverTrigger>
328
+ <MenuRoot bind:open={textMenuOpen}>
329
+ <MenuTrigger>
255
330
  {#snippet child({ props })}
256
331
  <ToolbarButton
257
332
  {...props}
258
- class={toolbarButtonClass}
259
- type="button"
260
- active={link}
261
- aria-label={i18n.t(
262
- link ? "plugin.richtext:editor.editLink" : "plugin.richtext:editor.addLink"
263
- )}
264
- aria-pressed={link}
333
+ class="ridu-richtext-toolbar-button ridu-richtext-toolbar-menu"
334
+ aria-label={i18n.t("plugin.richtext:editor.textStyle")}
265
335
  >
266
- <ToolbarIcon name="link" />
336
+ <ToolbarIcon name={blockType} /><ChevronDownIcon width="10" />
267
337
  </ToolbarButton>
268
338
  {/snippet}
269
- </PopoverTrigger>
270
-
271
- <PopoverContent
272
- class="grid w-68 gap-[0.45rem] p-[0.65rem]"
273
- role="dialog"
274
- aria-label={i18n.t("plugin.richtext:editor.editLink")}
275
- trapFocus
276
- side="bottom"
339
+ </MenuTrigger>
340
+ <MenuContent
341
+ class="ridu-richtext-format-menu"
342
+ sideOffset={0}
277
343
  align="start"
278
- onmousedown={preserveSelection}
279
- onOpenAutoFocus={focusLinkInput}
280
344
  onCloseAutoFocus={restoreEditorFocus}
281
345
  onInteractOutside={preserveOutsideFocus}
282
346
  >
283
- <label
284
- class="font-mono text-[10px] tracking-[0.12em] text-foreground-faint uppercase"
285
- for="ridu-richtext-link-input"
347
+ <MenuItem
348
+ class="ridu-richtext-format-option"
349
+ data-active={blockType === "paragraph" || undefined}
350
+ onSelect={() => changeBlock("paragraph")}
286
351
  >
287
- {i18n.t("plugin.richtext:editor.linkURL")}
288
- </label>
289
- <Input
290
- id="ridu-richtext-link-input"
291
- class="h-[2.2rem] px-[0.65rem] font-mono text-xs"
292
- bind:ref={linkInput}
293
- bind:value={linkDraft}
294
- aria-label={i18n.t("plugin.richtext:editor.linkURL")}
295
- onkeydown={handleLinkKeydown}
296
- />
297
- <div class="flex justify-end gap-[0.35rem]">
298
- {#if link}<Button
299
- variant="ghost"
300
- size="sm"
301
- class="me-auto h-7 px-[0.55rem] text-[11px] text-destructive hover:text-foreground"
302
- onclick={removeLink}
303
- >
304
- {i18n.t("plugin.richtext:editor.remove")}
305
- </Button>{/if}
306
- <Button
307
- variant="ghost"
308
- size="sm"
309
- class="h-7 px-[0.55rem] text-[11px]"
310
- onclick={closeLinkEditor}
352
+ <ToolbarIcon name="paragraph" />
353
+ {i18n.t("plugin.richtext:option.paragraph.label")}
354
+ </MenuItem>
355
+ {#each [1, 2, 3, 4, 5, 6] as level (level)}
356
+ <MenuItem
357
+ class="ridu-richtext-format-option"
358
+ data-active={blockType === `h${level}` || undefined}
359
+ onSelect={() => changeBlock(`h${level}`)}
311
360
  >
312
- {i18n.t("plugin.richtext:editor.cancel")}
313
- </Button>
314
- <Button size="sm" class="h-7 px-[0.55rem] text-[11px]" onclick={applyLink}>
315
- {i18n.t("plugin.richtext:editor.apply")}
316
- </Button>
317
- </div>
318
- </PopoverContent>
319
- </PopoverRoot>
361
+ <ToolbarIcon name={`h${level}` as RichTextIconName} />
362
+ {i18n.t("plugin.richtext:editor.heading", { level })}
363
+ </MenuItem>
364
+ {/each}
365
+ {#if hasRichTextFeature(config, "lists")}
366
+ <MenuItem
367
+ class="ridu-richtext-format-option"
368
+ data-active={blockType === "ordered" || undefined}
369
+ onSelect={() => changeBlock("ordered")}
370
+ >
371
+ <ToolbarIcon name="ordered" />
372
+ {i18n.t("plugin.richtext:option.numberedList.label")}
373
+ </MenuItem>
374
+ <MenuItem
375
+ class="ridu-richtext-format-option"
376
+ data-active={blockType === "unordered" || undefined}
377
+ onSelect={() => changeBlock("unordered")}
378
+ >
379
+ <ToolbarIcon name="unordered" />
380
+ {i18n.t("plugin.richtext:option.bulletedList.label")}
381
+ </MenuItem>
382
+ <MenuItem
383
+ class="ridu-richtext-format-option"
384
+ data-active={blockType === "check" || undefined}
385
+ onSelect={() => changeBlock("check")}
386
+ >
387
+ <ToolbarIcon name="check" />
388
+ {i18n.t("plugin.richtext:option.checklist.label")}
389
+ </MenuItem>
390
+ {/if}
391
+ <MenuItem
392
+ class="ridu-richtext-format-option"
393
+ data-active={blockType === "quote" || undefined}
394
+ onSelect={() => changeBlock("quote")}
395
+ >
396
+ <ToolbarIcon name="quote" />
397
+ {i18n.t("plugin.richtext:option.quote.label")}
398
+ </MenuItem>
399
+ </MenuContent>
400
+ </MenuRoot>
401
+ <span class="ridu-richtext-toolbar-separator" aria-hidden="true"></span>
402
+ <MenuRoot bind:open={alignMenuOpen}>
403
+ <MenuTrigger>
404
+ {#snippet child({ props })}
405
+ <ToolbarButton
406
+ {...props}
407
+ class="ridu-richtext-toolbar-button ridu-richtext-toolbar-menu"
408
+ aria-label={i18n.t("plugin.richtext:editor.alignment")}
409
+ >
410
+ <ToolbarIcon
411
+ name={alignment === "center" || alignment === "right" || alignment === "justify"
412
+ ? alignment
413
+ : "left"}
414
+ /><ChevronDownIcon width="10" />
415
+ </ToolbarButton>
416
+ {/snippet}
417
+ </MenuTrigger>
418
+ <MenuContent
419
+ class="ridu-richtext-format-menu"
420
+ sideOffset={0}
421
+ align="start"
422
+ onCloseAutoFocus={restoreEditorFocus}
423
+ onInteractOutside={preserveOutsideFocus}
424
+ >
425
+ {#each ["left", "center", "right", "justify"] as format (format)}
426
+ <MenuItem
427
+ class="ridu-richtext-format-option"
428
+ data-active={alignment === format || undefined}
429
+ onSelect={() => alignText(format as ElementFormatType)}
430
+ >
431
+ <ToolbarIcon name={format as RichTextIconName} />
432
+ {i18n.t(`plugin.richtext:editor.align.${format}`)}
433
+ </MenuItem>
434
+ {/each}
435
+ </MenuContent>
436
+ </MenuRoot>
437
+ <ToolbarButton
438
+ class={toolbarButtonClass}
439
+ disabled={!canOutdent}
440
+ aria-label={i18n.t("plugin.richtext:editor.outdent")}
441
+ onclick={() => editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined)}
442
+ >
443
+ <ToolbarIcon name="outdent" />
444
+ </ToolbarButton>
445
+ <ToolbarButton
446
+ class={toolbarButtonClass}
447
+ aria-label={i18n.t("plugin.richtext:editor.indent")}
448
+ onclick={() => editor.dispatchCommand(INDENT_CONTENT_COMMAND, undefined)}
449
+ >
450
+ <ToolbarIcon name="indent" />
451
+ </ToolbarButton>
452
+ <span class="ridu-richtext-toolbar-separator" aria-hidden="true"></span>
453
+
454
+ {#each formatButtons.filter((button) => button.format !== "code" || hasRichTextFeature(config, "code")) as button (button.format)}
455
+ <ToolbarButton
456
+ class={toolbarButtonClass}
457
+ type="button"
458
+ active={formats.has(button.format)}
459
+ aria-label={i18n.t(button.labelKey)}
460
+ aria-pressed={formats.has(button.format)}
461
+ title={i18n.t(button.labelKey)}
462
+ onclick={() => formatText(button.format)}
463
+ >
464
+ <ToolbarIcon name={button.icon} />
465
+ </ToolbarButton>
466
+ {/each}
467
+ <span class="ridu-richtext-toolbar-separator" aria-hidden="true"></span>
468
+ {#if hasRichTextFeature(config, "links")}
469
+ <ToolbarButton
470
+ class={toolbarButtonClass}
471
+ active={link}
472
+ aria-label={i18n.t(
473
+ link ? "plugin.richtext:editor.removeLink" : "plugin.richtext:editor.addLink"
474
+ )}
475
+ aria-pressed={link}
476
+ aria-haspopup={link ? undefined : "dialog"}
477
+ onclick={toggleLink}
478
+ >
479
+ <ToolbarIcon name="link" />
480
+ </ToolbarButton>
481
+ {/if}
320
482
  </ToolbarRoot>
321
483
  </Portal>
322
484
  {/if}