@gcforms/editor 0.0.1 → 1.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/CHANGELOG.md +21 -0
- package/package.json +5 -5
- package/src/Editor.tsx +86 -74
- package/src/config.ts +6 -3
- package/src/context/LocaleContext.tsx +31 -0
- package/src/context/ToolbarContext.tsx +86 -0
- package/src/hooks/useTranslation.tsx +19 -0
- package/src/i18n/en.json +22 -0
- package/src/i18n/fr.json +22 -0
- package/src/i18n/index.tsx +12 -0
- package/src/icons/CancelIcon.tsx +15 -0
- package/src/icons/CheckIcon.tsx +15 -0
- package/src/icons/DeleteIcon.tsx +15 -0
- package/src/icons/EditIcon.tsx +4 -3
- package/src/icons/LinkIcon.tsx +2 -2
- package/src/plugins/FloatingLinkEditorPlugin/index.css +50 -54
- package/src/plugins/FloatingLinkEditorPlugin/index.tsx +305 -138
- package/src/plugins/MaxLengthPlugin/index.tsx +73 -0
- package/src/plugins/MaxLengthPlugin/styles.css +7 -0
- package/src/plugins/ShortcutsPlugin/index.tsx +78 -0
- package/src/plugins/ShortcutsPlugin/shortcuts.ts +76 -0
- package/src/plugins/ToolbarPlugin/index.tsx +121 -149
- package/src/plugins/ToolbarPlugin/styles.css +4 -6
- package/src/plugins/ToolbarPlugin/utils.ts +172 -0
- package/src/styles.css +0 -8
- package/src/tests/Editor.cy.tsx +5 -3
- package/src/transformers/index.ts +18 -0
- package/src/ui/ContentEditable.css +2 -16
- package/src/ui/ContentEditable.tsx +2 -2
- package/src/ui/ToolTip.css +35 -0
- package/src/{ToolTip.tsx → ui/ToolTip.tsx} +4 -6
- package/src/utils/url.ts +7 -9
- package/src/themes/BasicTheme.ts +0 -69
|
@@ -0,0 +1,172 @@
|
|
|
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 { $createCodeNode } from "@lexical/code";
|
|
9
|
+
import {
|
|
10
|
+
INSERT_CHECK_LIST_COMMAND,
|
|
11
|
+
INSERT_ORDERED_LIST_COMMAND,
|
|
12
|
+
INSERT_UNORDERED_LIST_COMMAND,
|
|
13
|
+
} from "@lexical/list";
|
|
14
|
+
import { $isDecoratorBlockNode } from "@lexical/react/LexicalDecoratorBlockNode";
|
|
15
|
+
import {
|
|
16
|
+
$createHeadingNode,
|
|
17
|
+
$createQuoteNode,
|
|
18
|
+
$isHeadingNode,
|
|
19
|
+
$isQuoteNode,
|
|
20
|
+
HeadingTagType,
|
|
21
|
+
} from "@lexical/rich-text";
|
|
22
|
+
import { $setBlocksType } from "@lexical/selection";
|
|
23
|
+
import { $isTableSelection } from "@lexical/table";
|
|
24
|
+
import { $getNearestBlockElementAncestorOrThrow } from "@lexical/utils";
|
|
25
|
+
import {
|
|
26
|
+
$createParagraphNode,
|
|
27
|
+
$getSelection,
|
|
28
|
+
$isRangeSelection,
|
|
29
|
+
$isTextNode,
|
|
30
|
+
LexicalEditor,
|
|
31
|
+
} from "lexical";
|
|
32
|
+
|
|
33
|
+
export const formatParagraph = (editor: LexicalEditor) => {
|
|
34
|
+
editor.update(() => {
|
|
35
|
+
const selection = $getSelection();
|
|
36
|
+
$setBlocksType(selection, () => $createParagraphNode());
|
|
37
|
+
});
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const formatHeading = (
|
|
41
|
+
editor: LexicalEditor,
|
|
42
|
+
blockType: string,
|
|
43
|
+
headingSize: HeadingTagType
|
|
44
|
+
) => {
|
|
45
|
+
if (blockType === headingSize) {
|
|
46
|
+
formatParagraph(editor);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (blockType !== headingSize) {
|
|
50
|
+
editor.update(() => {
|
|
51
|
+
const selection = $getSelection();
|
|
52
|
+
$setBlocksType(selection, () => $createHeadingNode(headingSize));
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const formatBulletList = (editor: LexicalEditor, blockType: string) => {
|
|
58
|
+
if (blockType !== "bullet") {
|
|
59
|
+
editor.dispatchCommand(INSERT_UNORDERED_LIST_COMMAND, undefined);
|
|
60
|
+
} else {
|
|
61
|
+
formatParagraph(editor);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export const formatCheckList = (editor: LexicalEditor, blockType: string) => {
|
|
66
|
+
if (blockType !== "check") {
|
|
67
|
+
editor.dispatchCommand(INSERT_CHECK_LIST_COMMAND, undefined);
|
|
68
|
+
} else {
|
|
69
|
+
formatParagraph(editor);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export const formatNumberedList = (editor: LexicalEditor, blockType: string) => {
|
|
74
|
+
if (blockType !== "number") {
|
|
75
|
+
editor.dispatchCommand(INSERT_ORDERED_LIST_COMMAND, undefined);
|
|
76
|
+
} else {
|
|
77
|
+
formatParagraph(editor);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export const formatQuote = (editor: LexicalEditor, blockType: string) => {
|
|
82
|
+
if (blockType !== "quote") {
|
|
83
|
+
editor.update(() => {
|
|
84
|
+
const selection = $getSelection();
|
|
85
|
+
$setBlocksType(selection, () => $createQuoteNode());
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
export const formatCode = (editor: LexicalEditor, blockType: string) => {
|
|
91
|
+
if (blockType !== "code") {
|
|
92
|
+
editor.update(() => {
|
|
93
|
+
let selection = $getSelection();
|
|
94
|
+
if (!selection) {
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (!$isRangeSelection(selection) || selection.isCollapsed()) {
|
|
98
|
+
$setBlocksType(selection, () => $createCodeNode());
|
|
99
|
+
} else {
|
|
100
|
+
const textContent = selection.getTextContent();
|
|
101
|
+
const codeNode = $createCodeNode();
|
|
102
|
+
selection.insertNodes([codeNode]);
|
|
103
|
+
selection = $getSelection();
|
|
104
|
+
if ($isRangeSelection(selection)) {
|
|
105
|
+
selection.insertRawText(textContent);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
export const clearFormatting = (editor: LexicalEditor) => {
|
|
113
|
+
editor.update(() => {
|
|
114
|
+
const selection = $getSelection();
|
|
115
|
+
if ($isRangeSelection(selection) || $isTableSelection(selection)) {
|
|
116
|
+
const anchor = selection.anchor;
|
|
117
|
+
const focus = selection.focus;
|
|
118
|
+
const nodes = selection.getNodes();
|
|
119
|
+
const extractedNodes = selection.extract();
|
|
120
|
+
|
|
121
|
+
if (anchor.key === focus.key && anchor.offset === focus.offset) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
nodes.forEach((node, idx) => {
|
|
126
|
+
// We split the first and last node by the selection
|
|
127
|
+
// So that we don't format unselected text inside those nodes
|
|
128
|
+
if ($isTextNode(node)) {
|
|
129
|
+
// Use a separate variable to ensure TS does not lose the refinement
|
|
130
|
+
let textNode = node;
|
|
131
|
+
if (idx === 0 && anchor.offset !== 0) {
|
|
132
|
+
textNode = textNode.splitText(anchor.offset)[1] || textNode;
|
|
133
|
+
}
|
|
134
|
+
if (idx === nodes.length - 1) {
|
|
135
|
+
textNode = textNode.splitText(focus.offset)[0] || textNode;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* If the selected text has one format applied
|
|
139
|
+
* selecting a portion of the text, could
|
|
140
|
+
* clear the format to the wrong portion of the text.
|
|
141
|
+
*
|
|
142
|
+
* The cleared text is based on the length of the selected text.
|
|
143
|
+
*/
|
|
144
|
+
// We need this in case the selected text only has one format
|
|
145
|
+
const extractedTextNode = extractedNodes[0];
|
|
146
|
+
if (nodes.length === 1 && $isTextNode(extractedTextNode)) {
|
|
147
|
+
textNode = extractedTextNode;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (textNode.__style !== "") {
|
|
151
|
+
textNode.setStyle("");
|
|
152
|
+
}
|
|
153
|
+
if (textNode.__format !== 0) {
|
|
154
|
+
textNode.setFormat(0);
|
|
155
|
+
}
|
|
156
|
+
const nearestBlockElement = $getNearestBlockElementAncestorOrThrow(textNode);
|
|
157
|
+
if (nearestBlockElement.__format !== 0) {
|
|
158
|
+
nearestBlockElement.setFormat("");
|
|
159
|
+
}
|
|
160
|
+
if (nearestBlockElement.__indent !== 0) {
|
|
161
|
+
nearestBlockElement.setIndent(0);
|
|
162
|
+
}
|
|
163
|
+
node = textNode;
|
|
164
|
+
} else if ($isHeadingNode(node) || $isQuoteNode(node)) {
|
|
165
|
+
node.replace($createParagraphNode(), true);
|
|
166
|
+
} else if ($isDecoratorBlockNode(node)) {
|
|
167
|
+
node.setFormat("");
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
};
|
package/src/styles.css
CHANGED
package/src/tests/Editor.cy.tsx
CHANGED
|
@@ -7,7 +7,7 @@ describe("<RichTextEditor />", () => {
|
|
|
7
7
|
// see: https://on.cypress.io/mounting-react
|
|
8
8
|
cy.mount(
|
|
9
9
|
<div className="form-builder">
|
|
10
|
-
<Editor content="" ariaLabel="AriaLabel"
|
|
10
|
+
<Editor content="" ariaLabel="AriaLabel" locale="en" />
|
|
11
11
|
</div>
|
|
12
12
|
);
|
|
13
13
|
|
|
@@ -77,8 +77,10 @@ describe("<RichTextEditor />", () => {
|
|
|
77
77
|
// Add a link
|
|
78
78
|
cy.get('[id^="editor-"]').setSelection("will be a link");
|
|
79
79
|
cy.get('[data-testid="link-button"]').first().click();
|
|
80
|
-
cy.get('[data-testid="link-editor"]').
|
|
81
|
-
|
|
80
|
+
cy.get('[data-testid="gc-link-editor"] input').type(
|
|
81
|
+
"{selectAll}https://example.com{enter}{esc}"
|
|
82
|
+
);
|
|
82
83
|
cy.get('[id^="editor-"] a').first().contains("will be a link");
|
|
84
|
+
cy.get('[id^="editor-"] a').first().should("have.attr", "href", "https://example.com");
|
|
83
85
|
});
|
|
84
86
|
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { TextMatchTransformer } from "@lexical/markdown";
|
|
2
|
+
import { $createTextNode, $isLineBreakNode, LineBreakNode } from "lexical";
|
|
3
|
+
|
|
4
|
+
export const LINE_BREAK_FIX: TextMatchTransformer = {
|
|
5
|
+
dependencies: [LineBreakNode],
|
|
6
|
+
export: (node) => {
|
|
7
|
+
if (!$isLineBreakNode(node)) return null;
|
|
8
|
+
return " \n";
|
|
9
|
+
},
|
|
10
|
+
regExp: /\\$/,
|
|
11
|
+
importRegExp: /\\$/,
|
|
12
|
+
replace: (textNode) => {
|
|
13
|
+
if (!textNode?.getParent()) return;
|
|
14
|
+
textNode.replace($createTextNode());
|
|
15
|
+
},
|
|
16
|
+
trigger: "",
|
|
17
|
+
type: "text-match",
|
|
18
|
+
};
|
|
@@ -1,25 +1,11 @@
|
|
|
1
1
|
.gc-contenteditable {
|
|
2
2
|
padding: 20px;
|
|
3
|
+
width: 100%;
|
|
4
|
+
background-color: #fff;
|
|
3
5
|
|
|
4
6
|
&:focus {
|
|
5
7
|
outline: 2px #303fc3 solid;
|
|
6
8
|
}
|
|
7
|
-
|
|
8
|
-
p:first-child {
|
|
9
|
-
margin-top: 0;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
p {
|
|
13
|
-
margin: 40px 0;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
.editor-nested-listitem {
|
|
17
|
-
list-style-type: none;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
.editor-list-ol {
|
|
21
|
-
padding-left: 20px;
|
|
22
|
-
}
|
|
23
9
|
}
|
|
24
10
|
|
|
25
11
|
.gc-contenteditable-placeholder {
|
|
@@ -24,13 +24,13 @@ export default function LexicalContentEditable({
|
|
|
24
24
|
}: Props): JSX.Element {
|
|
25
25
|
return (
|
|
26
26
|
<ContentEditable
|
|
27
|
-
className={className ?? "gc-contenteditable
|
|
27
|
+
className={`${className ?? ""} gc-contenteditable`}
|
|
28
28
|
aria-placeholder={placeholder}
|
|
29
29
|
id={id}
|
|
30
30
|
aria-label={ariaLabel}
|
|
31
31
|
aria-describedby={ariaDescribedBy}
|
|
32
32
|
placeholder={
|
|
33
|
-
<div className={placeholderClassName
|
|
33
|
+
<div className={placeholderClassName || "gc-contenteditable-placeholder"}>
|
|
34
34
|
{placeholder}
|
|
35
35
|
</div>
|
|
36
36
|
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
.gc-tooltip-container {
|
|
2
|
+
position: relative;
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
.gc-tooltip {
|
|
6
|
+
position: absolute;
|
|
7
|
+
bottom: calc(100% + 0.5rem);
|
|
8
|
+
left: -3.5rem;
|
|
9
|
+
visibility: hidden;
|
|
10
|
+
padding: 0.25rem;
|
|
11
|
+
border-radius: 0.25rem;
|
|
12
|
+
width: 9rem;
|
|
13
|
+
font-size: 0.875rem;
|
|
14
|
+
line-height: 1.25rem;
|
|
15
|
+
text-align: center;
|
|
16
|
+
color: #ffffff;
|
|
17
|
+
white-space: wrap;
|
|
18
|
+
background-color: #1f2937;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.gc-tooltip:after {
|
|
22
|
+
content: " ";
|
|
23
|
+
position: absolute;
|
|
24
|
+
top: 100%; /* At the bottom of the tooltip */
|
|
25
|
+
left: 50%;
|
|
26
|
+
margin-left: -5px;
|
|
27
|
+
border-width: 5px;
|
|
28
|
+
border-style: solid;
|
|
29
|
+
border-color: black transparent transparent transparent;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.gc-tooltip-target:hover + .gc-tooltip,
|
|
33
|
+
.gc-tooltip-target:focus + .gc-tooltip {
|
|
34
|
+
visibility: visible;
|
|
35
|
+
}
|
|
@@ -1,24 +1,22 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import React, { useId, Children } from "react";
|
|
3
|
+
import "./ToolTip.css";
|
|
3
4
|
|
|
4
5
|
export const ToolTip = ({ children, text }: { children: React.ReactElement; text: string }) => {
|
|
5
6
|
const id = `tooltip-${useId()}`;
|
|
6
7
|
|
|
7
8
|
return (
|
|
8
|
-
<span className="
|
|
9
|
+
<span className="gc-tooltip-container">
|
|
9
10
|
{Children.map(children, (child) => {
|
|
10
11
|
return React.cloneElement(child, {
|
|
11
12
|
// @ts-expect-error -- TODO: fix this
|
|
12
13
|
...child.props,
|
|
13
14
|
// @ts-expect-error -- TODO: fix this
|
|
14
|
-
className: child.props.className + "
|
|
15
|
+
className: child.props.className + " gc-tooltip-target",
|
|
15
16
|
"aria-labelledby": id,
|
|
16
17
|
});
|
|
17
18
|
})}
|
|
18
|
-
<span
|
|
19
|
-
id={id}
|
|
20
|
-
className={`invisible absolute -left-14 -top-10 z-[1000] w-36 whitespace-nowrap rounded bg-gray-800 p-1 text-center text-sm text-white after:absolute after:left-1/2 after:top-full after:-translate-x-1/2 after:border-8 after:border-x-transparent after:border-b-transparent after:border-t-gray-700 after:content-[''] peer-hover:visible peer-focus:visible`}
|
|
21
|
-
>
|
|
19
|
+
<span id={id} className="gc-tooltip">
|
|
22
20
|
{text}
|
|
23
21
|
</span>
|
|
24
22
|
</span>
|
package/src/utils/url.ts
CHANGED
|
@@ -18,15 +18,13 @@ export function sanitizeUrl(url: string): string {
|
|
|
18
18
|
|
|
19
19
|
if (url.match(SAFE_URL_PATTERN) || url.match(DATA_URL_PATTERN)) return url;
|
|
20
20
|
|
|
21
|
-
return
|
|
21
|
+
return url;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
);
|
|
28
|
-
export function
|
|
29
|
-
|
|
30
|
-
// Maybe show a dialog where they user can type the URL before inserting it.
|
|
31
|
-
return url === "https://" || urlRegExp.test(url);
|
|
24
|
+
/**
|
|
25
|
+
* A pattern that matches a valid URL.
|
|
26
|
+
*/
|
|
27
|
+
const urlRegExp = new RegExp(/^(https?:\/\/)?([a-zA-Z0-9-]{1,63}\.)+[a-zA-Z]{2,}(\/[^\s]*)?$/);
|
|
28
|
+
export function isValidUrl(url: string): boolean {
|
|
29
|
+
return urlRegExp.test(url);
|
|
32
30
|
}
|
package/src/themes/BasicTheme.ts
DELETED
|
@@ -1,69 +0,0 @@
|
|
|
1
|
-
const basicTheme = {
|
|
2
|
-
ltr: "ltr",
|
|
3
|
-
rtl: "rtl",
|
|
4
|
-
placeholder: "editor-placeholder",
|
|
5
|
-
paragraph: "editor-paragraph",
|
|
6
|
-
quote: "editor-quote",
|
|
7
|
-
heading: {
|
|
8
|
-
h1: "editor-heading-h1",
|
|
9
|
-
h2: "editor-heading-h2",
|
|
10
|
-
h3: "editor-heading-h3",
|
|
11
|
-
h4: "editor-heading-h4",
|
|
12
|
-
h5: "editor-heading-h5"
|
|
13
|
-
},
|
|
14
|
-
list: {
|
|
15
|
-
nested: {
|
|
16
|
-
listitem: "editor-nested-listitem"
|
|
17
|
-
},
|
|
18
|
-
ol: "editor-list-ol",
|
|
19
|
-
ul: "editor-list-ul",
|
|
20
|
-
listitem: "editor-listitem"
|
|
21
|
-
},
|
|
22
|
-
image: "editor-image",
|
|
23
|
-
link: "editor-link",
|
|
24
|
-
text: {
|
|
25
|
-
bold: "editor-text-bold",
|
|
26
|
-
italic: "editor-text-italic",
|
|
27
|
-
overflowed: "editor-text-overflowed",
|
|
28
|
-
hashtag: "editor-text-hashtag",
|
|
29
|
-
underline: "editor-text-underline",
|
|
30
|
-
strikethrough: "editor-text-strikethrough",
|
|
31
|
-
underlineStrikethrough: "editor-text-underlineStrikethrough",
|
|
32
|
-
code: "editor-text-code"
|
|
33
|
-
},
|
|
34
|
-
code: "editor-code",
|
|
35
|
-
codeHighlight: {
|
|
36
|
-
atrule: "editor-tokenAttr",
|
|
37
|
-
attr: "editor-tokenAttr",
|
|
38
|
-
boolean: "editor-tokenProperty",
|
|
39
|
-
builtin: "editor-tokenSelector",
|
|
40
|
-
cdata: "editor-tokenComment",
|
|
41
|
-
char: "editor-tokenSelector",
|
|
42
|
-
class: "editor-tokenFunction",
|
|
43
|
-
"class-name": "editor-tokenFunction",
|
|
44
|
-
comment: "editor-tokenComment",
|
|
45
|
-
constant: "editor-tokenProperty",
|
|
46
|
-
deleted: "editor-tokenProperty",
|
|
47
|
-
doctype: "editor-tokenComment",
|
|
48
|
-
entity: "editor-tokenOperator",
|
|
49
|
-
function: "editor-tokenFunction",
|
|
50
|
-
important: "editor-tokenVariable",
|
|
51
|
-
inserted: "editor-tokenSelector",
|
|
52
|
-
keyword: "editor-tokenAttr",
|
|
53
|
-
namespace: "editor-tokenVariable",
|
|
54
|
-
number: "editor-tokenProperty",
|
|
55
|
-
operator: "editor-tokenOperator",
|
|
56
|
-
prolog: "editor-tokenComment",
|
|
57
|
-
property: "editor-tokenProperty",
|
|
58
|
-
punctuation: "editor-tokenPunctuation",
|
|
59
|
-
regex: "editor-tokenVariable",
|
|
60
|
-
selector: "editor-tokenSelector",
|
|
61
|
-
string: "editor-tokenSelector",
|
|
62
|
-
symbol: "editor-tokenProperty",
|
|
63
|
-
tag: "editor-tokenProperty",
|
|
64
|
-
url: "editor-tokenOperator",
|
|
65
|
-
variable: "editor-tokenVariable"
|
|
66
|
-
}
|
|
67
|
-
};
|
|
68
|
-
|
|
69
|
-
export default basicTheme;
|