@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,395 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React, { useState, useCallback, useEffect, useRef, KeyboardEvent } from "react";
|
|
3
|
+
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
|
|
4
|
+
import { $isHeadingNode, $createHeadingNode } from "@lexical/rich-text";
|
|
5
|
+
import { mergeRegister, $getNearestNodeOfType } from "@lexical/utils";
|
|
6
|
+
|
|
7
|
+
import { $isLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link";
|
|
8
|
+
|
|
9
|
+
// @TODO: pass in this dependency or otherwise incorporate into package
|
|
10
|
+
import { useTranslation } from "@i18n/client";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
$isListNode,
|
|
14
|
+
INSERT_ORDERED_LIST_COMMAND,
|
|
15
|
+
INSERT_UNORDERED_LIST_COMMAND,
|
|
16
|
+
REMOVE_LIST_COMMAND,
|
|
17
|
+
ListNode,
|
|
18
|
+
} from "@lexical/list";
|
|
19
|
+
|
|
20
|
+
import {
|
|
21
|
+
FORMAT_TEXT_COMMAND,
|
|
22
|
+
$getSelection,
|
|
23
|
+
$isRangeSelection,
|
|
24
|
+
SELECTION_CHANGE_COMMAND,
|
|
25
|
+
$createParagraphNode,
|
|
26
|
+
} from "lexical";
|
|
27
|
+
|
|
28
|
+
import { $wrapNodes } from "@lexical/selection";
|
|
29
|
+
import { sanitizeUrl } from "../../utils/url";
|
|
30
|
+
import { useEditorFocus } from "../../hooks/useEditorFocus";
|
|
31
|
+
import { getSelectedNode } from "../../utils/getSelectedNode";
|
|
32
|
+
import { ToolTip } from "../../ToolTip";
|
|
33
|
+
import { H2Icon } from "../../icons/H2Icon";
|
|
34
|
+
import { H3Icon } from "../../icons/H3Icon";
|
|
35
|
+
import { BoldIcon } from "../../icons/BoldIcon";
|
|
36
|
+
import { ItalicIcon } from "../../icons/ItalicIcon";
|
|
37
|
+
import { BulletListIcon } from "../../icons/BulletListIcon";
|
|
38
|
+
import { NumberedListIcon } from "../../icons/NumberedListIcon";
|
|
39
|
+
import { LinkIcon } from "../../icons/LinkIcon";
|
|
40
|
+
import "./styles.css";
|
|
41
|
+
|
|
42
|
+
const blockTypeToBlockName = {
|
|
43
|
+
bullet: "Bulleted List",
|
|
44
|
+
check: "Check List",
|
|
45
|
+
code: "Code Block",
|
|
46
|
+
h1: "Heading 1",
|
|
47
|
+
h2: "Heading 2",
|
|
48
|
+
h3: "Heading 3",
|
|
49
|
+
h4: "Heading 4",
|
|
50
|
+
h5: "Heading 5",
|
|
51
|
+
h6: "Heading 6",
|
|
52
|
+
number: "Numbered List",
|
|
53
|
+
paragraph: "Normal",
|
|
54
|
+
quote: "Quote",
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
const LowPriority = 1;
|
|
58
|
+
type HeadingTagType = "h2" | "h3" | "h4" | "h5";
|
|
59
|
+
|
|
60
|
+
export default function ToolbarPlugin({ editorId }: { editorId: string }) {
|
|
61
|
+
const [editor] = useLexicalComposerContext();
|
|
62
|
+
const [isBold, setIsBold] = useState(false);
|
|
63
|
+
const [isItalic, setIsItalic] = useState(false);
|
|
64
|
+
const [isLink, setIsLink] = useState(false);
|
|
65
|
+
const [, setSelectedElementKey] = useState("");
|
|
66
|
+
const [blockType, setBlockType] = useState("paragraph");
|
|
67
|
+
|
|
68
|
+
const [isEditable] = useState(() => editor.isEditable());
|
|
69
|
+
|
|
70
|
+
const insertLink = useCallback(() => {
|
|
71
|
+
if (!isLink) {
|
|
72
|
+
editor.dispatchCommand(TOGGLE_LINK_COMMAND, sanitizeUrl("https://"));
|
|
73
|
+
} else {
|
|
74
|
+
editor.dispatchCommand(TOGGLE_LINK_COMMAND, null);
|
|
75
|
+
}
|
|
76
|
+
}, [editor, isLink]);
|
|
77
|
+
|
|
78
|
+
const [items] = useState([
|
|
79
|
+
{ id: 1, txt: "heading2" },
|
|
80
|
+
{ id: 2, txt: "heading3" },
|
|
81
|
+
{ id: 3, txt: "bold" },
|
|
82
|
+
{ id: 4, txt: "italic" },
|
|
83
|
+
{ id: 5, txt: "bulletedList" },
|
|
84
|
+
{ id: 6, txt: "numberedList" },
|
|
85
|
+
{ id: 7, txt: "link" },
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
const itemsRef = useRef<[HTMLButtonElement] | []>([]);
|
|
89
|
+
const [currentFocusIndex, setCurrentFocusIndex] = useState(0);
|
|
90
|
+
const [toolbarInit, setToolbarInit] = useState(false);
|
|
91
|
+
|
|
92
|
+
useEffect(() => {
|
|
93
|
+
const index = `button-${currentFocusIndex}` as unknown as number;
|
|
94
|
+
const el = itemsRef.current[index];
|
|
95
|
+
if (el && toolbarInit) {
|
|
96
|
+
el.focus();
|
|
97
|
+
}
|
|
98
|
+
}, [currentFocusIndex, toolbarInit]);
|
|
99
|
+
|
|
100
|
+
const handleNav = useCallback(
|
|
101
|
+
(evt: KeyboardEvent<HTMLInputElement>) => {
|
|
102
|
+
const { key } = evt;
|
|
103
|
+
|
|
104
|
+
if (!toolbarInit) {
|
|
105
|
+
setCurrentFocusIndex(0);
|
|
106
|
+
setToolbarInit(true);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (key === "ArrowLeft") {
|
|
110
|
+
evt.preventDefault();
|
|
111
|
+
setCurrentFocusIndex((index) => Math.max(0, index - 1));
|
|
112
|
+
} else if (key === "ArrowRight") {
|
|
113
|
+
evt.preventDefault();
|
|
114
|
+
setCurrentFocusIndex((index) => Math.min(items.length - 1, index + 1));
|
|
115
|
+
}
|
|
116
|
+
},
|
|
117
|
+
[items, setCurrentFocusIndex, setToolbarInit, toolbarInit]
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const formatHeading = (level: HeadingTagType) => {
|
|
121
|
+
if (blockType === level) {
|
|
122
|
+
formatParagraph();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (blockType !== level) {
|
|
126
|
+
editor.update(() => {
|
|
127
|
+
const selection = $getSelection();
|
|
128
|
+
if ($isRangeSelection(selection)) {
|
|
129
|
+
$wrapNodes(selection, () => $createHeadingNode(level));
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const formatParagraph = () => {
|
|
136
|
+
if (blockType !== "paragraph") {
|
|
137
|
+
editor.update(() => {
|
|
138
|
+
const selection = $getSelection();
|
|
139
|
+
|
|
140
|
+
if ($isRangeSelection(selection)) {
|
|
141
|
+
$wrapNodes(selection, () => $createParagraphNode());
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
const formatBulletList = (evt: React.MouseEvent<HTMLButtonElement>) => {
|
|
148
|
+
evt.preventDefault();
|
|
149
|
+
if (blockType !== "bullet") {
|
|
150
|
+
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const formatNumberedList = (evt: React.MouseEvent<HTMLButtonElement>) => {
|
|
157
|
+
evt.preventDefault();
|
|
158
|
+
if (blockType !== "number") {
|
|
159
|
+
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
editor.dispatchCommand(REMOVE_LIST_COMMAND, undefined);
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const updateToolbar = useCallback(() => {
|
|
166
|
+
const selection = $getSelection();
|
|
167
|
+
|
|
168
|
+
if ($isRangeSelection(selection)) {
|
|
169
|
+
const anchorNode = selection.anchor.getNode();
|
|
170
|
+
const element =
|
|
171
|
+
anchorNode.getKey() === "root" ? anchorNode : anchorNode.getTopLevelElementOrThrow();
|
|
172
|
+
const elementKey = element.getKey();
|
|
173
|
+
const elementDOM = editor.getElementByKey(elementKey);
|
|
174
|
+
if (elementDOM !== null) {
|
|
175
|
+
setSelectedElementKey(elementKey);
|
|
176
|
+
|
|
177
|
+
const type = $isHeadingNode(element) ? element.getTag() : element.getType();
|
|
178
|
+
setBlockType(type);
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Update text format
|
|
182
|
+
setIsBold(selection.hasFormat("bold"));
|
|
183
|
+
setIsItalic(selection.hasFormat("italic"));
|
|
184
|
+
|
|
185
|
+
// Get current node and parent
|
|
186
|
+
const node = getSelectedNode(selection);
|
|
187
|
+
const parent = node.getParent();
|
|
188
|
+
|
|
189
|
+
// Update links
|
|
190
|
+
if ($isLinkNode(parent) || $isLinkNode(node)) {
|
|
191
|
+
setIsLink(true);
|
|
192
|
+
} else {
|
|
193
|
+
setIsLink(false);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (elementDOM !== null) {
|
|
197
|
+
setSelectedElementKey(elementKey);
|
|
198
|
+
if ($isListNode(element)) {
|
|
199
|
+
const parentList = $getNearestNodeOfType<ListNode>(anchorNode, ListNode);
|
|
200
|
+
const type = parentList ? parentList.getListType() : element.getListType();
|
|
201
|
+
setBlockType(type);
|
|
202
|
+
} else {
|
|
203
|
+
const type = $isHeadingNode(element) ? element.getTag() : element.getType();
|
|
204
|
+
if (type in blockTypeToBlockName) {
|
|
205
|
+
setBlockType(type as keyof typeof blockTypeToBlockName);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}, [editor]);
|
|
211
|
+
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
return mergeRegister(
|
|
214
|
+
editor.registerUpdateListener(({ editorState }) => {
|
|
215
|
+
editorState.read(() => {
|
|
216
|
+
updateToolbar();
|
|
217
|
+
});
|
|
218
|
+
}),
|
|
219
|
+
editor.registerCommand(
|
|
220
|
+
SELECTION_CHANGE_COMMAND,
|
|
221
|
+
() => {
|
|
222
|
+
updateToolbar();
|
|
223
|
+
return false;
|
|
224
|
+
},
|
|
225
|
+
LowPriority
|
|
226
|
+
)
|
|
227
|
+
);
|
|
228
|
+
}, [editor, updateToolbar]);
|
|
229
|
+
|
|
230
|
+
const { t } = useTranslation("form-builder");
|
|
231
|
+
const editorHasFocus = useEditorFocus();
|
|
232
|
+
|
|
233
|
+
return (
|
|
234
|
+
<>
|
|
235
|
+
<div
|
|
236
|
+
className="gc-toolbar-container"
|
|
237
|
+
role="toolbar"
|
|
238
|
+
aria-label={t("textFormatting")}
|
|
239
|
+
aria-controls={editorId}
|
|
240
|
+
onKeyDown={handleNav}
|
|
241
|
+
data-testid="toolbar"
|
|
242
|
+
>
|
|
243
|
+
<ToolTip text={t("tooltipFormatH2")}>
|
|
244
|
+
<button
|
|
245
|
+
tabIndex={currentFocusIndex == 0 ? 0 : -1}
|
|
246
|
+
ref={(el) => {
|
|
247
|
+
const index = "button-0" as unknown as number;
|
|
248
|
+
if (el && itemsRef.current) {
|
|
249
|
+
itemsRef.current[index] = el;
|
|
250
|
+
}
|
|
251
|
+
}}
|
|
252
|
+
onClick={() => {
|
|
253
|
+
formatHeading("h2");
|
|
254
|
+
}}
|
|
255
|
+
className={
|
|
256
|
+
"toolbar-item spaced " + (blockType === "h2" && editorHasFocus ? "active" : "")
|
|
257
|
+
}
|
|
258
|
+
aria-label={t("formatH2")}
|
|
259
|
+
aria-pressed={blockType === "h2"}
|
|
260
|
+
data-testid={`h2-button`}
|
|
261
|
+
>
|
|
262
|
+
<H2Icon />
|
|
263
|
+
</button>
|
|
264
|
+
</ToolTip>
|
|
265
|
+
|
|
266
|
+
<ToolTip text={t("tooltipFormatH3")}>
|
|
267
|
+
<button
|
|
268
|
+
tabIndex={currentFocusIndex == 1 ? 0 : -1}
|
|
269
|
+
ref={(el) => {
|
|
270
|
+
const index = "button-1" as unknown as number;
|
|
271
|
+
if (el && itemsRef.current) {
|
|
272
|
+
itemsRef.current[index] = el;
|
|
273
|
+
}
|
|
274
|
+
}}
|
|
275
|
+
onClick={() => {
|
|
276
|
+
formatHeading("h3");
|
|
277
|
+
}}
|
|
278
|
+
className={
|
|
279
|
+
"peer toolbar-item spaced " + (blockType === "h3" && editorHasFocus ? "active" : "")
|
|
280
|
+
}
|
|
281
|
+
aria-label={t("formatH3")}
|
|
282
|
+
aria-pressed={blockType === "h3"}
|
|
283
|
+
data-testid={`h3-button`}
|
|
284
|
+
>
|
|
285
|
+
<H3Icon />
|
|
286
|
+
</button>
|
|
287
|
+
</ToolTip>
|
|
288
|
+
|
|
289
|
+
<ToolTip text={t("tooltipFormatBold")}>
|
|
290
|
+
<button
|
|
291
|
+
tabIndex={currentFocusIndex == 2 ? 0 : -1}
|
|
292
|
+
ref={(el) => {
|
|
293
|
+
const index = "button-2" as unknown as number;
|
|
294
|
+
if (el && itemsRef.current) {
|
|
295
|
+
itemsRef.current[index] = el;
|
|
296
|
+
}
|
|
297
|
+
}}
|
|
298
|
+
onClick={() => {
|
|
299
|
+
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold");
|
|
300
|
+
}}
|
|
301
|
+
className={"peer toolbar-item " + (isBold && editorHasFocus ? "active" : "")}
|
|
302
|
+
aria-label={t("formatBold")}
|
|
303
|
+
aria-pressed={isBold}
|
|
304
|
+
data-testid={`bold-button`}
|
|
305
|
+
>
|
|
306
|
+
<BoldIcon />
|
|
307
|
+
</button>
|
|
308
|
+
</ToolTip>
|
|
309
|
+
|
|
310
|
+
<ToolTip text={t("tooltipFormatItalic")}>
|
|
311
|
+
<button
|
|
312
|
+
tabIndex={currentFocusIndex == 3 ? 0 : -1}
|
|
313
|
+
ref={(el) => {
|
|
314
|
+
const index = "button-3" as unknown as number;
|
|
315
|
+
if (el && itemsRef.current) {
|
|
316
|
+
itemsRef.current[index] = el;
|
|
317
|
+
}
|
|
318
|
+
}}
|
|
319
|
+
onClick={() => {
|
|
320
|
+
editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic");
|
|
321
|
+
}}
|
|
322
|
+
className={"peer toolbar-item " + (isItalic && editorHasFocus ? "active" : "")}
|
|
323
|
+
aria-label={t("formatItalic")}
|
|
324
|
+
aria-pressed={isItalic}
|
|
325
|
+
data-testid={`italic-button`}
|
|
326
|
+
>
|
|
327
|
+
<ItalicIcon />
|
|
328
|
+
</button>
|
|
329
|
+
</ToolTip>
|
|
330
|
+
|
|
331
|
+
<ToolTip text={t("tooltipFormatBulletList")}>
|
|
332
|
+
<button
|
|
333
|
+
tabIndex={currentFocusIndex == 4 ? 0 : -1}
|
|
334
|
+
ref={(el) => {
|
|
335
|
+
const index = "button-4" as unknown as number;
|
|
336
|
+
if (el && itemsRef.current) {
|
|
337
|
+
itemsRef.current[index] = el;
|
|
338
|
+
}
|
|
339
|
+
}}
|
|
340
|
+
onClick={formatBulletList}
|
|
341
|
+
className={
|
|
342
|
+
"peer toolbar-item " + (blockType === "bullet" && editorHasFocus ? "active" : "")
|
|
343
|
+
}
|
|
344
|
+
aria-label={t("formatBulletList")}
|
|
345
|
+
aria-pressed={blockType === "bullet"}
|
|
346
|
+
data-testid={`bullet-list-button`}
|
|
347
|
+
>
|
|
348
|
+
<BulletListIcon />
|
|
349
|
+
</button>
|
|
350
|
+
</ToolTip>
|
|
351
|
+
|
|
352
|
+
<ToolTip text={t("tooltipFormatNumberedList")}>
|
|
353
|
+
<button
|
|
354
|
+
tabIndex={currentFocusIndex == 5 ? 0 : -1}
|
|
355
|
+
ref={(el) => {
|
|
356
|
+
const index = "button-5" as unknown as number;
|
|
357
|
+
if (el && itemsRef.current) {
|
|
358
|
+
itemsRef.current[index] = el;
|
|
359
|
+
}
|
|
360
|
+
}}
|
|
361
|
+
onClick={formatNumberedList}
|
|
362
|
+
className={
|
|
363
|
+
"peer toolbar-item " + (blockType === "number" && editorHasFocus ? "active" : "")
|
|
364
|
+
}
|
|
365
|
+
aria-label={t("formatNumberedList")}
|
|
366
|
+
aria-pressed={blockType === "number"}
|
|
367
|
+
data-testid={`numbered-list-button`}
|
|
368
|
+
>
|
|
369
|
+
<NumberedListIcon />
|
|
370
|
+
</button>
|
|
371
|
+
</ToolTip>
|
|
372
|
+
|
|
373
|
+
<ToolTip text={t("tooltipInsertLink")}>
|
|
374
|
+
<button
|
|
375
|
+
tabIndex={currentFocusIndex == 6 ? 0 : -1}
|
|
376
|
+
ref={(el) => {
|
|
377
|
+
const index = "button-6" as unknown as number;
|
|
378
|
+
if (el && itemsRef.current) {
|
|
379
|
+
itemsRef.current[index] = el;
|
|
380
|
+
}
|
|
381
|
+
}}
|
|
382
|
+
disabled={!isEditable}
|
|
383
|
+
onClick={insertLink}
|
|
384
|
+
className={"peer toolbar-item " + (isLink && editorHasFocus ? "active" : "")}
|
|
385
|
+
aria-label={t("insertLink")}
|
|
386
|
+
aria-pressed={isLink}
|
|
387
|
+
data-testid={`link-button`}
|
|
388
|
+
>
|
|
389
|
+
<LinkIcon />
|
|
390
|
+
</button>
|
|
391
|
+
</ToolTip>
|
|
392
|
+
</div>
|
|
393
|
+
</>
|
|
394
|
+
);
|
|
395
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
.gc-toolbar-container {
|
|
2
|
+
padding: 10px;
|
|
3
|
+
border-radius: 2px 2px 0 0;
|
|
4
|
+
background-color: rgb(243 244 246 / var(--tw-bg-opacity, 1));
|
|
5
|
+
border-bottom: 1px solid #e9ecef;
|
|
6
|
+
|
|
7
|
+
&:focus-within {
|
|
8
|
+
margin: -2px;
|
|
9
|
+
border: 2px solid #015ecc;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
button {
|
|
13
|
+
padding: 4px;
|
|
14
|
+
border: 1.5px solid transparent;
|
|
15
|
+
border-radius: 4px;
|
|
16
|
+
margin-right: 5px;
|
|
17
|
+
svg {
|
|
18
|
+
display: block;
|
|
19
|
+
}
|
|
20
|
+
&.active {
|
|
21
|
+
border: 1.5px solid #015ecc;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
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 { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
|
|
10
|
+
import { TreeView } from "@lexical/react/LexicalTreeView";
|
|
11
|
+
import * as React from "react";
|
|
12
|
+
|
|
13
|
+
import type { JSX } from "react";
|
|
14
|
+
|
|
15
|
+
export default function TreeViewPlugin(): JSX.Element {
|
|
16
|
+
const [editor] = useLexicalComposerContext();
|
|
17
|
+
return (
|
|
18
|
+
<TreeView
|
|
19
|
+
viewClassName="tree-view-output"
|
|
20
|
+
timeTravelPanelClassName="debug-timetravel-panel"
|
|
21
|
+
timeTravelButtonClassName="debug-timetravel-button"
|
|
22
|
+
timeTravelPanelSliderClassName="debug-timetravel-panel-slider"
|
|
23
|
+
timeTravelPanelButtonClassName="debug-timetravel-panel-button"
|
|
24
|
+
treeTypeButtonClassName=""
|
|
25
|
+
editor={editor}
|
|
26
|
+
/>
|
|
27
|
+
);
|
|
28
|
+
}
|
package/src/styles.css
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { Editor } from "../Editor";
|
|
4
|
+
|
|
5
|
+
describe("<RichTextEditor />", () => {
|
|
6
|
+
it("Adds and styles text", () => {
|
|
7
|
+
// see: https://on.cypress.io/mounting-react
|
|
8
|
+
cy.mount(
|
|
9
|
+
<div className="form-builder">
|
|
10
|
+
<Editor content="" ariaLabel="AriaLabel" lang="en" />
|
|
11
|
+
</div>
|
|
12
|
+
);
|
|
13
|
+
|
|
14
|
+
// Add some strings to get formatted
|
|
15
|
+
cy.get('[id^="editor-"]').type(
|
|
16
|
+
`
|
|
17
|
+
H2 heading text
|
|
18
|
+
H3 heading text
|
|
19
|
+
Let's bold part of this sentence.
|
|
20
|
+
Let's italicize part of this sentence.
|
|
21
|
+
Part of his one will be a link
|
|
22
|
+
`
|
|
23
|
+
);
|
|
24
|
+
|
|
25
|
+
// Add H2 heading
|
|
26
|
+
cy.get('[id^="editor-"]').setSelection("H2 heading text");
|
|
27
|
+
cy.get('[data-testid="h2-button"]').first().click();
|
|
28
|
+
cy.get('[id^="editor-"] h2').first().contains("H2 heading text");
|
|
29
|
+
cy.get('[data-testid="h2-button"]').first().should("have.attr", "aria-pressed", "true");
|
|
30
|
+
|
|
31
|
+
// Add H3 heading
|
|
32
|
+
cy.get('[id^="editor-"]').setSelection("H3 heading text");
|
|
33
|
+
cy.get('[data-testid="h3-button"]').first().click();
|
|
34
|
+
cy.get('[id^="editor-"] h3').first().contains("H3 heading text");
|
|
35
|
+
cy.get('[data-testid="h3-button"]').first().should("have.attr", "aria-pressed", "true");
|
|
36
|
+
|
|
37
|
+
// Bold part of the text
|
|
38
|
+
cy.get('[id^="editor-"]').setSelection("bold part of this");
|
|
39
|
+
cy.get('[data-testid="bold-button"]').first().click();
|
|
40
|
+
cy.get('[id^="editor-"] strong').first().contains("bold part of this");
|
|
41
|
+
cy.get('[data-testid="bold-button"]').first().should("have.attr", "aria-pressed", "true");
|
|
42
|
+
|
|
43
|
+
// Italicize part of the text
|
|
44
|
+
cy.get('[id^="editor-"]').setSelection("italicize part of this");
|
|
45
|
+
cy.get('[data-testid="italic-button"]').first().click();
|
|
46
|
+
cy.get('[id^="editor-"] em').first().contains("italicize part of this");
|
|
47
|
+
cy.get('[data-testid="italic-button"]').first().should("have.attr", "aria-pressed", "true");
|
|
48
|
+
|
|
49
|
+
// Add a bullet list
|
|
50
|
+
cy.get('[id^="editor-"]').type("{moveToEnd}This is a bullet list item");
|
|
51
|
+
cy.get('[data-testid="bullet-list-button"]').first().click();
|
|
52
|
+
|
|
53
|
+
cy.get('[id^="editor-"]')
|
|
54
|
+
.setCursorAfter("This is a bullet list item")
|
|
55
|
+
.type("{enter}This is another bullet list item");
|
|
56
|
+
cy.get('[data-testid="bullet-list-button"]')
|
|
57
|
+
.first()
|
|
58
|
+
.should("have.attr", "aria-pressed", "true");
|
|
59
|
+
cy.get('[id^="editor-"] ul li').first().contains("This is a bullet list item");
|
|
60
|
+
cy.get('[id^="editor-"] ul li').last().contains("This is another bullet list item");
|
|
61
|
+
|
|
62
|
+
// Add a numbered list - two {enter}s are needed to escape the previous list
|
|
63
|
+
cy.get('[id^="editor-"]').type("{moveToEnd}{enter}{enter}This is a numbered list item");
|
|
64
|
+
cy.get('[data-testid="numbered-list-button"]').first().click();
|
|
65
|
+
cy.get('[data-testid="numbered-list-button"]')
|
|
66
|
+
.first()
|
|
67
|
+
.should("have.attr", "aria-pressed", "true");
|
|
68
|
+
cy.get('[id^="editor-"]')
|
|
69
|
+
.setCursorAfter("This is a numbered list item")
|
|
70
|
+
.type("{enter}This is another numbered list item");
|
|
71
|
+
cy.get('[data-testid="numbered-list-button"]')
|
|
72
|
+
.first()
|
|
73
|
+
.should("have.attr", "aria-pressed", "true");
|
|
74
|
+
cy.get('[id^="editor-"] ol li').first().contains("This is a numbered list item");
|
|
75
|
+
cy.get('[id^="editor-"] ol li').last().contains("This is another numbered list item");
|
|
76
|
+
|
|
77
|
+
// Add a link
|
|
78
|
+
cy.get('[id^="editor-"]').setSelection("will be a link");
|
|
79
|
+
cy.get('[data-testid="link-button"]').first().click();
|
|
80
|
+
cy.get('[data-testid="link-editor"]').first().click();
|
|
81
|
+
cy.get('[data-testid="link-editor"]').type("example.com{enter}{esc}");
|
|
82
|
+
cy.get('[id^="editor-"] a').first().contains("will be a link");
|
|
83
|
+
});
|
|
84
|
+
});
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import React from "react";
|
|
5
|
+
import { cleanup, screen, render, act, within } from "@testing-library/react";
|
|
6
|
+
import userEvent from "@testing-library/user-event";
|
|
7
|
+
// import { defaultStore as store, Providers } from "@lib/utils/form-builder/test-utils";
|
|
8
|
+
// import { RichTextEditor } from "../RichTextEditor";
|
|
9
|
+
import { Editor } from "../Editor";
|
|
10
|
+
|
|
11
|
+
const promise = Promise.resolve();
|
|
12
|
+
|
|
13
|
+
describe("Lexical Editor", () => {
|
|
14
|
+
afterEach(() => {
|
|
15
|
+
cleanup();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("Renders the Lexical Editor", async () => {
|
|
19
|
+
//
|
|
20
|
+
const rendered = render(
|
|
21
|
+
<Editor ariaLabel="AriaLabel" ariaDescribedBy="AriaDescribedBy" id="editor-test" content="Here is some test content" />
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
await act(async () => {
|
|
25
|
+
await promise;
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
const contentArea = rendered.container.querySelector('[id^="editor-"]');
|
|
29
|
+
const toolbar = screen.getByTestId("toolbar");
|
|
30
|
+
const [h2, h3, bold, italic, bulletList, numberedList, link] =
|
|
31
|
+
within(toolbar).getAllByRole("button");
|
|
32
|
+
|
|
33
|
+
const toolbarButtons = within(toolbar).getAllByRole("button");
|
|
34
|
+
|
|
35
|
+
expect(h2).toHaveAttribute("tabindex", "0");
|
|
36
|
+
expect(h3).toHaveAttribute("tabindex", "-1");
|
|
37
|
+
expect(bold).toHaveAttribute("tabindex", "-1");
|
|
38
|
+
expect(italic).toHaveAttribute("tabindex", "-1");
|
|
39
|
+
expect(bulletList).toHaveAttribute("tabindex", "-1");
|
|
40
|
+
expect(numberedList).toHaveAttribute("tabindex", "-1");
|
|
41
|
+
expect(link).toHaveAttribute("tabindex", "-1");
|
|
42
|
+
|
|
43
|
+
// Toolbar has aria-controls attribute
|
|
44
|
+
expect(toolbar).toHaveAttribute("aria-controls", contentArea.id);
|
|
45
|
+
|
|
46
|
+
// Toolbar contains 7 formatting buttons
|
|
47
|
+
expect(toolbarButtons).toHaveLength(7);
|
|
48
|
+
|
|
49
|
+
// Content area has default content and attributes
|
|
50
|
+
expect(contentArea).toHaveAttribute("aria-label", "AriaLabel");
|
|
51
|
+
expect(contentArea).toContainHTML("Here is some test content");
|
|
52
|
+
expect(contentArea).toHaveAttribute("contenteditable", "true");
|
|
53
|
+
expect(contentArea).toHaveAttribute("role", "textbox");
|
|
54
|
+
expect(contentArea).toHaveAttribute("spellcheck", "true");
|
|
55
|
+
expect(contentArea).toHaveAttribute("data-lexical-editor", "true");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("can keyboard navigate the RichTextEditor", async () => {
|
|
59
|
+
render(
|
|
60
|
+
<div><Editor ariaLabel="AriaLabel" ariaDescribedBy="AriaDescribedBy" content="Here is some test content" /></div>
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
await act(async () => {
|
|
64
|
+
await promise;
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const toolbar = screen.getByTestId("toolbar");
|
|
68
|
+
|
|
69
|
+
const [h2, h3, bold, italic, bulletList, numberedList, link] =
|
|
70
|
+
within(toolbar).getAllByRole("button");
|
|
71
|
+
|
|
72
|
+
// expect(document.body).toHaveFocus();
|
|
73
|
+
|
|
74
|
+
// tab into toolbar
|
|
75
|
+
await userEvent.tab();
|
|
76
|
+
expect(h2).toHaveFocus();
|
|
77
|
+
|
|
78
|
+
// tab back out of toolbar
|
|
79
|
+
await userEvent.tab({ shift: true });
|
|
80
|
+
expect(document.body).toHaveFocus();
|
|
81
|
+
|
|
82
|
+
// tab back into toolbar
|
|
83
|
+
await userEvent.tab();
|
|
84
|
+
await userEvent.keyboard("{arrowright}");
|
|
85
|
+
expect(h3).toHaveFocus();
|
|
86
|
+
|
|
87
|
+
await userEvent.keyboard("{arrowright}");
|
|
88
|
+
expect(bold).toHaveFocus();
|
|
89
|
+
|
|
90
|
+
await userEvent.keyboard("{arrowright}");
|
|
91
|
+
expect(italic).toHaveFocus();
|
|
92
|
+
|
|
93
|
+
await userEvent.keyboard("{arrowright}");
|
|
94
|
+
expect(bulletList).toHaveFocus();
|
|
95
|
+
|
|
96
|
+
await userEvent.keyboard("{arrowright}");
|
|
97
|
+
expect(numberedList).toHaveFocus();
|
|
98
|
+
|
|
99
|
+
await userEvent.keyboard("{arrowright}");
|
|
100
|
+
expect(link).toHaveFocus();
|
|
101
|
+
|
|
102
|
+
await userEvent.keyboard("{arrowleft}");
|
|
103
|
+
expect(numberedList).toHaveFocus();
|
|
104
|
+
|
|
105
|
+
// tab back out of toolbar
|
|
106
|
+
await userEvent.tab({ shift: true });
|
|
107
|
+
expect(document.body).toHaveFocus();
|
|
108
|
+
|
|
109
|
+
// tab back into toolbar
|
|
110
|
+
await userEvent.tab();
|
|
111
|
+
expect(numberedList).toHaveFocus();
|
|
112
|
+
});
|
|
113
|
+
});
|