@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
@@ -11,11 +11,10 @@
11
11
  TooltipRoot,
12
12
  TooltipTrigger,
13
13
  } from "@riducms/ui";
14
- import { DraggableBlockPlugin, useLexicalComposerContext } from "@hvniel/lexical-svelte";
14
+ import { Portal, useLexicalComposerContext } from "@hvniel/lexical-svelte";
15
15
  import { mergeRegister } from "@lexical/utils";
16
16
  import {
17
17
  $createParagraphNode,
18
- $createTextNode,
19
18
  $getNearestNodeFromDOMNode,
20
19
  $getNodeByKey,
21
20
  $getRoot,
@@ -25,6 +24,7 @@
25
24
  COMMAND_PRIORITY_LOW,
26
25
  KEY_ARROW_DOWN_COMMAND,
27
26
  KEY_ARROW_UP_COMMAND,
27
+ IS_FIREFOX,
28
28
  type LexicalNode,
29
29
  type NodeKey,
30
30
  } from "lexical";
@@ -46,6 +46,13 @@
46
46
  targetNodeKey: NodeKey;
47
47
  };
48
48
 
49
+ type DropTarget = {
50
+ element: HTMLElement;
51
+ before: boolean;
52
+ empty: boolean;
53
+ rect: DOMRect;
54
+ };
55
+
49
56
  let {
50
57
  anchorElement,
51
58
  authoring,
@@ -60,8 +67,15 @@
60
67
  let draggableElement = $state.raw<HTMLElement | null>(null);
61
68
  let pickerState = $state.raw<PickerState | null>(null);
62
69
  let pickerOpen = $state(false);
70
+ const pickerID = $props.id();
63
71
  let query = $state("");
64
72
  let menuElement = $state.raw<HTMLElement | null>(null);
73
+ let targetLineElement: HTMLElement | null = null;
74
+ let highlightElement: HTMLElement | null = null;
75
+ let highlightAnimation: Animation | undefined;
76
+ let highlightFrame: number | undefined;
77
+ let draggedNodeKey: NodeKey | null = null;
78
+ let focusFrame: number | undefined;
65
79
  let movementAnnouncement = $state("");
66
80
  let restorePickerFocus = true;
67
81
  // The mounted field's authoring surface and feature set are stable.
@@ -74,11 +88,276 @@
74
88
  richTextBlockTypes(getRichTextField().field)
75
89
  );
76
90
  const options = $derived(filterRichTextOptions(baseOptions, query, i18n.language));
91
+ const dragDataFormat = "application/x-ridu-richtext-block";
77
92
 
78
93
  function isOnToolbar(element: HTMLElement) {
79
94
  return element.closest(".ridu-richtext-block-toolbar") !== null;
80
95
  }
81
96
 
97
+ function hideTargetLine() {
98
+ if (targetLineElement === null) return;
99
+ targetLineElement.style.opacity = "0";
100
+ }
101
+
102
+ function handleDragStart(event: DragEvent) {
103
+ if (draggableElement === null || event.dataTransfer === null) {
104
+ event.preventDefault();
105
+ return;
106
+ }
107
+
108
+ editor.read(() => {
109
+ draggedNodeKey = $getNearestNodeFromDOMNode(draggableElement!)?.getKey() ?? null;
110
+ });
111
+ if (draggedNodeKey === null) {
112
+ event.preventDefault();
113
+ return;
114
+ }
115
+ event.dataTransfer.effectAllowed = "move";
116
+ event.dataTransfer.setData(dragDataFormat, draggedNodeKey);
117
+ event.dataTransfer.setDragImage(draggableElement, 0, 0);
118
+ if (IS_FIREFOX) editor.getRootElement()?.focus({ preventScroll: true });
119
+ }
120
+
121
+ function handleDragEnd() {
122
+ if (draggedNodeKey === null) return;
123
+ draggedNodeKey = null;
124
+ draggableElement = null;
125
+ hideTargetLine();
126
+ const view = anchorElement.ownerDocument.defaultView;
127
+ if (view === null) return;
128
+ if (focusFrame !== undefined) view.cancelAnimationFrame(focusFrame);
129
+ // Native drag completion can move focus after the drop handler has run.
130
+ focusFrame = view.requestAnimationFrame(() => {
131
+ focusFrame = undefined;
132
+ editor.focus();
133
+ const root = editor.getRootElement();
134
+ if (root !== null && root.ownerDocument.activeElement !== root) {
135
+ root.focus({ preventScroll: true });
136
+ }
137
+ });
138
+ }
139
+
140
+ function withinEditorBuffer(
141
+ clientX: number,
142
+ clientY: number,
143
+ horizontalBuffer: number,
144
+ verticalBuffer: number
145
+ ) {
146
+ const rect = anchorElement.getBoundingClientRect();
147
+ return (
148
+ clientX >= rect.left - horizontalBuffer &&
149
+ clientX <= rect.right + horizontalBuffer &&
150
+ clientY >= rect.top - verticalBuffer &&
151
+ clientY <= rect.bottom + verticalBuffer
152
+ );
153
+ }
154
+
155
+ function isFromAnotherEditor(target: EventTarget | null) {
156
+ const element =
157
+ target instanceof Element ? target : target instanceof Node ? target.parentElement : null;
158
+ return element?.closest(".ridu-richtext-canvas") !== anchorElement;
159
+ }
160
+
161
+ function isOwnedDropSurface(target: EventTarget | null) {
162
+ return target instanceof Node && anchorElement.contains(target) && !isFromAnotherEditor(target);
163
+ }
164
+
165
+ function findNearestBlock(clientY: number) {
166
+ const keys = editor.read(() => $getRoot().getChildrenKeys());
167
+ const blocks = keys
168
+ .map((key) => editor.getElementByKey(key))
169
+ .filter((element): element is HTMLElement => element !== null);
170
+ if (blocks.length === 0) return null;
171
+
172
+ let low = 0;
173
+ let high = blocks.length;
174
+ while (low < high) {
175
+ const middle = (low + high) >>> 1;
176
+ const rect = blocks[middle].getBoundingClientRect();
177
+ if (rect.bottom < clientY) low = middle + 1;
178
+ else high = middle;
179
+ }
180
+
181
+ const next = blocks[Math.min(low, blocks.length - 1)];
182
+ const previous = blocks[Math.max(0, low - 1)];
183
+ const nextRect = next.getBoundingClientRect();
184
+ const previousRect = previous.getBoundingClientRect();
185
+ const nextDistance = Math.max(nextRect.top - clientY, clientY - nextRect.bottom, 0);
186
+ const previousDistance = Math.max(previousRect.top - clientY, clientY - previousRect.bottom, 0);
187
+ return previousDistance < nextDistance ? previous : next;
188
+ }
189
+
190
+ function findHoveredBlock(target: EventTarget | null, clientY: number) {
191
+ const root = editor.getRootElement();
192
+ if (root === null) return null;
193
+ if (target instanceof Element && root.contains(target)) {
194
+ let element: Element | null = target;
195
+ while (element !== null && element.parentElement !== root) element = element.parentElement;
196
+ if (element instanceof HTMLElement) {
197
+ const key = editor.read(() => {
198
+ const node = $getNearestNodeFromDOMNode(element!);
199
+ return node?.getParent()?.is($getRoot()) ? node.getKey() : null;
200
+ });
201
+ if (key !== null && editor.getElementByKey(key) === element) return element;
202
+ }
203
+ }
204
+
205
+ const block = findNearestBlock(clientY);
206
+ if (block === null) return null;
207
+ const rect = block.getBoundingClientRect();
208
+ const style = block.ownerDocument.defaultView?.getComputedStyle(block);
209
+ const marginTop = Number.parseFloat(style?.marginTop ?? "") || 0;
210
+ const marginBottom = Number.parseFloat(style?.marginBottom ?? "") || 0;
211
+ return clientY >= rect.top - marginTop && clientY <= rect.bottom + marginBottom ? block : null;
212
+ }
213
+
214
+ function findDropTarget(clientY: number) {
215
+ const element = findNearestBlock(clientY);
216
+ if (element === null) return null;
217
+ const rect = element.getBoundingClientRect();
218
+ const empty = editor.read(() => {
219
+ const node = $getNearestNodeFromDOMNode(element);
220
+ return $isParagraphNode(node) && node.isEmpty();
221
+ });
222
+ return {
223
+ element,
224
+ before: clientY < rect.top + rect.height / 2,
225
+ empty,
226
+ rect,
227
+ };
228
+ }
229
+
230
+ // @lexical-scope
231
+ function $dropWouldMove(source: LexicalNode, target: LexicalNode, drop: DropTarget) {
232
+ if (source.is(target)) return false;
233
+ if (drop.empty) return true;
234
+ return drop.before
235
+ ? !target.getPreviousSibling()?.is(source)
236
+ : !target.getNextSibling()?.is(source);
237
+ }
238
+
239
+ function positionTargetLine(target: DropTarget) {
240
+ if (targetLineElement === null) return;
241
+ const anchorRect = anchorElement.getBoundingClientRect();
242
+ const view = anchorElement.ownerDocument.defaultView;
243
+ if (view === null) return;
244
+ const margin = (element: Element | null, edge: "marginTop" | "marginBottom") =>
245
+ element === null ? 0 : Number.parseFloat(view.getComputedStyle(element)[edge]) || 0;
246
+ const gap = target.before
247
+ ? Math.max(
248
+ margin(target.element, "marginTop"),
249
+ margin(target.element.previousElementSibling, "marginBottom")
250
+ )
251
+ : Math.max(
252
+ margin(target.element, "marginBottom"),
253
+ margin(target.element.nextElementSibling, "marginTop")
254
+ );
255
+ const top = target.empty
256
+ ? target.rect.top + target.rect.height / 2
257
+ : target.before
258
+ ? target.rect.top - gap / 2
259
+ : target.rect.bottom + gap / 2;
260
+ targetLineElement.style.width = `${target.rect.width}px`;
261
+ targetLineElement.style.transform = `translate(${target.rect.left - anchorRect.left}px, ${top - anchorRect.top + anchorElement.scrollTop - 2}px)`;
262
+ targetLineElement.style.opacity = "0.8";
263
+ }
264
+
265
+ function highlightMovedBlock(key: NodeKey) {
266
+ const view = anchorElement.ownerDocument.defaultView;
267
+ if (view === null) return;
268
+ if (highlightFrame !== undefined) view.cancelAnimationFrame(highlightFrame);
269
+ highlightAnimation?.cancel();
270
+ // Measure after Lexical reconciles the moved node into its new DOM position.
271
+ highlightFrame = view.requestAnimationFrame(() => {
272
+ highlightFrame = undefined;
273
+ const block = editor.getElementByKey(key);
274
+ if (block === null || highlightElement === null) return;
275
+ const rect = block.getBoundingClientRect();
276
+ const anchorRect = anchorElement.getBoundingClientRect();
277
+ highlightElement.style.width = `${rect.width + 8}px`;
278
+ highlightElement.style.height = `${rect.height + 8}px`;
279
+ highlightElement.style.transform = `translate(${rect.left - anchorRect.left - 4}px, ${rect.top - anchorRect.top + anchorElement.scrollTop - 4}px)`;
280
+ const reducedMotion = view.matchMedia("(prefers-reduced-motion: reduce)").matches;
281
+ highlightAnimation = highlightElement.animate(
282
+ [{ opacity: 0.1 }, { opacity: 0.1, offset: reducedMotion ? 1 : 2 / 3 }, { opacity: 0 }],
283
+ { duration: reducedMotion ? 1000 : 1500 }
284
+ );
285
+ });
286
+ }
287
+
288
+ function positionMenu(element: HTMLElement) {
289
+ if (menuElement === null) return;
290
+ const blockRect = element.getBoundingClientRect();
291
+ const anchorRect = anchorElement.getBoundingClientRect();
292
+ const lineHeight = Number.parseFloat(
293
+ element.ownerDocument.defaultView?.getComputedStyle(element).lineHeight ?? ""
294
+ );
295
+ const height = Number.isFinite(lineHeight) ? lineHeight : blockRect.height;
296
+ const top = blockRect.top + (height - menuElement.getBoundingClientRect().height) / 2;
297
+ menuElement.style.transform = `translate(4px, ${top - anchorRect.top + anchorElement.scrollTop}px)`;
298
+ }
299
+
300
+ function updateDropIndicator(event: DragEvent) {
301
+ if (draggedNodeKey === null) return false;
302
+ if (!isOwnedDropSurface(event.target)) {
303
+ hideTargetLine();
304
+ return false;
305
+ }
306
+ const target = findDropTarget(event.clientY);
307
+ if (target === null) return false;
308
+ let canMove = false;
309
+ editor.read(() => {
310
+ const source = $getNodeByKey(draggedNodeKey!);
311
+ const destination = $getNearestNodeFromDOMNode(target.element);
312
+ canMove =
313
+ source !== null && destination !== null && $dropWouldMove(source, destination, target);
314
+ });
315
+ if (canMove) positionTargetLine(target);
316
+ else hideTargetLine();
317
+ event.preventDefault();
318
+ event.stopPropagation();
319
+ return true;
320
+ }
321
+
322
+ function handleDragLeave(event: DragEvent) {
323
+ if (draggedNodeKey === null) return;
324
+ const rect = anchorElement.getBoundingClientRect();
325
+ if (
326
+ event.clientX < rect.left ||
327
+ event.clientX > rect.right ||
328
+ event.clientY < rect.top ||
329
+ event.clientY > rect.bottom
330
+ )
331
+ hideTargetLine();
332
+ }
333
+
334
+ function handleDragEnter(event: DragEvent) {
335
+ if (draggedNodeKey !== null && !isOwnedDropSurface(event.target)) hideTargetLine();
336
+ }
337
+
338
+ function handleDocumentDrop(event: DragEvent) {
339
+ if (draggedNodeKey === null || !isOwnedDropSurface(event.target)) return;
340
+ const target = findDropTarget(event.clientY);
341
+ if (target === null) return;
342
+ const sourceKey = draggedNodeKey;
343
+ editor.update(() => {
344
+ const source = $getNodeByKey(sourceKey);
345
+ const destination = $getNearestNodeFromDOMNode(target.element);
346
+ if (source === null || destination === null || !$dropWouldMove(source, destination, target))
347
+ return;
348
+ if (target.empty) {
349
+ // replace() in Lexical 0.49 leaves the count stale when moving a sibling.
350
+ destination.insertBefore(source);
351
+ destination.remove();
352
+ } else if (target.before) destination.insertBefore(source);
353
+ else destination.insertAfter(source);
354
+ highlightMovedBlock(sourceKey);
355
+ });
356
+ event.preventDefault();
357
+ event.stopPropagation();
358
+ handleDragEnd();
359
+ }
360
+
82
361
  function closePicker() {
83
362
  pickerOpen = false;
84
363
  pickerState = null;
@@ -184,12 +463,13 @@
184
463
  editor.update(() => {
185
464
  const target = $getNodeByKey(state.targetNodeKey);
186
465
  if (target === null) return;
187
- const placeholder = $createParagraphNode();
188
- const text = $createTextNode("");
189
- placeholder.append(text);
190
- if (state.insertBefore) target.insertBefore(placeholder);
191
- else target.insertAfter(placeholder);
192
- text.select();
466
+ const placeholder =
467
+ $isParagraphNode(target) && target.isEmpty() ? target : $createParagraphNode();
468
+ if (placeholder !== target) {
469
+ if (state.insertBefore) target.insertBefore(placeholder);
470
+ else target.insertAfter(placeholder);
471
+ }
472
+ placeholder.select();
193
473
  option.select();
194
474
 
195
475
  if (option.preservePlaceholder) return;
@@ -208,12 +488,39 @@
208
488
  };
209
489
  };
210
490
 
491
+ const attachTargetLine: Attachment<HTMLElement> = (element) => {
492
+ targetLineElement = element;
493
+ return () => {
494
+ if (targetLineElement === element) targetLineElement = null;
495
+ };
496
+ };
497
+
498
+ const attachHighlight: Attachment<HTMLElement> = (element) => {
499
+ highlightElement = element;
500
+ return () => {
501
+ if (highlightElement === element) highlightElement = null;
502
+ };
503
+ };
504
+
211
505
  const focusSearchInput: Attachment<HTMLInputElement> = (element) => {
212
506
  element.focus();
213
507
  };
214
508
 
215
- $effect(() =>
216
- mergeRegister(
509
+ $effect(() => {
510
+ const ownerDocument = anchorElement.ownerDocument;
511
+ function handleMouseMove(event: MouseEvent) {
512
+ if (draggedNodeKey !== null || pickerOpen) return;
513
+ if (event.target instanceof HTMLElement && isOnToolbar(event.target)) return;
514
+ draggableElement =
515
+ !isFromAnotherEditor(event.target) &&
516
+ withinEditorBuffer(event.clientX, event.clientY, 50, 25)
517
+ ? findHoveredBlock(event.target, event.clientY)
518
+ : null;
519
+ }
520
+ function handleScroll() {
521
+ if (draggableElement !== null) positionMenu(draggableElement);
522
+ }
523
+ const unregisterCommands = mergeRegister(
217
524
  editor.registerCommand(
218
525
  KEY_ARROW_UP_COMMAND,
219
526
  (event) => {
@@ -238,69 +545,90 @@
238
545
  },
239
546
  COMMAND_PRIORITY_LOW
240
547
  )
241
- )
242
- );
548
+ );
549
+ ownerDocument.addEventListener("mousemove", handleMouseMove);
550
+ ownerDocument.addEventListener("dragover", updateDropIndicator, true);
551
+ ownerDocument.addEventListener("dragenter", handleDragEnter, true);
552
+ ownerDocument.addEventListener("dragleave", handleDragLeave, true);
553
+ ownerDocument.addEventListener("drop", handleDocumentDrop, true);
554
+ ownerDocument.addEventListener("scroll", handleScroll, true);
555
+ ownerDocument.defaultView?.addEventListener("resize", handleScroll);
556
+ return () => {
557
+ unregisterCommands();
558
+ ownerDocument.removeEventListener("mousemove", handleMouseMove);
559
+ ownerDocument.removeEventListener("dragover", updateDropIndicator, true);
560
+ ownerDocument.removeEventListener("dragenter", handleDragEnter, true);
561
+ ownerDocument.removeEventListener("dragleave", handleDragLeave, true);
562
+ ownerDocument.removeEventListener("drop", handleDocumentDrop, true);
563
+ ownerDocument.removeEventListener("scroll", handleScroll, true);
564
+ ownerDocument.defaultView?.removeEventListener("resize", handleScroll);
565
+ draggedNodeKey = null;
566
+ hideTargetLine();
567
+ if (focusFrame !== undefined) ownerDocument.defaultView?.cancelAnimationFrame(focusFrame);
568
+ if (highlightFrame !== undefined)
569
+ ownerDocument.defaultView?.cancelAnimationFrame(highlightFrame);
570
+ highlightAnimation?.cancel();
571
+ };
572
+ });
573
+
574
+ $effect(() => {
575
+ if (draggableElement !== null) positionMenu(draggableElement);
576
+ });
243
577
  </script>
244
578
 
245
579
  <PopoverRoot open={pickerOpen} onOpenChange={handlePickerOpenChange}>
246
- <DraggableBlockPlugin
247
- {anchorElement}
248
- isOnMenu={isOnToolbar}
249
- onElementChanged={(element) => (draggableElement = element)}
250
- >
251
- {#snippet menu(attach)}
252
- <ToolbarRoot
253
- 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"
254
- aria-label={i18n.t("plugin.richtext:editor.blockActions")}
255
- {@attach attach}
256
- {@attach attachMenuElement}
580
+ <Portal to={anchorElement}>
581
+ <ToolbarRoot
582
+ class={["ridu-richtext-block-toolbar", draggableElement !== null && "is-visible"]}
583
+ inert={draggableElement === null}
584
+ aria-hidden={draggableElement === null ? true : undefined}
585
+ aria-label={i18n.t("plugin.richtext:editor.blockActions")}
586
+ {@attach attachMenuElement}
587
+ >
588
+ <ToolbarButton
589
+ class="ridu-richtext-block-grip"
590
+ type="button"
591
+ draggable="true"
592
+ title={i18n.t("plugin.richtext:editor.dragToMoveBlock")}
593
+ aria-label={i18n.t("plugin.richtext:editor.dragToMoveBlock")}
594
+ ondragstart={handleDragStart}
595
+ ondragend={handleDragEnd}
257
596
  >
258
- <span
259
- 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]"
260
- title={i18n.t("plugin.richtext:editor.dragToMoveBlock")}
261
- aria-hidden="true"
262
- >
263
- <span></span>
264
- <span></span>
265
- <span></span>
266
- <span></span>
267
- <span></span>
268
- <span></span>
269
- </span>
270
- <TooltipRoot>
271
- <TooltipTrigger>
272
- {#snippet child({ props })}
273
- <ToolbarButton
274
- {...props}
275
- 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"
276
- type="button"
277
- aria-label={i18n.t("plugin.richtext:editor.addBlock")}
278
- aria-haspopup="dialog"
279
- aria-expanded={pickerOpen}
280
- aria-controls={pickerOpen ? "ridu-richtext-block-picker" : undefined}
281
- onclick={openPicker}
282
- >
283
- <span aria-hidden="true">+</span>
284
- </ToolbarButton>
285
- {/snippet}
286
- </TooltipTrigger>
287
- <TooltipContent>{i18n.t("plugin.richtext:editor.addBlock")}</TooltipContent>
288
- </TooltipRoot>
289
- </ToolbarRoot>
290
- {/snippet}
291
-
292
- {#snippet targetLine(attach)}
293
- <div
294
- class="pointer-events-none absolute top-0 left-0 z-2 h-0.5 rounded-full bg-primary opacity-0 will-change-transform"
295
- {@attach attach}
296
- ></div>
297
- {/snippet}
298
- </DraggableBlockPlugin>
597
+ <span aria-hidden="true"></span>
598
+ <span aria-hidden="true"></span>
599
+ <span aria-hidden="true"></span>
600
+ <span aria-hidden="true"></span>
601
+ <span aria-hidden="true"></span>
602
+ <span aria-hidden="true"></span>
603
+ </ToolbarButton>
604
+ <TooltipRoot>
605
+ <TooltipTrigger>
606
+ {#snippet child({ props })}
607
+ <ToolbarButton
608
+ {...props}
609
+ class="ridu-richtext-block-add"
610
+ type="button"
611
+ aria-label={i18n.t("plugin.richtext:editor.addBlock")}
612
+ aria-haspopup="dialog"
613
+ aria-expanded={pickerOpen}
614
+ aria-controls={pickerOpen ? pickerID : undefined}
615
+ onclick={openPicker}
616
+ >
617
+ <span aria-hidden="true">+</span>
618
+ </ToolbarButton>
619
+ {/snippet}
620
+ </TooltipTrigger>
621
+ <TooltipContent>{i18n.t("plugin.richtext:editor.addBlock")}</TooltipContent>
622
+ </TooltipRoot>
623
+ </ToolbarRoot>
624
+ <div class="ridu-richtext-drop-line" {@attach attachTargetLine}></div>
625
+ <div class="ridu-richtext-drop-highlight" aria-hidden="true" {@attach attachHighlight}></div>
626
+ </Portal>
299
627
 
300
628
  {#if pickerOpen && menuElement !== null}
301
629
  <PopoverContent
302
- id="ridu-richtext-block-picker"
303
- class="w-[min(19rem,calc(100vw-2rem))] animate-[ridu-richtext-menu-in_150ms_ease] motion-reduce:animate-none"
630
+ id={pickerID}
631
+ class="ridu-richtext-menu-popover"
304
632
  role="dialog"
305
633
  aria-label={i18n.t("plugin.richtext:editor.insertBlock")}
306
634
  customAnchor={menuElement}
@@ -315,7 +643,7 @@
315
643
  <CommandRoot label={i18n.t("plugin.richtext:editor.filterBlocks")} shouldFilter={false} loop>
316
644
  <CommandInput
317
645
  type="search"
318
- 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"
646
+ class="ridu-richtext-picker-search"
319
647
  aria-label={i18n.t("plugin.richtext:editor.filterBlocks")}
320
648
  placeholder={i18n.t("plugin.richtext:editor.filterBlocksPlaceholder")}
321
649
  bind:value={query}
@@ -323,9 +651,9 @@
323
651
  {@attach focusSearchInput}
324
652
  />
325
653
  {#if options.length > 0}
326
- <RichTextMenu {options} onSelect={selectOption} embedded command />
654
+ <RichTextMenu {options} onSelect={selectOption} command />
327
655
  {:else}
328
- <p class="p-4 text-xs text-foreground-faint">
656
+ <p class="ridu-richtext-menu-empty">
329
657
  {i18n.t("plugin.richtext:editor.noMatchingBlocks")}
330
658
  </p>
331
659
  {/if}
@@ -334,6 +662,11 @@
334
662
  {/if}
335
663
  </PopoverRoot>
336
664
 
337
- <p class="sr-only" aria-live="polite" aria-atomic="true" data-richtext-movement-status>
665
+ <p
666
+ class="ridu-richtext-announcement"
667
+ aria-live="polite"
668
+ aria-atomic="true"
669
+ data-richtext-movement-status
670
+ >
338
671
  {movementAnnouncement}
339
672
  </p>
@@ -13,13 +13,22 @@ export interface OpenUploadBrowserPayload {
13
13
  nodeKey?: NodeKey;
14
14
  }
15
15
 
16
- export type OpenRelationshipBrowserPayload = OpenUploadBrowserPayload;
16
+ export interface OpenRelationshipBrowserPayload {
17
+ collectionSlug?: string;
18
+ documentID?: string;
19
+ mode?: "edit" | "insert" | "replace";
20
+ nodeKey?: NodeKey;
21
+ }
17
22
 
18
23
  export interface UpdateUploadCaptionPayload {
19
24
  caption: string;
20
25
  nodeKey: NodeKey;
21
26
  }
22
27
 
28
+ export const OPEN_LINK_EDITOR_COMMAND: LexicalCommand<void> = createCommand(
29
+ "OPEN_LINK_EDITOR_COMMAND"
30
+ );
31
+
23
32
  export const OPEN_UPLOAD_BROWSER_COMMAND: LexicalCommand<OpenUploadBrowserPayload> = createCommand(
24
33
  "OPEN_UPLOAD_BROWSER_COMMAND"
25
34
  );
@@ -0,0 +1,92 @@
1
+ @use "@riducms/ui/styles.scss" as ui;
2
+
3
+ @layer ridu.components {
4
+ .ridu-richtext-menu-popover.ridu-popover-content {
5
+ inline-size: min(200px, calc(100vw - 16px));
6
+ padding: 0;
7
+ border: 0;
8
+ border-radius: 3px;
9
+ background: var(--control-surface);
10
+ box-shadow: var(--richtext-popup-shadow);
11
+ }
12
+
13
+ .ridu-richtext-menu {
14
+ max-block-size: min(300px, var(--bits-popover-content-available-height, 300px));
15
+ overflow-y: auto;
16
+ padding: 0;
17
+ color: var(--foreground);
18
+ font-family: var(--font-sans);
19
+ font-size: 13px;
20
+ line-height: 20px;
21
+ }
22
+
23
+ .ridu-richtext-menu__group {
24
+ padding-block-end: 8px;
25
+ }
26
+
27
+ .ridu-richtext-menu .ridu-richtext-menu__heading {
28
+ margin: 0;
29
+ padding: 0 10px;
30
+ color: var(--foreground-muted);
31
+ font-family: inherit;
32
+ font-size: 10px;
33
+ font-weight: 400;
34
+ letter-spacing: normal;
35
+ text-transform: none;
36
+ }
37
+
38
+ .ridu-richtext-menu__items {
39
+ display: grid;
40
+ gap: 0;
41
+ margin: 0;
42
+ padding: 0;
43
+ list-style: none;
44
+ }
45
+
46
+ .ridu-richtext-menu .ridu-richtext-menu__option {
47
+ box-sizing: border-box;
48
+ display: flex;
49
+ align-items: center;
50
+ gap: 6px;
51
+ min-block-size: 30px;
52
+ block-size: 30px;
53
+ padding: 0 12px;
54
+ border: 0;
55
+ border-radius: 0;
56
+ color: inherit;
57
+ font: inherit;
58
+ cursor: pointer;
59
+ outline: none;
60
+
61
+ &:hover,
62
+ &.is-selected,
63
+ &[data-selected] {
64
+ background: var(--control-surface-hover);
65
+ }
66
+
67
+ svg {
68
+ flex-shrink: 0;
69
+ color: var(--foreground-muted);
70
+ }
71
+ }
72
+
73
+ .ridu-richtext-menu__label {
74
+ overflow: hidden;
75
+ white-space: nowrap;
76
+ text-overflow: ellipsis;
77
+ }
78
+
79
+ .ridu-richtext-picker-search.ridu-command__input {
80
+ block-size: 36px;
81
+ padding: 0 10px;
82
+ border-block-end: 1px solid var(--control-border);
83
+ font-size: 13px;
84
+ }
85
+
86
+ .ridu-richtext-menu-empty {
87
+ padding: 12px;
88
+ margin: 0;
89
+ color: var(--foreground-muted);
90
+ font-size: 13px;
91
+ }
92
+ }