@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
@@ -0,0 +1,528 @@
1
+ <script lang="ts">
2
+ import { Portal, useLexicalComposerContext, useLexicalEditable } from "@hvniel/lexical-svelte";
3
+ import { $createLinkNode, $isLinkNode, $toggleLink, type LinkNode } from "@lexical/link";
4
+ import { mergeRegister } from "@lexical/utils";
5
+ import type { Attachment } from "svelte/attachments";
6
+ import { getAdminI18n } from "@riducms/plugin";
7
+ import {
8
+ Button,
9
+ Checkbox,
10
+ Dialog,
11
+ DialogClose,
12
+ DialogContent,
13
+ DialogTitle,
14
+ FieldFrame,
15
+ Input,
16
+ fieldControlARIA,
17
+ } from "@riducms/ui";
18
+ import EditIcon from "~icons/lucide/pencil";
19
+ import ExternalLinkIcon from "~icons/lucide/external-link";
20
+ import TrashIcon from "~icons/lucide/trash-2";
21
+ import XIcon from "~icons/lucide/x";
22
+ import {
23
+ $addUpdateTag,
24
+ $createTextNode,
25
+ $getNodeByKey,
26
+ $getSelection,
27
+ $isRangeSelection,
28
+ $setSelection,
29
+ COMMAND_PRIORITY_LOW,
30
+ HISTORY_PUSH_TAG,
31
+ KEY_DOWN_COMMAND,
32
+ KEY_TAB_COMMAND,
33
+ SELECTION_CHANGE_COMMAND,
34
+ SKIP_DOM_SELECTION_TAG,
35
+ type LexicalNode,
36
+ type NodeKey,
37
+ type RangeSelection,
38
+ } from "lexical";
39
+
40
+ import { normalizeLinkURL } from "@plugin-richtext/link/link-url";
41
+ import { OPEN_LINK_EDITOR_COMMAND } from "@plugin-richtext/menu/rich-text-commands";
42
+ import "@plugin-richtext/link/rich-text-link.scss";
43
+
44
+ interface LinkEditorSession {
45
+ linkKey: NodeKey | undefined;
46
+ selection: RangeSelection;
47
+ text: string;
48
+ }
49
+
50
+ interface LinkPreview {
51
+ key: NodeKey;
52
+ url: string;
53
+ safeURL: string | undefined;
54
+ newTab: boolean;
55
+ }
56
+
57
+ const editor = useLexicalComposerContext()[0];
58
+ const isEditable = useLexicalEditable();
59
+ const i18n = getAdminI18n();
60
+ const instanceID = $props.id();
61
+ const textID = `${instanceID}-text`;
62
+ const urlID = `${instanceID}-url`;
63
+ const newTabID = `${instanceID}-new-tab`;
64
+
65
+ let session: LinkEditorSession | undefined;
66
+ let drawerOpen = $state(false);
67
+ let textDraft = $state("");
68
+ let urlDraft = $state("");
69
+ let newTabDraft = $state(false);
70
+ let textError = $state("");
71
+ let urlError = $state("");
72
+ let textInput: HTMLInputElement | null = null;
73
+ let preview = $state.raw<LinkPreview>();
74
+ let previewElement: HTMLElement | null = null;
75
+ let previewLeft = $state(12);
76
+ let previewTop = $state(12);
77
+ let focusRevision = 0;
78
+ let focusFrame: number | undefined;
79
+ const textARIA = $derived(fieldControlARIA(textID, false, textError !== ""));
80
+ const urlARIA = $derived(fieldControlARIA(urlID, false, urlError !== ""));
81
+
82
+ // @lexical-scope
83
+ function $nearestLink(node: LexicalNode): LinkNode | undefined {
84
+ if ($isLinkNode(node)) return node;
85
+ const parent = node.getParent();
86
+ return $isLinkNode(parent) ? parent : undefined;
87
+ }
88
+
89
+ // @lexical-scope
90
+ function $linkAtSelection(): LinkNode | undefined {
91
+ const selection = $getSelection();
92
+ if (!$isRangeSelection(selection)) return undefined;
93
+ const anchorLink = $nearestLink(selection.anchor.getNode());
94
+ if (selection.isCollapsed()) return anchorLink;
95
+ const focusLink = $nearestLink(selection.focus.getNode());
96
+ return anchorLink !== undefined && focusLink?.getKey() === anchorLink.getKey()
97
+ ? anchorLink
98
+ : undefined;
99
+ }
100
+
101
+ // @lexical-scope
102
+ function $beginLinkSession(linkKey?: NodeKey): boolean {
103
+ if (!editor.isEditable()) return false;
104
+ const selection = $getSelection();
105
+ if (!$isRangeSelection(selection)) return false;
106
+ const selectedLink =
107
+ linkKey === undefined ? $linkAtSelection() : $getNodeByKey<LinkNode>(linkKey);
108
+ const link = $isLinkNode(selectedLink) ? selectedLink : undefined;
109
+ const text = link?.getTextContent() ?? selection.getTextContent();
110
+ if (link === undefined && (selection.isCollapsed() || text.trim() === "")) return false;
111
+
112
+ session = {
113
+ linkKey: link?.getKey(),
114
+ selection: selection.clone(),
115
+ text,
116
+ };
117
+ textDraft = text;
118
+ urlDraft = link?.getURL() ?? "";
119
+ newTabDraft = link?.getTarget() === "_blank";
120
+ textError = "";
121
+ urlError = "";
122
+ preview = undefined;
123
+ focusRevision += 1;
124
+ drawerOpen = true;
125
+ return true;
126
+ }
127
+
128
+ function beginLinkSession(linkKey?: NodeKey): boolean {
129
+ let opened = false;
130
+ editor.getEditorState().read(() => {
131
+ opened = $beginLinkSession(linkKey);
132
+ });
133
+ return opened;
134
+ }
135
+
136
+ function closeLinkEditor(restoreFocus = true) {
137
+ if (focusFrame !== undefined) cancelAnimationFrame(focusFrame);
138
+ focusFrame = undefined;
139
+ const revision = ++focusRevision;
140
+ drawerOpen = false;
141
+ session = undefined;
142
+ textError = "";
143
+ urlError = "";
144
+ if (restoreFocus)
145
+ queueMicrotask(() => {
146
+ if (!drawerOpen && revision === focusRevision) editor.focus();
147
+ });
148
+ }
149
+
150
+ function handleDrawerOpenChange(open: boolean) {
151
+ if (open) drawerOpen = true;
152
+ else closeLinkEditor();
153
+ }
154
+
155
+ function focusTextInput(event: Event) {
156
+ event.preventDefault();
157
+ const revision = focusRevision;
158
+ if (focusFrame !== undefined) cancelAnimationFrame(focusFrame);
159
+ focusFrame = requestAnimationFrame(() => {
160
+ focusFrame = undefined;
161
+ if (!drawerOpen || session === undefined || revision !== focusRevision) return;
162
+ textInput?.focus();
163
+ textInput?.select();
164
+ });
165
+ }
166
+
167
+ function setTextInput(node: HTMLInputElement | null) {
168
+ textInput = node;
169
+ }
170
+
171
+ function preventAutomaticFocusRestore(event: Event) {
172
+ event.preventDefault();
173
+ }
174
+
175
+ function validateDraft(): string | undefined {
176
+ textError = textDraft.trim() === "" ? i18n.t("plugin.richtext:link.textRequired") : "";
177
+ if (urlDraft.trim() === "") {
178
+ urlError = i18n.t("plugin.richtext:link.urlRequired");
179
+ return undefined;
180
+ }
181
+ const url = normalizeLinkURL(urlDraft);
182
+ urlError = url === undefined ? i18n.t("plugin.richtext:link.urlInvalid") : "";
183
+ return textError === "" ? url : undefined;
184
+ }
185
+
186
+ function saveLink() {
187
+ const active = session;
188
+ const url = validateDraft();
189
+ const text = textDraft;
190
+ if (active === undefined || url === undefined || text.trim() === "" || !editor.isEditable())
191
+ return;
192
+
193
+ editor.update(
194
+ () => {
195
+ $addUpdateTag(HISTORY_PUSH_TAG);
196
+ if (active.linkKey !== undefined) {
197
+ const node = $getNodeByKey(active.linkKey);
198
+ if (!$isLinkNode(node)) return;
199
+ node
200
+ .setURL(url)
201
+ .setTarget(newTabDraft ? "_blank" : null)
202
+ .setRel(newTabDraft ? "noopener noreferrer" : null);
203
+ if (node.getTextContent() !== text) {
204
+ const [firstText, ...remainingText] = node.getAllTextNodes();
205
+ if (firstText === undefined) {
206
+ const replacement = $createTextNode(text);
207
+ node.append(replacement);
208
+ replacement.selectEnd();
209
+ } else {
210
+ firstText.setTextContent(text);
211
+ for (const extraText of remainingText) extraText.remove();
212
+ firstText.selectEnd();
213
+ }
214
+ } else {
215
+ node.selectEnd();
216
+ }
217
+ return;
218
+ }
219
+
220
+ if (
221
+ $getNodeByKey(active.selection.anchor.key) === null ||
222
+ $getNodeByKey(active.selection.focus.key) === null
223
+ )
224
+ return;
225
+ $setSelection(active.selection.clone());
226
+ const selection = $getSelection();
227
+ if (!$isRangeSelection(selection)) return;
228
+ if (text === active.text) {
229
+ $toggleLink({
230
+ url,
231
+ target: newTabDraft ? "_blank" : null,
232
+ rel: newTabDraft ? "noopener noreferrer" : null,
233
+ });
234
+ return;
235
+ }
236
+ const textNode = $createTextNode(text)
237
+ .setFormat(selection.format)
238
+ .setStyle(selection.style);
239
+ const link = $createLinkNode(url, {
240
+ target: newTabDraft ? "_blank" : null,
241
+ rel: newTabDraft ? "noopener noreferrer" : null,
242
+ });
243
+ link.append(textNode);
244
+ selection.insertNodes([link]);
245
+ link.selectEnd();
246
+ },
247
+ { tag: HISTORY_PUSH_TAG, discrete: true }
248
+ );
249
+ closeLinkEditor();
250
+ }
251
+
252
+ function removePreviewLink() {
253
+ const selected = preview;
254
+ if (selected === undefined || !editor.isEditable()) return;
255
+ editor.update(
256
+ () => {
257
+ const link = $getNodeByKey(selected.key);
258
+ if (!$isLinkNode(link)) return;
259
+ $addUpdateTag(HISTORY_PUSH_TAG);
260
+ const parent = link.getParentOrThrow();
261
+ const children = link.getChildren();
262
+ parent.splice(link.getIndexWithinParent(), 0, children);
263
+ link.remove();
264
+ children.at(-1)?.selectEnd();
265
+ },
266
+ { tag: HISTORY_PUSH_TAG, discrete: true }
267
+ );
268
+ preview = undefined;
269
+ queueMicrotask(() => editor.focus());
270
+ }
271
+
272
+ function positionPreview() {
273
+ const selected = preview;
274
+ if (selected === undefined) return;
275
+ const link = editor.getElementByKey(selected.key);
276
+ if (link === null || !link.isConnected) {
277
+ preview = undefined;
278
+ return;
279
+ }
280
+ const rect = link.getBoundingClientRect();
281
+ const width = previewElement?.offsetWidth ?? 320;
282
+ const height = previewElement?.offsetHeight ?? 38;
283
+ previewLeft = Math.max(12, Math.min(window.innerWidth - width - 12, rect.left));
284
+ const preferredTop = rect.bottom + 8;
285
+ previewTop = Math.max(12, Math.min(window.innerHeight - height - 12, preferredTop));
286
+ }
287
+
288
+ const attachPreviewElement: Attachment<HTMLElement> = (element) => {
289
+ previewElement = element;
290
+ positionPreview();
291
+ return () => {
292
+ if (previewElement === element) previewElement = null;
293
+ };
294
+ };
295
+
296
+ function focusPreview(event: KeyboardEvent) {
297
+ if (event.shiftKey || preview === undefined || previewElement === null) return false;
298
+ const firstControl = previewElement.querySelector<HTMLElement>(
299
+ "a[href], button:not([disabled]), [tabindex]:not([tabindex='-1'])"
300
+ );
301
+ if (firstControl === null) return false;
302
+ event.preventDefault();
303
+ firstControl.focus();
304
+ return true;
305
+ }
306
+
307
+ // @lexical-scope
308
+ function $updatePreview() {
309
+ if (drawerOpen || !isEditable()) {
310
+ preview = undefined;
311
+ return;
312
+ }
313
+ const selection = $getSelection();
314
+ const link = $linkAtSelection();
315
+ if (!$isRangeSelection(selection) || !selection.isCollapsed() || link === undefined) {
316
+ preview = undefined;
317
+ return;
318
+ }
319
+ const root = editor.getRootElement();
320
+ const activeElement = document.activeElement;
321
+ if (
322
+ root === null ||
323
+ (!root.contains(activeElement) && !previewElement?.contains(activeElement))
324
+ ) {
325
+ preview = undefined;
326
+ return;
327
+ }
328
+ const url = link.getURL();
329
+ preview = {
330
+ key: link.getKey(),
331
+ url,
332
+ safeURL: normalizeLinkURL(url),
333
+ newTab: link.getTarget() === "_blank",
334
+ };
335
+ queueMicrotask(positionPreview);
336
+ }
337
+
338
+ function updatePreviewFromEditor() {
339
+ editor.getEditorState().read($updatePreview, { editor });
340
+ }
341
+
342
+ function preserveEditorSelection(event: MouseEvent) {
343
+ event.preventDefault();
344
+ }
345
+
346
+ $effect(() => {
347
+ const unregister = mergeRegister(
348
+ editor.registerCommand(
349
+ OPEN_LINK_EDITOR_COMMAND,
350
+ () => {
351
+ $addUpdateTag(SKIP_DOM_SELECTION_TAG);
352
+ return $beginLinkSession();
353
+ },
354
+ COMMAND_PRIORITY_LOW
355
+ ),
356
+ editor.registerCommand(
357
+ KEY_DOWN_COMMAND,
358
+ (event) => {
359
+ if (
360
+ event.key.toLowerCase() !== "k" ||
361
+ (!event.metaKey && !event.ctrlKey) ||
362
+ event.altKey ||
363
+ event.shiftKey
364
+ )
365
+ return false;
366
+ if (!$beginLinkSession()) return false;
367
+ $addUpdateTag(SKIP_DOM_SELECTION_TAG);
368
+ event.preventDefault();
369
+ event.stopPropagation();
370
+ return true;
371
+ },
372
+ COMMAND_PRIORITY_LOW
373
+ ),
374
+ editor.registerCommand(KEY_TAB_COMMAND, focusPreview, COMMAND_PRIORITY_LOW),
375
+ editor.registerUpdateListener(({ editorState }) => editorState.read($updatePreview)),
376
+ editor.registerCommand(
377
+ SELECTION_CHANGE_COMMAND,
378
+ () => {
379
+ $updatePreview();
380
+ return false;
381
+ },
382
+ COMMAND_PRIORITY_LOW
383
+ )
384
+ );
385
+ document.addEventListener("focusin", updatePreviewFromEditor);
386
+ document.addEventListener("scroll", positionPreview, true);
387
+ window.addEventListener("resize", positionPreview);
388
+ return () => {
389
+ unregister();
390
+ document.removeEventListener("focusin", updatePreviewFromEditor);
391
+ document.removeEventListener("scroll", positionPreview, true);
392
+ window.removeEventListener("resize", positionPreview);
393
+ if (focusFrame !== undefined) cancelAnimationFrame(focusFrame);
394
+ focusFrame = undefined;
395
+ focusRevision += 1;
396
+ session = undefined;
397
+ preview = undefined;
398
+ };
399
+ });
400
+ </script>
401
+
402
+ {#if preview}
403
+ <Portal>
404
+ <div
405
+ {@attach attachPreviewElement}
406
+ class="ridu-richtext-link-preview"
407
+ role="dialog"
408
+ tabindex="-1"
409
+ aria-label={i18n.t("plugin.richtext:link.preview")}
410
+ dir={i18n.direction}
411
+ style={`left: ${previewLeft}px; top: ${previewTop}px;`}
412
+ onmousedown={preserveEditorSelection}
413
+ >
414
+ {#if preview.safeURL}
415
+ <a
416
+ class="ridu-richtext-link-preview__url"
417
+ href={preview.safeURL}
418
+ target="_blank"
419
+ rel="noopener noreferrer"
420
+ title={preview.url}
421
+ >
422
+ {#if preview.newTab}<ExternalLinkIcon aria-hidden="true" />{/if}
423
+ <span>{preview.url}</span>
424
+ </a>
425
+ {:else}
426
+ <span class="ridu-richtext-link-preview__url" title={preview.url}>
427
+ {#if preview.newTab}<ExternalLinkIcon aria-hidden="true" />{/if}
428
+ <span>{preview.url}</span>
429
+ </span>
430
+ {/if}
431
+ <div class="ridu-richtext-link-preview__actions">
432
+ <Button
433
+ variant="ghost"
434
+ size="icon-sm"
435
+ aria-label={i18n.t("plugin.richtext:editor.editLink")}
436
+ tooltip={i18n.t("plugin.richtext:editor.editLink")}
437
+ onclick={() => beginLinkSession(preview?.key)}
438
+ >
439
+ <EditIcon aria-hidden="true" />
440
+ </Button>
441
+ <Button
442
+ variant="ghost"
443
+ size="icon-sm"
444
+ aria-label={i18n.t("plugin.richtext:editor.remove")}
445
+ tooltip={i18n.t("plugin.richtext:editor.remove")}
446
+ onclick={removePreviewLink}
447
+ >
448
+ <TrashIcon aria-hidden="true" />
449
+ </Button>
450
+ </div>
451
+ </div>
452
+ </Portal>
453
+ {/if}
454
+
455
+ <Dialog bind:open={() => drawerOpen, handleDrawerOpenChange}>
456
+ <DialogContent
457
+ variant="drawer"
458
+ class="ridu-richtext-link-drawer"
459
+ dir={i18n.direction}
460
+ aria-describedby={undefined}
461
+ onOpenAutoFocus={focusTextInput}
462
+ onCloseAutoFocus={preventAutomaticFocusRestore}
463
+ >
464
+ <header class="ridu-richtext-link-drawer__header">
465
+ <DialogTitle class="ridu-richtext-link-drawer__title">
466
+ {i18n.t("plugin.richtext:editor.editLink")}
467
+ </DialogTitle>
468
+ <DialogClose
469
+ type="button"
470
+ class="ridu-richtext-link-drawer__close"
471
+ aria-label={i18n.t("plugin.richtext:editor.cancel")}
472
+ >
473
+ <XIcon aria-hidden="true" />
474
+ </DialogClose>
475
+ </header>
476
+
477
+ <form
478
+ class="ridu-richtext-link-form"
479
+ novalidate
480
+ onsubmit={(event) => {
481
+ event.preventDefault();
482
+ saveLink();
483
+ }}
484
+ >
485
+ <FieldFrame
486
+ controlID={textID}
487
+ label={i18n.t("plugin.richtext:link.textLabel")}
488
+ required
489
+ errors={textError === "" ? [] : [textError]}
490
+ >
491
+ <Input
492
+ id={textID}
493
+ bind:ref={() => textInput, setTextInput}
494
+ bind:value={textDraft}
495
+ required
496
+ {...textARIA}
497
+ oninput={() => (textError = "")}
498
+ />
499
+ </FieldFrame>
500
+
501
+ <FieldFrame
502
+ controlID={urlID}
503
+ label={i18n.t("plugin.richtext:editor.linkURL")}
504
+ required
505
+ errors={urlError === "" ? [] : [urlError]}
506
+ >
507
+ <Input
508
+ id={urlID}
509
+ bind:value={urlDraft}
510
+ type="url"
511
+ required
512
+ spellcheck="false"
513
+ {...urlARIA}
514
+ oninput={() => (urlError = "")}
515
+ />
516
+ </FieldFrame>
517
+
518
+ <div class="ridu-richtext-link-checkbox-field">
519
+ <Checkbox id={newTabID} bind:checked={newTabDraft} />
520
+ <label for={newTabID}>{i18n.t("plugin.richtext:link.newTab")}</label>
521
+ </div>
522
+
523
+ <Button type="submit" class="ridu-richtext-link-form__submit">
524
+ {i18n.t("plugin.richtext:link.saveChanges")}
525
+ </Button>
526
+ </form>
527
+ </DialogContent>
528
+ </Dialog>
@@ -0,0 +1,144 @@
1
+ @use "@riducms/ui/styles.scss" as ui;
2
+
3
+ @layer ridu.components {
4
+ .ridu-richtext-link-drawer__header {
5
+ display: flex;
6
+ align-items: center;
7
+ gap: 20px;
8
+ padding: 40px var(--admin-gutter) 20px;
9
+ }
10
+
11
+ .ridu-richtext-link-drawer__title {
12
+ margin: 0;
13
+ min-width: 0;
14
+ font-size: 26px;
15
+ font-weight: 500;
16
+ line-height: 32px;
17
+ overflow-wrap: anywhere;
18
+ }
19
+
20
+ .ridu-richtext-link-drawer__close {
21
+ display: grid;
22
+ place-items: center;
23
+ width: 40px;
24
+ height: 40px;
25
+ margin-inline-start: auto;
26
+ border: 0;
27
+ padding: 0;
28
+ background: none;
29
+ color: inherit;
30
+ cursor: pointer;
31
+
32
+ &:focus-visible {
33
+ @include ui.focus-outline;
34
+ }
35
+
36
+ svg {
37
+ width: 24px;
38
+ height: 24px;
39
+ stroke-width: 1px;
40
+ }
41
+ }
42
+
43
+ .ridu-richtext-link-form {
44
+ display: grid;
45
+ gap: 24px;
46
+ width: 100%;
47
+ min-width: 0;
48
+ margin: 0;
49
+ padding: 30px var(--admin-gutter) 60px;
50
+ }
51
+
52
+ .ridu-richtext-link-checkbox-field {
53
+ display: flex;
54
+ align-items: center;
55
+ gap: 8px;
56
+ min-height: 24px;
57
+ width: fit-content;
58
+ color: var(--foreground);
59
+ font: inherit;
60
+ cursor: pointer;
61
+
62
+ label {
63
+ cursor: pointer;
64
+ }
65
+ }
66
+
67
+ .ridu-richtext-link-form__submit {
68
+ width: 100%;
69
+ }
70
+
71
+ .ridu-richtext-link-preview {
72
+ position: fixed;
73
+ z-index: 49;
74
+ display: flex;
75
+ align-items: center;
76
+ inline-size: max-content;
77
+ max-inline-size: calc(100vw - 24px);
78
+ min-block-size: 38px;
79
+ padding-block: 4px;
80
+ padding-inline: 12px 4px;
81
+ border: 0;
82
+ border-radius: var(--control-radius);
83
+ background: var(--control-surface);
84
+ color: var(--foreground);
85
+ box-shadow: var(--richtext-popup-shadow);
86
+ font-family: var(--font-sans);
87
+ font-size: var(--font-size-body);
88
+ line-height: var(--line-height-body);
89
+ }
90
+
91
+ .ridu-richtext-link-preview__url {
92
+ display: flex;
93
+ align-items: center;
94
+ min-width: 0;
95
+ flex: 1;
96
+ color: var(--status-success-foreground);
97
+ font: inherit;
98
+ white-space: nowrap;
99
+ text-decoration: underline;
100
+ text-underline-offset: 2px;
101
+
102
+ &[href]:hover {
103
+ color: var(--success);
104
+ }
105
+
106
+ > svg {
107
+ flex: 0 0 auto;
108
+ inline-size: 14px;
109
+ block-size: 14px;
110
+ margin-inline-end: 5px;
111
+ }
112
+
113
+ > span {
114
+ min-width: 0;
115
+ overflow: hidden;
116
+ text-overflow: ellipsis;
117
+ }
118
+ }
119
+
120
+ .ridu-richtext-link-preview__actions {
121
+ display: flex;
122
+ align-items: center;
123
+ gap: 0;
124
+ flex-shrink: 0;
125
+
126
+ .ridu-button {
127
+ inline-size: 30px;
128
+ block-size: 30px;
129
+ border: 0;
130
+ border-radius: var(--control-radius);
131
+ }
132
+
133
+ svg {
134
+ width: 14px;
135
+ height: 14px;
136
+ }
137
+ }
138
+
139
+ @media (max-width: ui.$small) {
140
+ .ridu-richtext-link-drawer__header {
141
+ padding-block-start: 24px;
142
+ }
143
+ }
144
+ }
@@ -0,0 +1,20 @@
1
+ import { LinkNode } from "@lexical/link";
2
+ import type { LexicalEditor } from "lexical";
3
+
4
+ import { normalizeLinkURL } from "@plugin-richtext/link/link-url";
5
+
6
+ /** Prevent imported HTML from persisting links outside Ridu's portable URL contract. */
7
+ export function registerSafeLinkTransform(editor: LexicalEditor): () => void {
8
+ return editor.registerNodeTransform(LinkNode, (link) => {
9
+ if (normalizeLinkURL(link.getURL()) !== undefined) return;
10
+
11
+ const parent = link.getParent();
12
+ if (parent === null) {
13
+ link.remove();
14
+ return;
15
+ }
16
+ const children = link.getChildren();
17
+ parent.splice(link.getIndexWithinParent(), 0, children);
18
+ link.remove();
19
+ });
20
+ }
@@ -0,0 +1,14 @@
1
+ const explicitScheme = /^[a-z][a-z\d+.-]*:/iu;
2
+ const allowedScheme = /^(?:https?|mailto|tel):/iu;
3
+
4
+ /** Return the trimmed URL when every Ridu rich-text renderer can expose it safely. */
5
+ export function renderableRichTextURL(value: string): string | undefined {
6
+ const url = value.trim();
7
+ if (
8
+ /[\u0000-\u001f\u007f]/u.test(value) ||
9
+ url.startsWith("//") ||
10
+ (explicitScheme.test(url) && !allowedScheme.test(url))
11
+ )
12
+ return undefined;
13
+ return url;
14
+ }