@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 ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "name": "@gcforms/editor",
3
+ "version": "0.0.1",
4
+ "author": "Canadian Digital Service",
5
+ "license": "MIT",
6
+ "publishConfig": {
7
+ "access": "public"
8
+ },
9
+ "packageManager": "yarn@4.6.0",
10
+ "dependencies": {
11
+ "@lexical/react": "^0.28.0",
12
+ "lexical": "^0.28.0"
13
+ },
14
+ "peerDependencies": {
15
+ "react": "^19.0.0",
16
+ "react-dom": "^19.0.0"
17
+ }
18
+ }
package/src/Editor.tsx ADDED
@@ -0,0 +1,126 @@
1
+ "use client";
2
+ import React, { useId, useState } from "react";
3
+ import { LexicalComposer } from "@lexical/react/LexicalComposer";
4
+ import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
5
+ import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
6
+ import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
7
+ import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin";
8
+ import { ListPlugin } from "@lexical/react/LexicalListPlugin";
9
+ import ContentEditable from "./ui/ContentEditable";
10
+ import TabControlPlugin from "./plugins/TabControlPlugin";
11
+ import ToolbarPlugin from "./plugins/ToolbarPlugin";
12
+ import TreeViewPlugin from "./plugins/TreeViewPlugin";
13
+ import ListMaxIndentLevelPlugin from "./plugins/ListMaxIndentPlugin";
14
+ import FloatingLinkEditorPlugin from "./plugins/FloatingLinkEditorPlugin";
15
+ import { editorConfig } from "./config";
16
+ import { $createParagraphNode, $getRoot } from "lexical";
17
+ import {
18
+ $convertFromMarkdownString,
19
+ $convertToMarkdownString,
20
+ TRANSFORMERS,
21
+ } from "@lexical/markdown";
22
+ import { OnChangePlugin } from "@lexical/react/LexicalOnChangePlugin";
23
+ import "./styles.css";
24
+
25
+ interface EditorProps {
26
+ id?: string;
27
+ content?: string;
28
+ showTreeview?: boolean;
29
+ ariaLabel?: string;
30
+ ariaDescribedBy?: string;
31
+ lang?: string;
32
+
33
+ onChange?(...args: unknown[]): unknown;
34
+ }
35
+
36
+ export const Editor = ({
37
+ id,
38
+ content = "",
39
+ showTreeview = false,
40
+ ariaLabel = "AriaLabel",
41
+ ariaDescribedBy = "AriaDescribedBy",
42
+ onChange,
43
+ }: EditorProps) => {
44
+ const [floatingAnchorElem, setFloatingAnchorElem] = useState<HTMLDivElement | null>(null);
45
+ const [isLinkEditMode, setIsLinkEditMode] = useState<boolean>(false);
46
+
47
+ const randomId = useId();
48
+ const editorId = id || `editor-${randomId}`;
49
+
50
+ const onRef = (_floatingAnchorElem: HTMLDivElement) => {
51
+ if (_floatingAnchorElem !== null) {
52
+ setFloatingAnchorElem(_floatingAnchorElem);
53
+ }
54
+ };
55
+
56
+ return (
57
+ <LexicalComposer
58
+ initialConfig={{
59
+ ...editorConfig,
60
+ editorState: () => {
61
+ // @TODO: how to make this configurable
62
+ if (!content) {
63
+ const root = $getRoot();
64
+ const paragraphNode = $createParagraphNode();
65
+ root.append(paragraphNode);
66
+ return;
67
+ }
68
+ $convertFromMarkdownString(content, TRANSFORMERS);
69
+ },
70
+ }}
71
+ >
72
+ <div>
73
+ <ToolbarPlugin editorId={id || ""} />
74
+
75
+ <RichTextPlugin
76
+ contentEditable={
77
+ <div className="gc-editor-container" ref={onRef}>
78
+ <ContentEditable
79
+ placeholder={""}
80
+ id={editorId}
81
+ ariaLabel={ariaLabel && ariaLabel}
82
+ ariaDescribedBy={ariaDescribedBy && ariaDescribedBy}
83
+ />
84
+ </div>
85
+ }
86
+ ErrorBoundary={LexicalErrorBoundary}
87
+ />
88
+ <HistoryPlugin />
89
+ {showTreeview && <TreeViewPlugin />}
90
+ <OnChangePlugin // @TODO: make the following configurable
91
+ onChange={(editorState) => {
92
+ editorState.read(() => {
93
+ // Read the contents of the EditorState here.
94
+ const markdown = $convertToMarkdownString(TRANSFORMERS);
95
+
96
+ // Add two spaces to previous line for linebreaks (this is not handled properly by $convertToMarkdownString)
97
+ const lines = markdown.split("\n");
98
+ lines.forEach((currentLine, i) => {
99
+ if (i > 0) {
100
+ const previousLine = lines[i - 1];
101
+ if (previousLine !== "" && currentLine !== "") {
102
+ lines[i - 1] = previousLine.trim() + " ";
103
+ }
104
+ }
105
+ });
106
+ onChange && onChange(lines.join("\n"));
107
+ });
108
+ }}
109
+ />
110
+ <ListPlugin />
111
+ <LinkPlugin />
112
+ <TabControlPlugin />
113
+ <ListMaxIndentLevelPlugin maxDepth={5} />
114
+ {floatingAnchorElem && (
115
+ <>
116
+ <FloatingLinkEditorPlugin
117
+ anchorElem={floatingAnchorElem}
118
+ isLinkEditMode={isLinkEditMode}
119
+ setIsLinkEditMode={setIsLinkEditMode}
120
+ />
121
+ </>
122
+ )}
123
+ </div>
124
+ </LexicalComposer>
125
+ );
126
+ };
@@ -0,0 +1,26 @@
1
+ "use client";
2
+ import React, { useId, Children } from "react";
3
+
4
+ export const ToolTip = ({ children, text }: { children: React.ReactElement; text: string }) => {
5
+ const id = `tooltip-${useId()}`;
6
+
7
+ return (
8
+ <span className="relative">
9
+ {Children.map(children, (child) => {
10
+ return React.cloneElement(child, {
11
+ // @ts-expect-error -- TODO: fix this
12
+ ...child.props,
13
+ // @ts-expect-error -- TODO: fix this
14
+ className: child.props.className + " peer",
15
+ "aria-labelledby": id,
16
+ });
17
+ })}
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
+ >
22
+ {text}
23
+ </span>
24
+ </span>
25
+ );
26
+ };
package/src/config.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { HeadingNode } from "@lexical/rich-text";
2
+ import { LinkNode } from "@lexical/link";
3
+ import { ListItemNode, ListNode } from "@lexical/list";
4
+ import basicTheme from "./themes/BasicTheme";
5
+
6
+ export const editorConfig = {
7
+ namespace: "FormBuilder",
8
+ // The editor theme
9
+ theme: basicTheme,
10
+ // Handling of errors during update
11
+ onError(error: Error) {
12
+ throw error;
13
+ },
14
+ // Any custom nodes go here
15
+ nodes: [HeadingNode, LinkNode, ListItemNode, ListNode],
16
+ };
@@ -0,0 +1,38 @@
1
+ "use client";
2
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
3
+ import { BLUR_COMMAND, COMMAND_PRIORITY_LOW, FOCUS_COMMAND } from "lexical";
4
+ import { useEffect, useState } from "react";
5
+
6
+ export const useEditorFocus = () => {
7
+ const [editor] = useLexicalComposerContext();
8
+ // Possibly use useRef for synchronous updates but no re-rendering effect
9
+ const [hasFocus, setFocus] = useState(false);
10
+
11
+ useEffect(
12
+ () =>
13
+ editor.registerCommand(
14
+ BLUR_COMMAND,
15
+ () => {
16
+ setFocus(false);
17
+ return false;
18
+ },
19
+ COMMAND_PRIORITY_LOW
20
+ ),
21
+ [editor]
22
+ );
23
+
24
+ useEffect(
25
+ () =>
26
+ editor.registerCommand(
27
+ FOCUS_COMMAND,
28
+ () => {
29
+ setFocus(true);
30
+ return false;
31
+ },
32
+ COMMAND_PRIORITY_LOW
33
+ ),
34
+ [editor]
35
+ );
36
+
37
+ return hasFocus;
38
+ };
@@ -0,0 +1,15 @@
1
+ export const BoldIcon = ({ className, title }: { className?: string; title?: string }) => (
2
+ <svg
3
+ xmlns="http://www.w3.org/2000/svg"
4
+ height="20"
5
+ width="20"
6
+ className={className}
7
+ viewBox="0 0 20 20"
8
+ focusable="false"
9
+ aria-hidden={title ? false : true}
10
+ role={title ? "img" : "presentation"}
11
+ >
12
+ {title && <title>{title}</title>}
13
+ <path d="M15.6 10.79c.97-.67 1.65-1.77 1.65-2.79 0-2.26-1.75-4-4-4H7v14h7.04c2.09 0 3.71-1.7 3.71-3.79 0-1.52-.86-2.82-2.15-3.42zM10 6.5h3c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5h-3v-3zm3.5 9H10v-3h3.5c.83 0 1.5.67 1.5 1.5s-.67 1.5-1.5 1.5z"></path>
14
+ </svg>
15
+ );
@@ -0,0 +1,15 @@
1
+ export const BulletListIcon = ({ className, title }: { className?: string; title?: string }) => (
2
+ <svg
3
+ xmlns="http://www.w3.org/2000/svg"
4
+ height="20"
5
+ width="20"
6
+ className={className}
7
+ viewBox="0 0 20 20"
8
+ focusable="false"
9
+ aria-hidden={title ? false : true}
10
+ role={title ? "img" : "presentation"}
11
+ >
12
+ {title && <title>{title}</title>}
13
+ <path d="M4 10.5c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5 1.5-.67 1.5-1.5-.67-1.5-1.5-1.5zm0-6c-.83 0-1.5.67-1.5 1.5S3.17 7.5 4 7.5 5.5 6.83 5.5 6 4.83 4.5 4 4.5zm0 12c-.83 0-1.5.68-1.5 1.5s.68 1.5 1.5 1.5 1.5-.68 1.5-1.5-.67-1.5-1.5-1.5zM7 19h14v-2H7v2zm0-6h14v-2H7v2zm0-8v2h14V5H7z"></path>
14
+ </svg>
15
+ );
@@ -0,0 +1,14 @@
1
+ export const EditIcon = ({ className, title }: { className?: string; title?: string }) => (
2
+ <svg
3
+ xmlns="http://www.w3.org/2000/svg"
4
+ height="21"
5
+ width="21"
6
+ className={className}
7
+ focusable="false"
8
+ aria-hidden={title ? false : true}
9
+ role={title ? "img" : "presentation"}
10
+ >
11
+ {title && <title>{title}</title>}
12
+ <path d="M.582 20.418v-4.256l5.293-5.294L.991 5.957a1.654 1.654 0 0 1-.368-.546c-.082-.2-.123-.4-.123-.6 0-.2.04-.4.123-.6.082-.2.204-.382.368-.546l2.456-2.483c.163-.164.345-.286.546-.368.2-.082.4-.123.6-.123a1.678 1.678 0 0 1 1.173.491l4.911 4.911L16.052.718a.667.667 0 0 1 .273-.177c.091-.027.191-.041.3-.041.11 0 .21.014.3.04a.667.667 0 0 1 .273.178l3.084 3.083c.09.091.15.182.177.273.027.091.041.191.041.3 0 .11-.014.21-.04.3a.667.667 0 0 1-.178.273l-5.376 5.376 4.912 4.911c.164.164.286.35.368.56.082.209.123.413.123.613s-.04.4-.123.6c-.082.2-.204.383-.368.546l-2.456 2.429a1.653 1.653 0 0 1-.545.368c-.2.082-.4.123-.6.123-.2 0-.4-.041-.601-.123a1.653 1.653 0 0 1-.546-.368l-4.911-4.884-5.32 5.32H.581ZM7.048 9.695 9.504 7.24 7.512 5.248l-1.31 1.31L5.058 5.41l1.31-1.31L4.62 2.356 2.164 4.811l4.884 4.884Zm9.114 9.14 2.455-2.455-1.746-1.746-1.31 1.31-1.146-1.147 1.31-1.31-1.992-1.991-2.455 2.456 4.884 4.884Zm-13.943-.054h1.91L15.452 7.458l-1.91-1.91L2.22 16.87v1.91Zm14.38-12.47 1.91-1.91-1.91-1.91-1.91 1.91 1.91 1.91Z" />
13
+ </svg>
14
+ );
@@ -0,0 +1,15 @@
1
+ export const H2Icon = ({ className, title }: { className?: string; title?: string }) => (
2
+ <svg
3
+ xmlns="http://www.w3.org/2000/svg"
4
+ height="20"
5
+ width="20"
6
+ className={className}
7
+ viewBox="0 0 20 20"
8
+ focusable="false"
9
+ aria-hidden={title ? false : true}
10
+ role={title ? "img" : "presentation"}
11
+ >
12
+ {title && <title>{title}</title>}
13
+ <path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-4 8a2 2 0 0 1-2 2h-2v2h4v2H9v-4a2 2 0 0 1 2-2h2V9H9V7h4a2 2 0 0 1 2 2v2z"></path>
14
+ </svg>
15
+ );
@@ -0,0 +1,15 @@
1
+ export const H3Icon = ({ className, title }: { className?: string; title?: string }) => (
2
+ <svg
3
+ xmlns="http://www.w3.org/2000/svg"
4
+ height="20"
5
+ width="20"
6
+ className={className}
7
+ viewBox="0 0 20 20"
8
+ focusable="false"
9
+ aria-hidden={title ? false : true}
10
+ role={title ? "img" : "presentation"}
11
+ >
12
+ {title && <title>{title}</title>}
13
+ <path d="M19.01 3h-14c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-4 7.5c0 .83-.67 1.5-1.5 1.5.83 0 1.5.67 1.5 1.5V15a2 2 0 0 1-2 2h-4v-2h4v-2h-2v-2h2V9h-4V7h4a2 2 0 0 1 2 2v1.5z"></path>
14
+ </svg>
15
+ );
@@ -0,0 +1,15 @@
1
+ export const ItalicIcon = ({ className, title }: { className?: string; title?: string }) => (
2
+ <svg
3
+ xmlns="http://www.w3.org/2000/svg"
4
+ height="20"
5
+ width="20"
6
+ className={className}
7
+ viewBox="0 0 20 20"
8
+ focusable="false"
9
+ aria-hidden={title ? false : true}
10
+ role={title ? "img" : "presentation"}
11
+ >
12
+ {title && <title>{title}</title>}
13
+ <path d="M10 4v3h2.21l-3.42 8H6v3h8v-3h-2.21l3.42-8H18V4z"></path>
14
+ </svg>
15
+ );
@@ -0,0 +1,15 @@
1
+ export const LinkIcon = ({ className, title }: { className?: string; title?: string }) => (
2
+ <svg
3
+ xmlns="http://www.w3.org/2000/svg"
4
+ height="20"
5
+ width="20"
6
+ className={className}
7
+ viewBox="0 0 20 20"
8
+ focusable="false"
9
+ aria-hidden={title ? false : true}
10
+ role={title ? "img" : "presentation"}
11
+ >
12
+ {title && <title>{title}</title>}
13
+ <path d="M9 14H6q-1.667 0-2.833-1.167Q2 11.667 2 10q0-1.667 1.167-2.833Q4.333 6 6 6h3v1.5H6q-1.042 0-1.771.729Q3.5 8.958 3.5 10q0 1.042.729 1.771.729.729 1.771.729h3Zm-2-3.25v-1.5h6v1.5ZM11 14v-1.5h3q1.042 0 1.771-.729.729-.729.729-1.771 0-1.042-.729-1.771Q15.042 7.5 14 7.5h-3V6h3q1.667 0 2.833 1.167Q18 8.333 18 10q0 1.667-1.167 2.833Q15.667 14 14 14Z" />
14
+ </svg>
15
+ );
@@ -0,0 +1,15 @@
1
+ export const NumberedListIcon = ({ className, title }: { className?: string; title?: string }) => (
2
+ <svg
3
+ xmlns="http://www.w3.org/2000/svg"
4
+ height="20"
5
+ width="20"
6
+ className={className}
7
+ viewBox="0 0 20 20"
8
+ focusable="false"
9
+ aria-hidden={title ? false : true}
10
+ role={title ? "img" : "presentation"}
11
+ >
12
+ {title && <title>{title}</title>}
13
+ <path d="M3 20v-1h2v-.5H4v-1h1V17H3v-1h3v4Zm5-1v-2h13v2Zm-5-5v-.9L4.8 11H3v-1h3v.9L4.2 13H6v1Zm5-1v-2h13v2ZM4 8V5H3V4h2v4Zm4-1V5h13v2Z" />
14
+ </svg>
15
+ );
package/src/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { Editor } from "./Editor";
@@ -0,0 +1,71 @@
1
+ /*********************
2
+ * link editor *
3
+ *********************/
4
+ .gc-link-editor {
5
+ position: absolute;
6
+ top: 0;
7
+ left: 0;
8
+ z-index: 10;
9
+ max-width: 400px;
10
+ width: 100%;
11
+ background-color: #fff;
12
+ border: 2px solid black;
13
+ border-radius: 8px;
14
+ }
15
+
16
+ .gc-link-editor button.active,
17
+ .toolbar button.active {
18
+ background-color: #fff;
19
+ }
20
+
21
+ .gc-link-editor .link-input {
22
+ display: inline-block;
23
+ width: calc(100% - 24px);
24
+ box-sizing: border-box;
25
+ margin: 8px 12px;
26
+ padding: 8px 12px;
27
+ border-radius: 8px;
28
+ background-color: #eee;
29
+ font-size: 15px;
30
+ color: #050505;
31
+ border: 0;
32
+ outline: 0;
33
+ position: relative;
34
+ font-family: inherit;
35
+ }
36
+
37
+ .gc-link-editor button.link-edit {
38
+ background-size: 16px;
39
+ background-position: center;
40
+ background-repeat: no-repeat;
41
+ width: 35px;
42
+ vertical-align: -0.25em;
43
+ position: absolute;
44
+ right: 0;
45
+ top: 0;
46
+ bottom: 0;
47
+ cursor: pointer;
48
+ }
49
+
50
+ .gc-link-editor .link-input span {
51
+ color: #216fdb;
52
+ text-decoration: none;
53
+ display: block;
54
+ white-space: nowrap;
55
+ overflow: hidden;
56
+ margin-right: 30px;
57
+ text-overflow: ellipsis;
58
+ }
59
+
60
+ .gc-link-editor .font-size-wrapper,
61
+ .gc-link-editor .font-family-wrapper {
62
+ display: flex;
63
+ margin: 0 4px;
64
+ }
65
+
66
+ .gc-link-editor select {
67
+ padding: 6px;
68
+ border: none;
69
+ background-color: #00000013;
70
+ border-radius: 4px;
71
+ }