@gcforms/editor 0.0.1 → 1.0.0

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,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
@@ -1,11 +1,11 @@
1
- /*--------------------------------------------*
2
- * Rich text editor
3
- *--------------------------------------------*/
4
-
5
- .editor-list-ul li {
6
- margin-bottom: 0.25rem;
7
- }
8
-
9
1
  .gc-editor-container {
10
2
  position: relative;
11
3
  }
4
+
5
+ .gc-editor-max-length {
6
+ position: absolute;
7
+ bottom: 0;
8
+ right: 8px;
9
+ font-size: 0.8rem;
10
+ color: #999;
11
+ }
@@ -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" lang="en" />
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"]').first().click();
81
- cy.get('[data-testid="link-editor"]').type("example.com{enter}{esc}");
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 ?? "gc-contenteditable-placeholder"}>
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="relative">
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 + " peer",
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 "https://";
21
+ return url;
22
22
  }
23
23
 
24
- // Source: https://stackoverflow.com/a/8234912/2013580
25
- const urlRegExp = new RegExp(
26
- /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[-;:&=+$,\w]+@)?[A-Za-z0-9.-]+|(?:www.|[-;:&=+$,\w]+@)[A-Za-z0-9.-]+)((?:\/[+~%/.\w-_]*)?\??(?:[-+=&;%@.\w_]*)#?(?:[\w]*))?)/
27
- );
28
- export function validateUrl(url: string): boolean {
29
- // TODO Fix UI for link insertion; it should never default to an invalid URL such as https://.
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
  }
@@ -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;