@eventuras/scribo 0.4.2

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.
@@ -0,0 +1,469 @@
1
+ /**
2
+ * Copyright (c) Meta Platforms, Inc. and affiliates.
3
+ *
4
+ * This source code is licensed under the MIT license found in the
5
+ * LICENSE file in the root directory of this source tree.
6
+ *
7
+ */
8
+ // https://github.com/facebook/lexical/blob/main/packages/lexical-playground/src/plugins/FloatingLinkEditorPlugin/index.tsx
9
+ import type {JSX} from 'react';
10
+
11
+ import './FloatingLinkEditorPlugin.css';
12
+
13
+ import {
14
+ $createLinkNode,
15
+ $isAutoLinkNode,
16
+ $isLinkNode,
17
+ TOGGLE_LINK_COMMAND,
18
+ } from '@lexical/link';
19
+ import {useLexicalComposerContext} from '@lexical/react/LexicalComposerContext';
20
+ import {$findMatchingParent, mergeRegister} from '@lexical/utils';
21
+ import {
22
+ $getSelection,
23
+ $isLineBreakNode,
24
+ $isNodeSelection,
25
+ $isRangeSelection,
26
+ BaseSelection,
27
+ CLICK_COMMAND,
28
+ COMMAND_PRIORITY_CRITICAL,
29
+ COMMAND_PRIORITY_HIGH,
30
+ COMMAND_PRIORITY_LOW,
31
+ getDOMSelection,
32
+ KEY_ESCAPE_COMMAND,
33
+ LexicalEditor,
34
+ SELECTION_CHANGE_COMMAND,
35
+ } from 'lexical';
36
+ import {Dispatch, useCallback, useEffect, useRef, useState} from 'react';
37
+ import * as React from 'react';
38
+ import {createPortal} from 'react-dom';
39
+
40
+ import {getSelectedNode} from '../utils/getSelectedNode';
41
+ import {setFloatingElemPositionForLinkEditor} from '../utils/setFloatingElemPositionForLinkEditor';
42
+ import {sanitizeUrl} from '../utils/url';
43
+
44
+ function preventDefault(
45
+ event: React.KeyboardEvent<HTMLInputElement> | React.MouseEvent<HTMLElement>,
46
+ ): void {
47
+ event.preventDefault();
48
+ }
49
+
50
+ function FloatingLinkEditor({
51
+ editor,
52
+ isLink,
53
+ setIsLink,
54
+ anchorElem,
55
+ isLinkEditMode,
56
+ setIsLinkEditMode,
57
+ }: {
58
+ editor: LexicalEditor;
59
+ isLink: boolean;
60
+ setIsLink: Dispatch<boolean>;
61
+ anchorElem: HTMLElement;
62
+ isLinkEditMode: boolean;
63
+ setIsLinkEditMode: Dispatch<boolean>;
64
+ }): JSX.Element {
65
+ const editorRef = useRef<HTMLDivElement | null>(null);
66
+ const inputRef = useRef<HTMLInputElement>(null);
67
+ const [linkUrl, setLinkUrl] = useState('');
68
+ const [editedLinkUrl, setEditedLinkUrl] = useState('https://');
69
+ const [lastSelection, setLastSelection] = useState<BaseSelection | null>(
70
+ null,
71
+ );
72
+
73
+ const $updateLinkEditor = useCallback(() => {
74
+ const selection = $getSelection();
75
+ if ($isRangeSelection(selection)) {
76
+ const node = getSelectedNode(selection);
77
+ const linkParent = $findMatchingParent(node, $isLinkNode);
78
+
79
+ if (linkParent) {
80
+ setLinkUrl(linkParent.getURL());
81
+ } else if ($isLinkNode(node)) {
82
+ setLinkUrl(node.getURL());
83
+ } else {
84
+ setLinkUrl('');
85
+ }
86
+ if (isLinkEditMode) {
87
+ setEditedLinkUrl(linkUrl);
88
+ }
89
+ } else if ($isNodeSelection(selection)) {
90
+ const nodes = selection.getNodes();
91
+ if (nodes.length > 0) {
92
+ const node = nodes[0];
93
+ const parent = node.getParent();
94
+ if ($isLinkNode(parent)) {
95
+ setLinkUrl(parent.getURL());
96
+ } else if ($isLinkNode(node)) {
97
+ setLinkUrl(node.getURL());
98
+ } else {
99
+ setLinkUrl('');
100
+ }
101
+ if (isLinkEditMode) {
102
+ setEditedLinkUrl(linkUrl);
103
+ }
104
+ }
105
+ }
106
+
107
+ const editorElem = editorRef.current;
108
+ const nativeSelection = getDOMSelection(editor._window);
109
+ const activeElement = document.activeElement;
110
+
111
+ if (editorElem === null) {
112
+ return;
113
+ }
114
+
115
+ const rootElement = editor.getRootElement();
116
+
117
+ if (selection !== null && rootElement !== null && editor.isEditable()) {
118
+ let domRect: DOMRect | undefined;
119
+
120
+ if ($isNodeSelection(selection)) {
121
+ const nodes = selection.getNodes();
122
+ if (nodes.length > 0) {
123
+ const element = editor.getElementByKey(nodes[0].getKey());
124
+ if (element) {
125
+ domRect = element.getBoundingClientRect();
126
+ }
127
+ }
128
+ } else if (
129
+ nativeSelection !== null &&
130
+ rootElement.contains(nativeSelection.anchorNode)
131
+ ) {
132
+ domRect =
133
+ nativeSelection.focusNode?.parentElement?.getBoundingClientRect();
134
+ }
135
+
136
+ if (domRect) {
137
+ domRect.y += 40;
138
+ setFloatingElemPositionForLinkEditor(domRect, editorElem, anchorElem);
139
+ }
140
+ setLastSelection(selection);
141
+ } else if (!activeElement || activeElement.className !== 'link-input') {
142
+ if (rootElement !== null) {
143
+ setFloatingElemPositionForLinkEditor(null, editorElem, anchorElem);
144
+ }
145
+ setLastSelection(null);
146
+ setIsLinkEditMode(false);
147
+ setLinkUrl('');
148
+ }
149
+
150
+ return true;
151
+ }, [anchorElem, editor, setIsLinkEditMode, isLinkEditMode, linkUrl]);
152
+
153
+ useEffect(() => {
154
+ const scrollerElem = anchorElem.parentElement;
155
+
156
+ const update = () => {
157
+ editor.getEditorState().read(() => {
158
+ $updateLinkEditor();
159
+ });
160
+ };
161
+
162
+ window.addEventListener('resize', update);
163
+
164
+ if (scrollerElem) {
165
+ scrollerElem.addEventListener('scroll', update);
166
+ }
167
+
168
+ return () => {
169
+ window.removeEventListener('resize', update);
170
+
171
+ if (scrollerElem) {
172
+ scrollerElem.removeEventListener('scroll', update);
173
+ }
174
+ };
175
+ }, [anchorElem.parentElement, editor, $updateLinkEditor]);
176
+
177
+ useEffect(() => {
178
+ return mergeRegister(
179
+ editor.registerUpdateListener(({editorState}) => {
180
+ editorState.read(() => {
181
+ $updateLinkEditor();
182
+ });
183
+ }),
184
+
185
+ editor.registerCommand(
186
+ SELECTION_CHANGE_COMMAND,
187
+ () => {
188
+ $updateLinkEditor();
189
+ return true;
190
+ },
191
+ COMMAND_PRIORITY_LOW,
192
+ ),
193
+ editor.registerCommand(
194
+ KEY_ESCAPE_COMMAND,
195
+ () => {
196
+ if (isLink) {
197
+ setIsLink(false);
198
+ return true;
199
+ }
200
+ return false;
201
+ },
202
+ COMMAND_PRIORITY_HIGH,
203
+ ),
204
+ );
205
+ }, [editor, $updateLinkEditor, setIsLink, isLink]);
206
+
207
+ useEffect(() => {
208
+ editor.getEditorState().read(() => {
209
+ $updateLinkEditor();
210
+ });
211
+ }, [editor, $updateLinkEditor]);
212
+
213
+ useEffect(() => {
214
+ if (isLinkEditMode && inputRef.current) {
215
+ inputRef.current.focus();
216
+ }
217
+ }, [isLinkEditMode, isLink]);
218
+
219
+ useEffect(() => {
220
+ const editorElement = editorRef.current;
221
+ if (editorElement === null) {
222
+ return;
223
+ }
224
+ const handleBlur = (event: FocusEvent) => {
225
+ if (!editorElement.contains(event.relatedTarget as Element) && isLink) {
226
+ setIsLink(false);
227
+ setIsLinkEditMode(false);
228
+ }
229
+ };
230
+ editorElement.addEventListener('focusout', handleBlur);
231
+ return () => {
232
+ editorElement.removeEventListener('focusout', handleBlur);
233
+ };
234
+ }, [editorRef, setIsLink, setIsLinkEditMode, isLink]);
235
+
236
+ const monitorInputInteraction = (
237
+ event: React.KeyboardEvent<HTMLInputElement>,
238
+ ) => {
239
+ if (event.key === 'Enter') {
240
+ handleLinkSubmission(event);
241
+ } else if (event.key === 'Escape') {
242
+ event.preventDefault();
243
+ setIsLinkEditMode(false);
244
+ }
245
+ };
246
+
247
+ const handleLinkSubmission = (
248
+ event:
249
+ | React.KeyboardEvent<HTMLInputElement>
250
+ | React.MouseEvent<HTMLElement>,
251
+ ) => {
252
+ event.preventDefault();
253
+ if (lastSelection !== null) {
254
+ if (linkUrl !== '') {
255
+ editor.update(() => {
256
+ editor.dispatchCommand(
257
+ TOGGLE_LINK_COMMAND,
258
+ sanitizeUrl(editedLinkUrl),
259
+ );
260
+ const selection = $getSelection();
261
+ if ($isRangeSelection(selection)) {
262
+ const parent = getSelectedNode(selection).getParent();
263
+ if ($isAutoLinkNode(parent)) {
264
+ const linkNode = $createLinkNode(parent.getURL(), {
265
+ rel: parent.__rel,
266
+ target: parent.__target,
267
+ title: parent.__title,
268
+ });
269
+ parent.replace(linkNode, true);
270
+ }
271
+ }
272
+ });
273
+ }
274
+ setEditedLinkUrl('https://');
275
+ setIsLinkEditMode(false);
276
+ }
277
+ };
278
+
279
+ return (
280
+ <div ref={editorRef} className="link-editor">
281
+ {!isLink ? null : isLinkEditMode ? (
282
+ <>
283
+ <input
284
+ ref={inputRef}
285
+ className="link-input"
286
+ value={editedLinkUrl}
287
+ onChange={(event) => {
288
+ setEditedLinkUrl(event.target.value);
289
+ }}
290
+ onKeyDown={(event) => {
291
+ monitorInputInteraction(event);
292
+ }}
293
+ />
294
+ <div>
295
+ <div
296
+ className="link-cancel"
297
+ role="button"
298
+ tabIndex={0}
299
+ onMouseDown={preventDefault}
300
+ onClick={() => {
301
+ setIsLinkEditMode(false);
302
+ }}
303
+ />
304
+
305
+ <div
306
+ className="link-confirm"
307
+ role="button"
308
+ tabIndex={0}
309
+ onMouseDown={preventDefault}
310
+ onClick={handleLinkSubmission}
311
+ />
312
+ </div>
313
+ </>
314
+ ) : (
315
+ <div className="link-view">
316
+ <a
317
+ href={sanitizeUrl(linkUrl)}
318
+ target="_blank"
319
+ rel="noopener noreferrer">
320
+ {linkUrl}
321
+ </a>
322
+ <div
323
+ className="link-edit"
324
+ role="button"
325
+ tabIndex={0}
326
+ onMouseDown={preventDefault}
327
+ onClick={(event) => {
328
+ event.preventDefault();
329
+ setEditedLinkUrl(linkUrl);
330
+ setIsLinkEditMode(true);
331
+ }}
332
+ />
333
+ <div
334
+ className="link-trash"
335
+ role="button"
336
+ tabIndex={0}
337
+ onMouseDown={preventDefault}
338
+ onClick={() => {
339
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
340
+ }}
341
+ />
342
+ </div>
343
+ )}
344
+ </div>
345
+ );
346
+ }
347
+
348
+ function useFloatingLinkEditorToolbar(
349
+ editor: LexicalEditor,
350
+ anchorElem: HTMLElement,
351
+ isLinkEditMode: boolean,
352
+ setIsLinkEditMode: Dispatch<boolean>,
353
+ ): JSX.Element | null {
354
+ const [activeEditor, setActiveEditor] = useState(editor);
355
+ const [isLink, setIsLink] = useState(false);
356
+
357
+ useEffect(() => {
358
+ function $updateToolbar() {
359
+ const selection = $getSelection();
360
+ if ($isRangeSelection(selection)) {
361
+ const focusNode = getSelectedNode(selection);
362
+ const focusLinkNode = $findMatchingParent(focusNode, $isLinkNode);
363
+ const focusAutoLinkNode = $findMatchingParent(
364
+ focusNode,
365
+ $isAutoLinkNode,
366
+ );
367
+ if (!(focusLinkNode || focusAutoLinkNode)) {
368
+ setIsLink(false);
369
+ return;
370
+ }
371
+ const badNode = selection
372
+ .getNodes()
373
+ .filter((node) => !$isLineBreakNode(node))
374
+ .find((node) => {
375
+ const linkNode = $findMatchingParent(node, $isLinkNode);
376
+ const autoLinkNode = $findMatchingParent(node, $isAutoLinkNode);
377
+ return (
378
+ (focusLinkNode && !focusLinkNode.is(linkNode)) ||
379
+ (linkNode && !linkNode.is(focusLinkNode)) ||
380
+ (focusAutoLinkNode && !focusAutoLinkNode.is(autoLinkNode)) ||
381
+ (autoLinkNode &&
382
+ (!autoLinkNode.is(focusAutoLinkNode) ||
383
+ autoLinkNode.getIsUnlinked()))
384
+ );
385
+ });
386
+ if (!badNode) {
387
+ setIsLink(true);
388
+ } else {
389
+ setIsLink(false);
390
+ }
391
+ } else if ($isNodeSelection(selection)) {
392
+ const nodes = selection.getNodes();
393
+ if (nodes.length === 0) {
394
+ setIsLink(false);
395
+ return;
396
+ }
397
+ const node = nodes[0];
398
+ const parent = node.getParent();
399
+ if ($isLinkNode(parent) || $isLinkNode(node)) {
400
+ setIsLink(true);
401
+ } else {
402
+ setIsLink(false);
403
+ }
404
+ }
405
+ }
406
+ return mergeRegister(
407
+ editor.registerUpdateListener(({editorState}) => {
408
+ editorState.read(() => {
409
+ $updateToolbar();
410
+ });
411
+ }),
412
+ editor.registerCommand(
413
+ SELECTION_CHANGE_COMMAND,
414
+ (_payload, newEditor) => {
415
+ $updateToolbar();
416
+ setActiveEditor(newEditor);
417
+ return false;
418
+ },
419
+ COMMAND_PRIORITY_CRITICAL,
420
+ ),
421
+ editor.registerCommand(
422
+ CLICK_COMMAND,
423
+ (payload) => {
424
+ const selection = $getSelection();
425
+ if ($isRangeSelection(selection)) {
426
+ const node = getSelectedNode(selection);
427
+ const linkNode = $findMatchingParent(node, $isLinkNode);
428
+ if ($isLinkNode(linkNode) && (payload.metaKey || payload.ctrlKey)) {
429
+ window.open(linkNode.getURL(), '_blank');
430
+ return true;
431
+ }
432
+ }
433
+ return false;
434
+ },
435
+ COMMAND_PRIORITY_LOW,
436
+ ),
437
+ );
438
+ }, [editor]);
439
+
440
+ return createPortal(
441
+ <FloatingLinkEditor
442
+ editor={activeEditor}
443
+ isLink={isLink}
444
+ anchorElem={anchorElem}
445
+ setIsLink={setIsLink}
446
+ isLinkEditMode={isLinkEditMode}
447
+ setIsLinkEditMode={setIsLinkEditMode}
448
+ />,
449
+ anchorElem,
450
+ );
451
+ }
452
+
453
+ export default function FloatingLinkEditorPlugin({
454
+ anchorElem = document.body,
455
+ isLinkEditMode,
456
+ setIsLinkEditMode,
457
+ }: {
458
+ anchorElem?: HTMLElement;
459
+ isLinkEditMode: boolean;
460
+ setIsLinkEditMode: Dispatch<boolean>;
461
+ }): JSX.Element | null {
462
+ const [editor] = useLexicalComposerContext();
463
+ return useFloatingLinkEditorToolbar(
464
+ editor,
465
+ anchorElem,
466
+ isLinkEditMode,
467
+ setIsLinkEditMode,
468
+ );
469
+ }