@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.
- package/README.md +7 -1
- package/package.json +7 -5
- package/src/block/rich-text-block-card.scss +139 -0
- package/src/block/rich-text-block-card.svelte +42 -21
- package/src/block/rich-text-block-clipboard.ts +1 -1
- package/src/block/rich-text-block-node.ts +1 -1
- package/src/block/rich-text-blocks-plugin.svelte +5 -2
- package/src/document-validation.ts +7 -5
- package/src/field/reference-node-placement.ts +49 -0
- package/src/field/rich-text-blocks.ts +1 -5
- package/src/field/rich-text-config.ts +1 -4
- package/src/field/rich-text-document.ts +1 -4
- package/src/field/rich-text-editor.svelte +105 -57
- package/src/field/rich-text-footer.svelte +7 -12
- package/src/field/rich-text-markdown.ts +62 -0
- package/src/link/link-url.ts +30 -0
- package/src/link/rich-text-link-plugin.svelte +528 -0
- package/src/link/rich-text-link.scss +144 -0
- package/src/link/safe-link-transform.ts +20 -0
- package/src/link-url-policy.ts +14 -0
- package/src/menu/rich-text-block-toolbar.svelte +403 -70
- package/src/menu/rich-text-commands.ts +10 -1
- package/src/menu/rich-text-menu.scss +92 -0
- package/src/menu/rich-text-menu.svelte +62 -69
- package/src/menu/rich-text-option-filter.ts +30 -0
- package/src/menu/rich-text-options.ts +81 -84
- package/src/menu/rich-text-slash-menu.svelte +15 -11
- package/src/messages.ts +72 -27
- package/src/relationship/rich-text-relationship-node.scss +98 -0
- package/src/relationship/rich-text-relationship-node.svelte +38 -32
- package/src/relationship/rich-text-relationship-plugin.svelte +24 -40
- package/src/render/rich-text.svelte +10 -5
- package/src/render.ts +3 -8
- package/src/styles/integration.scss +384 -0
- package/src/toolbar/PAYLOAD-LICENSE.md +22 -0
- package/src/toolbar/rich-text-floating-toolbar.svelte +331 -169
- package/src/toolbar/rich-text-toolbar.scss +80 -0
- package/src/toolbar/toolbar-icon.svelte +200 -30
- package/src/upload/rich-text-upload-node.scss +308 -0
- package/src/upload/rich-text-upload-node.svelte +96 -125
- package/src/upload/rich-text-upload-node.ts +1 -0
- package/src/upload/rich-text-upload-plugin.svelte +10 -33
- 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
|
+
}
|