@gcforms/editor 0.0.1

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,326 @@
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
+ import type { JSX } from "react";
9
+
10
+ import "./index.css";
11
+
12
+ import { $isAutoLinkNode, $isLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link";
13
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
14
+ import { $findMatchingParent, mergeRegister } from "@lexical/utils";
15
+ import {
16
+ $getSelection,
17
+ $isRangeSelection,
18
+ BLUR_COMMAND,
19
+ BaseSelection,
20
+ COMMAND_PRIORITY_CRITICAL,
21
+ COMMAND_PRIORITY_HIGH,
22
+ COMMAND_PRIORITY_LOW,
23
+ COMMAND_PRIORITY_NORMAL,
24
+ getDOMSelection,
25
+ KEY_ESCAPE_COMMAND,
26
+ KEY_TAB_COMMAND,
27
+ LexicalEditor,
28
+ NodeSelection,
29
+ RangeSelection,
30
+ SELECTION_CHANGE_COMMAND,
31
+ } from "lexical";
32
+ import { Dispatch, useCallback, useEffect, useRef, useState } from "react";
33
+ import * as React from "react";
34
+ import { createPortal } from "react-dom";
35
+
36
+ import { getSelectedNode } from "../../utils/getSelectedNode";
37
+
38
+ import { sanitizeUrl } from "../../utils/url";
39
+ import { setFloatingElemPositionForLinkEditor } from "../../utils/setFloatingElemPositionForLinkEditor";
40
+ import { EditIcon } from "../../icons/EditIcon";
41
+ import { useTranslation } from "@i18n/client"; // @TODO: inject i18n
42
+
43
+ function FloatingLinkEditor({
44
+ editor,
45
+ isLink,
46
+ setIsLink,
47
+ anchorElem,
48
+ isLinkEditMode,
49
+ setIsLinkEditMode,
50
+ }: {
51
+ editor: LexicalEditor;
52
+ isLink: boolean;
53
+ setIsLink: Dispatch<boolean>;
54
+ anchorElem: HTMLElement;
55
+ isLinkEditMode: boolean;
56
+ setIsLinkEditMode: Dispatch<boolean>;
57
+ }): JSX.Element {
58
+ const editorRef = useRef<HTMLDivElement | null>(null);
59
+ const inputRef = useRef<HTMLInputElement>(null);
60
+ const [linkUrl, setLinkUrl] = useState("");
61
+ const [lastSelection, setLastSelection] = useState<
62
+ RangeSelection | NodeSelection | BaseSelection | null
63
+ >(null);
64
+
65
+ const { t } = useTranslation();
66
+ const popped = useRef(false);
67
+
68
+ const updateLinkEditor = useCallback(() => {
69
+ const selection = $getSelection();
70
+ if ($isRangeSelection(selection)) {
71
+ const node = getSelectedNode(selection);
72
+ const parent = node.getParent();
73
+ if ($isLinkNode(parent)) {
74
+ setLinkUrl(parent.getURL());
75
+ } else if ($isLinkNode(node)) {
76
+ setLinkUrl(node.getURL());
77
+ } else {
78
+ setLinkUrl("");
79
+ }
80
+ }
81
+ const editorElem = editorRef.current;
82
+ const nativeSelection = getDOMSelection(editor._window);
83
+ const activeElement = document.activeElement;
84
+
85
+ if (editorElem === null) {
86
+ return;
87
+ }
88
+
89
+ const rootElement = editor.getRootElement();
90
+
91
+ if (
92
+ selection !== null &&
93
+ nativeSelection !== null &&
94
+ rootElement !== null &&
95
+ rootElement.contains(nativeSelection.anchorNode) &&
96
+ editor.isEditable()
97
+ ) {
98
+ const domRect: DOMRect | undefined =
99
+ nativeSelection.focusNode?.parentElement?.getBoundingClientRect();
100
+ if (domRect) {
101
+ setFloatingElemPositionForLinkEditor(domRect, editorElem, anchorElem, -30, -10);
102
+ }
103
+ setLastSelection(selection);
104
+ } else if (!activeElement || activeElement.className !== "link-input") {
105
+ if (rootElement !== null) {
106
+ setFloatingElemPositionForLinkEditor(null, editorElem, anchorElem, -30, -10);
107
+ }
108
+ setLastSelection(null);
109
+ setIsLinkEditMode(false);
110
+ setLinkUrl("");
111
+ }
112
+
113
+ return true;
114
+ }, [anchorElem, editor, setIsLinkEditMode]);
115
+
116
+ useEffect(() => {
117
+ const scrollerElem = anchorElem.parentElement;
118
+
119
+ const update = () => {
120
+ editor.getEditorState().read(() => {
121
+ updateLinkEditor();
122
+ });
123
+ };
124
+
125
+ window.addEventListener("resize", update);
126
+
127
+ if (scrollerElem) {
128
+ scrollerElem.addEventListener("scroll", update);
129
+ }
130
+
131
+ return () => {
132
+ window.removeEventListener("resize", update);
133
+
134
+ if (scrollerElem) {
135
+ scrollerElem.removeEventListener("scroll", update);
136
+ }
137
+ };
138
+ }, [anchorElem.parentElement, editor, updateLinkEditor]);
139
+
140
+ useEffect(() => {
141
+ return mergeRegister(
142
+ editor.registerUpdateListener(({ editorState }) => {
143
+ editorState.read(() => {
144
+ updateLinkEditor();
145
+ });
146
+ }),
147
+
148
+ editor.registerCommand(
149
+ SELECTION_CHANGE_COMMAND,
150
+ () => {
151
+ updateLinkEditor();
152
+ return true;
153
+ },
154
+ COMMAND_PRIORITY_LOW
155
+ ),
156
+ // Hide link editor by pressing escape
157
+ editor.registerCommand(
158
+ KEY_ESCAPE_COMMAND,
159
+ () => {
160
+ if (isLink) {
161
+ setIsLink(false);
162
+ return true;
163
+ }
164
+ return false;
165
+ },
166
+ COMMAND_PRIORITY_HIGH
167
+ ),
168
+ // Hide link editor when editor loses focus
169
+ editor.registerCommand(
170
+ BLUR_COMMAND,
171
+ () => {
172
+ if (isLink && !popped.current) {
173
+ setIsLink(false);
174
+ return true;
175
+ }
176
+ popped.current = false;
177
+ return false;
178
+ },
179
+ COMMAND_PRIORITY_NORMAL
180
+ ),
181
+ // Don't hide link editor when tabbing into it
182
+ editor.registerCommand(
183
+ KEY_TAB_COMMAND,
184
+ () => {
185
+ if (isLink) {
186
+ popped.current = true;
187
+ return true;
188
+ }
189
+ return false;
190
+ },
191
+ COMMAND_PRIORITY_NORMAL
192
+ )
193
+ );
194
+ }, [editor, updateLinkEditor, setIsLink, isLink, popped]);
195
+
196
+ useEffect(() => {
197
+ editor.getEditorState().read(() => {
198
+ updateLinkEditor();
199
+ });
200
+ }, [editor, updateLinkEditor]);
201
+
202
+ useEffect(() => {
203
+ if (isLinkEditMode && inputRef.current) {
204
+ inputRef.current.focus();
205
+ }
206
+ }, [isLinkEditMode]);
207
+
208
+ return (
209
+ <div ref={editorRef} className="gc-link-editor" data-testid="link-editor">
210
+ {isLinkEditMode ? (
211
+ <input
212
+ ref={inputRef}
213
+ className="link-input"
214
+ value={linkUrl}
215
+ onChange={(event) => {
216
+ setLinkUrl(event.target.value);
217
+ }}
218
+ onKeyDown={(event) => {
219
+ if (event.key === "Enter" || event.key === "Escape" || event.key === "Tab") {
220
+ event.preventDefault();
221
+ if (lastSelection !== null) {
222
+ if (linkUrl !== "") {
223
+ editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl(linkUrl));
224
+ }
225
+ setIsLinkEditMode(false);
226
+ }
227
+ }
228
+ }}
229
+ />
230
+ ) : (
231
+ <>
232
+ <div className="link-input">
233
+ <button
234
+ title={t("editLink")}
235
+ aria-label={t("editLink")}
236
+ className="relative w-full truncate pr-5"
237
+ onMouseDown={(event) => event.preventDefault()}
238
+ onKeyDown={(event) => {
239
+ if (event.key === "Escape") {
240
+ event.preventDefault();
241
+ editor.focus();
242
+ }
243
+ if (event.key === "Tab") {
244
+ setIsLink(false);
245
+ }
246
+ }}
247
+ onClick={() => {
248
+ popped.current = true;
249
+ setIsLinkEditMode(true);
250
+ }}
251
+ >
252
+ {linkUrl}
253
+ <EditIcon title={t("editLink")} className="absolute right-0 inline-block size-5" />
254
+ </button>
255
+ </div>
256
+ {/* <LinkPreview url={linkUrl} /> */}
257
+ </>
258
+ )}
259
+ </div>
260
+ );
261
+ }
262
+
263
+ function useFloatingLinkEditorToolbar(
264
+ editor: LexicalEditor,
265
+ anchorElem: HTMLElement,
266
+ isLinkEditMode: boolean,
267
+ setIsLinkEditMode: Dispatch<boolean>
268
+ ): JSX.Element | null {
269
+ const [activeEditor, setActiveEditor] = useState(editor);
270
+ const [isLink, setIsLink] = useState(false);
271
+
272
+ const updateToolbar = useCallback(() => {
273
+ const selection = $getSelection();
274
+ if ($isRangeSelection(selection)) {
275
+ const node = getSelectedNode(selection);
276
+ const linkParent = $findMatchingParent(node, $isLinkNode);
277
+ const autoLinkParent = $findMatchingParent(node, $isAutoLinkNode);
278
+
279
+ // We don't want this menu to open for auto links.
280
+ if (linkParent != null && autoLinkParent == null) {
281
+ setIsLink(true);
282
+ } else {
283
+ setIsLink(false);
284
+ }
285
+ }
286
+ }, []);
287
+
288
+ useEffect(() => {
289
+ return editor.registerCommand(
290
+ SELECTION_CHANGE_COMMAND,
291
+ (_payload, newEditor) => {
292
+ updateToolbar();
293
+ setActiveEditor(newEditor);
294
+ return false;
295
+ },
296
+ COMMAND_PRIORITY_CRITICAL
297
+ );
298
+ }, [editor, updateToolbar]);
299
+
300
+ return isLink
301
+ ? createPortal(
302
+ <FloatingLinkEditor
303
+ editor={activeEditor}
304
+ isLink={isLink}
305
+ anchorElem={anchorElem}
306
+ setIsLink={setIsLink}
307
+ isLinkEditMode={isLinkEditMode}
308
+ setIsLinkEditMode={setIsLinkEditMode}
309
+ />,
310
+ anchorElem
311
+ )
312
+ : null;
313
+ }
314
+
315
+ export default function FloatingLinkEditorPlugin({
316
+ anchorElem = document.body,
317
+ isLinkEditMode,
318
+ setIsLinkEditMode,
319
+ }: {
320
+ anchorElem?: HTMLElement;
321
+ isLinkEditMode: boolean;
322
+ setIsLinkEditMode: Dispatch<boolean>;
323
+ }): JSX.Element | null {
324
+ const [editor] = useLexicalComposerContext();
325
+ return useFloatingLinkEditorToolbar(editor, anchorElem, isLinkEditMode, setIsLinkEditMode);
326
+ }
@@ -0,0 +1,81 @@
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
+
9
+ import type { RangeSelection } from "lexical";
10
+
11
+ import { $getListDepth, $isListItemNode, $isListNode } from "@lexical/list";
12
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
13
+ import {
14
+ $getSelection,
15
+ $isElementNode,
16
+ $isRangeSelection,
17
+ COMMAND_PRIORITY_CRITICAL,
18
+ ElementNode,
19
+ INDENT_CONTENT_COMMAND,
20
+ } from "lexical";
21
+ import { useEffect } from "react";
22
+
23
+ type Props = Readonly<{
24
+ maxDepth: number | null | undefined;
25
+ }>;
26
+
27
+ function getElementNodesInSelection(selection: RangeSelection): Set<ElementNode> {
28
+ const nodesInSelection = selection.getNodes();
29
+
30
+ if (nodesInSelection.length === 0) {
31
+ return new Set([
32
+ selection.anchor.getNode().getParentOrThrow(),
33
+ selection.focus.getNode().getParentOrThrow(),
34
+ ]);
35
+ }
36
+
37
+ return new Set(nodesInSelection.map((n) => ($isElementNode(n) ? n : n.getParentOrThrow())));
38
+ }
39
+
40
+ function isIndentPermitted(maxDepth: number): boolean {
41
+ const selection = $getSelection();
42
+
43
+ if (!$isRangeSelection(selection)) {
44
+ return false;
45
+ }
46
+
47
+ const elementNodesInSelection: Set<ElementNode> = getElementNodesInSelection(selection);
48
+
49
+ let totalDepth = 0;
50
+
51
+ for (const elementNode of elementNodesInSelection) {
52
+ if ($isListNode(elementNode)) {
53
+ totalDepth = Math.max($getListDepth(elementNode) + 1, totalDepth);
54
+ } else if ($isListItemNode(elementNode)) {
55
+ const parent = elementNode.getParent();
56
+
57
+ if (!$isListNode(parent)) {
58
+ throw new Error(
59
+ "ListMaxIndentLevelPlugin: A ListItemNode must have a ListNode for a parent."
60
+ );
61
+ }
62
+
63
+ totalDepth = Math.max($getListDepth(parent) + 1, totalDepth);
64
+ }
65
+ }
66
+
67
+ return totalDepth <= maxDepth;
68
+ }
69
+
70
+ export default function ListMaxIndentLevelPlugin({ maxDepth }: Props): null {
71
+ const [editor] = useLexicalComposerContext();
72
+
73
+ useEffect(() => {
74
+ return editor.registerCommand(
75
+ INDENT_CONTENT_COMMAND,
76
+ () => !isIndentPermitted(maxDepth ?? 7),
77
+ COMMAND_PRIORITY_CRITICAL
78
+ );
79
+ }, [editor, maxDepth]);
80
+ return null;
81
+ }
@@ -0,0 +1,28 @@
1
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
2
+ import {
3
+ COMMAND_PRIORITY_EDITOR,
4
+ INDENT_CONTENT_COMMAND,
5
+ KEY_TAB_COMMAND,
6
+ OUTDENT_CONTENT_COMMAND,
7
+ } from "lexical";
8
+ import { useEffect } from "react";
9
+
10
+ export default function TabControlPlugin() {
11
+ const [editor] = useLexicalComposerContext();
12
+
13
+ useEffect(() => {
14
+ return editor.registerCommand(
15
+ KEY_TAB_COMMAND,
16
+ (payload) => {
17
+ const event: KeyboardEvent = payload;
18
+ event.preventDefault();
19
+ return editor.dispatchCommand(
20
+ event.shiftKey ? OUTDENT_CONTENT_COMMAND : INDENT_CONTENT_COMMAND,
21
+ undefined
22
+ );
23
+ },
24
+ COMMAND_PRIORITY_EDITOR
25
+ );
26
+ }, [editor]);
27
+ return null;
28
+ }