@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.
- package/package.json +18 -0
- package/src/Editor.tsx +126 -0
- package/src/ToolTip.tsx +26 -0
- package/src/config.ts +16 -0
- package/src/hooks/useEditorFocus.tsx +38 -0
- package/src/icons/BoldIcon.tsx +15 -0
- package/src/icons/BulletListIcon.tsx +15 -0
- package/src/icons/EditIcon.tsx +14 -0
- package/src/icons/H2Icon.tsx +15 -0
- package/src/icons/H3Icon.tsx +15 -0
- package/src/icons/ItalicIcon.tsx +15 -0
- package/src/icons/LinkIcon.tsx +15 -0
- package/src/icons/NumberedListIcon.tsx +15 -0
- package/src/index.ts +1 -0
- package/src/plugins/FloatingLinkEditorPlugin/index.css +71 -0
- package/src/plugins/FloatingLinkEditorPlugin/index.tsx +326 -0
- package/src/plugins/ListMaxIndentPlugin/index.tsx +81 -0
- package/src/plugins/TabControlPlugin/index.tsx +28 -0
- package/src/plugins/ToolbarPlugin/index.tsx +395 -0
- package/src/plugins/ToolbarPlugin/styles.css +24 -0
- package/src/plugins/TreeViewPlugin/index.tsx +28 -0
- package/src/styles.css +11 -0
- package/src/tests/Editor.cy.tsx +84 -0
- package/src/tests/Editor.test.js +113 -0
- package/src/themes/BasicTheme.ts +69 -0
- package/src/ui/ContentEditable.css +44 -0
- package/src/ui/ContentEditable.tsx +39 -0
- package/src/utils/getSelectedNode.ts +25 -0
- package/src/utils/setFloatingElemPositionForLinkEditor.ts +46 -0
- package/src/utils/url.ts +32 -0
|
@@ -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
|
+
}
|