@gcforms/editor 1.0.17 → 1.0.19
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 +8 -0
- package/package.json +16 -15
- package/src/Editor.tsx +20 -19
- package/src/config.ts +19 -0
- package/src/i18n/en.json +3 -1
- package/src/i18n/fr.json +3 -1
- package/src/icons/CollapsibleIcon.tsx +11 -0
- package/src/plugins/CollapsibleExtension/Collapsible.css +107 -0
- package/src/plugins/CollapsibleExtension/CollapsibleContainerNode.ts +177 -0
- package/src/plugins/CollapsibleExtension/CollapsibleContentNode.ts +81 -0
- package/src/plugins/CollapsibleExtension/CollapsibleTitleNode.ts +98 -0
- package/src/plugins/CollapsibleExtension/CollapsibleUtils.ts +15 -0
- package/src/plugins/CollapsibleExtension/index.ts +292 -0
- package/src/plugins/ToolbarPlugin/index.tsx +26 -13
- package/src/plugins/ToolbarPlugin/utils.ts +5 -0
- package/src/tests/Editor.test.jsx +60 -3
- package/src/transformers/collapsible.ts +54 -0
- package/src/transformers/index.ts +1 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.0.19] - 2026-08-17
|
|
9
|
+
|
|
10
|
+
- Add collapsible extension
|
|
11
|
+
|
|
12
|
+
## [1.0.18] - 2026-08-17
|
|
13
|
+
|
|
14
|
+
- Update Lexical to version 0.49.0
|
|
15
|
+
|
|
8
16
|
## [1.0.17] - 2026-06-15
|
|
9
17
|
|
|
10
18
|
- Update Lexical to version 0.45.0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@gcforms/editor",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.19",
|
|
4
4
|
"author": "Canadian Digital Service",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"publishConfig": {
|
|
@@ -8,23 +8,24 @@
|
|
|
8
8
|
},
|
|
9
9
|
"packageManager": "yarn@4.10.3",
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@lexical/code": "^0.
|
|
12
|
-
"@lexical/
|
|
13
|
-
"@lexical/
|
|
14
|
-
"@lexical/
|
|
15
|
-
"@lexical/
|
|
16
|
-
"@lexical/
|
|
17
|
-
"@lexical/
|
|
18
|
-
"@lexical/
|
|
19
|
-
"@lexical/
|
|
20
|
-
"lexical": "^0.
|
|
11
|
+
"@lexical/code": "^0.49.0",
|
|
12
|
+
"@lexical/html": "^0.49.0",
|
|
13
|
+
"@lexical/link": "^0.49.0",
|
|
14
|
+
"@lexical/list": "^0.49.0",
|
|
15
|
+
"@lexical/markdown": "^0.49.0",
|
|
16
|
+
"@lexical/react": "^0.49.0",
|
|
17
|
+
"@lexical/rich-text": "^0.49.0",
|
|
18
|
+
"@lexical/selection": "^0.49.0",
|
|
19
|
+
"@lexical/table": "^0.49.0",
|
|
20
|
+
"@lexical/utils": "^0.49.0",
|
|
21
|
+
"lexical": "^0.49.0"
|
|
21
22
|
},
|
|
22
23
|
"devDependencies": {
|
|
23
|
-
"@testing-library/react": "^16.3.
|
|
24
|
-
"@testing-library/user-event": "^14.6.
|
|
24
|
+
"@testing-library/react": "^16.3.2",
|
|
25
|
+
"@testing-library/user-event": "^14.6.4"
|
|
25
26
|
},
|
|
26
27
|
"peerDependencies": {
|
|
27
|
-
"react": "^19.
|
|
28
|
-
"react-dom": "^19.
|
|
28
|
+
"react": "^19.2.8",
|
|
29
|
+
"react-dom": "^19.2.8"
|
|
29
30
|
}
|
|
30
31
|
}
|
package/src/Editor.tsx
CHANGED
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import React, { useId, useState } from "react";
|
|
3
|
-
import {
|
|
4
|
-
|
|
5
|
-
$convertToMarkdownString,
|
|
6
|
-
TRANSFORMERS,
|
|
7
|
-
} from "@lexical/markdown";
|
|
8
|
-
import { LexicalComposer } from "@lexical/react/LexicalComposer";
|
|
3
|
+
import { $convertToMarkdownString, TRANSFORMERS } from "@lexical/markdown";
|
|
4
|
+
import { LexicalExtensionComposer } from "@lexical/react/LexicalExtensionComposer";
|
|
9
5
|
import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
|
|
10
6
|
import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
|
|
11
7
|
import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
|
|
@@ -18,8 +14,8 @@ import ToolbarPlugin from "./plugins/ToolbarPlugin";
|
|
|
18
14
|
import TreeViewPlugin from "./plugins/TreeViewPlugin";
|
|
19
15
|
import ListMaxIndentLevelPlugin from "./plugins/ListMaxIndentPlugin";
|
|
20
16
|
import FloatingLinkEditorPlugin from "./plugins/FloatingLinkEditorPlugin";
|
|
21
|
-
import {
|
|
22
|
-
import { LINE_BREAK_FIX } from "./transformers";
|
|
17
|
+
import { createEditorExtension } from "./config";
|
|
18
|
+
import { COLLAPSIBLE, LINE_BREAK_FIX } from "./transformers";
|
|
23
19
|
import { Language } from "./i18n";
|
|
24
20
|
import { LocaleContext } from "./context/LocaleContext";
|
|
25
21
|
|
|
@@ -40,6 +36,7 @@ interface EditorProps {
|
|
|
40
36
|
className?: string;
|
|
41
37
|
maxLength?: number;
|
|
42
38
|
enableDraggableBlocks?: boolean;
|
|
39
|
+
enableCollapsibleBlocks?: boolean;
|
|
43
40
|
|
|
44
41
|
onChange?(...args: unknown[]): unknown;
|
|
45
42
|
}
|
|
@@ -56,6 +53,7 @@ export const Editor = ({
|
|
|
56
53
|
className,
|
|
57
54
|
maxLength,
|
|
58
55
|
enableDraggableBlocks = false,
|
|
56
|
+
enableCollapsibleBlocks = false,
|
|
59
57
|
}: EditorProps) => {
|
|
60
58
|
contentLocale = contentLocale || locale;
|
|
61
59
|
|
|
@@ -64,6 +62,8 @@ export const Editor = ({
|
|
|
64
62
|
|
|
65
63
|
const randomId = useId();
|
|
66
64
|
const editorId = id || `editor-${randomId}`;
|
|
65
|
+
// LexicalExtensionComposer recreates the editor when its extension changes.
|
|
66
|
+
const [editorExtension] = useState(() => createEditorExtension(content));
|
|
67
67
|
|
|
68
68
|
const onRef = (_floatingAnchorElem: HTMLDivElement) => {
|
|
69
69
|
if (_floatingAnchorElem !== null) {
|
|
@@ -74,16 +74,13 @@ export const Editor = ({
|
|
|
74
74
|
return (
|
|
75
75
|
<LocaleContext initialLocale={locale as Language}>
|
|
76
76
|
<ToolbarContext>
|
|
77
|
-
<
|
|
78
|
-
initialConfig={{
|
|
79
|
-
...editorConfig,
|
|
80
|
-
editorState: () => {
|
|
81
|
-
$convertFromMarkdownString(content, [...TRANSFORMERS]);
|
|
82
|
-
},
|
|
83
|
-
}}
|
|
84
|
-
>
|
|
77
|
+
<LexicalExtensionComposer extension={editorExtension} contentEditable={null}>
|
|
85
78
|
<div className="gc-editor-container">
|
|
86
|
-
<ToolbarPlugin
|
|
79
|
+
<ToolbarPlugin
|
|
80
|
+
editorId={editorId}
|
|
81
|
+
setIsLinkEditMode={setIsLinkEditMode}
|
|
82
|
+
enableCollapsibleBlocks={enableCollapsibleBlocks}
|
|
83
|
+
/>
|
|
87
84
|
<ShortcutsPlugin setIsLinkEditMode={setIsLinkEditMode} />
|
|
88
85
|
<RichTextPlugin
|
|
89
86
|
contentEditable={
|
|
@@ -108,7 +105,11 @@ export const Editor = ({
|
|
|
108
105
|
<OnChangePlugin
|
|
109
106
|
onChange={(editorState) => {
|
|
110
107
|
editorState.read(() => {
|
|
111
|
-
const markdown = $convertToMarkdownString([
|
|
108
|
+
const markdown = $convertToMarkdownString([
|
|
109
|
+
...TRANSFORMERS,
|
|
110
|
+
COLLAPSIBLE,
|
|
111
|
+
LINE_BREAK_FIX,
|
|
112
|
+
]);
|
|
112
113
|
onChange && onChange(markdown);
|
|
113
114
|
});
|
|
114
115
|
}}
|
|
@@ -133,7 +134,7 @@ export const Editor = ({
|
|
|
133
134
|
</>
|
|
134
135
|
)}
|
|
135
136
|
</div>
|
|
136
|
-
</
|
|
137
|
+
</LexicalExtensionComposer>
|
|
137
138
|
</ToolbarContext>
|
|
138
139
|
</LocaleContext>
|
|
139
140
|
);
|
package/src/config.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { HeadingNode, QuoteNode } from "@lexical/rich-text";
|
|
2
2
|
import { LinkNode } from "@lexical/link";
|
|
3
3
|
import { ListItemNode, ListNode } from "@lexical/list";
|
|
4
|
+
import { $convertFromMarkdownString, TRANSFORMERS } from "@lexical/markdown";
|
|
5
|
+
import { defineExtension } from "lexical";
|
|
6
|
+
import { CollapsibleExtension } from "./plugins/CollapsibleExtension";
|
|
7
|
+
import { COLLAPSIBLE } from "./transformers";
|
|
4
8
|
|
|
5
9
|
export const editorConfig = {
|
|
6
10
|
namespace: "FormBuilder",
|
|
@@ -17,3 +21,18 @@ export const editorConfig = {
|
|
|
17
21
|
// Any custom nodes go here
|
|
18
22
|
nodes: [HeadingNode, QuoteNode, LinkNode, ListItemNode, ListNode],
|
|
19
23
|
};
|
|
24
|
+
|
|
25
|
+
export const createEditorExtension = (content: string) =>
|
|
26
|
+
defineExtension({
|
|
27
|
+
name: editorConfig.namespace,
|
|
28
|
+
namespace: editorConfig.namespace,
|
|
29
|
+
nodes: () => editorConfig.nodes,
|
|
30
|
+
theme: editorConfig.theme,
|
|
31
|
+
onError: editorConfig.onError,
|
|
32
|
+
dependencies: [CollapsibleExtension],
|
|
33
|
+
$initialEditorState: (editor) => {
|
|
34
|
+
editor.update(() => {
|
|
35
|
+
$convertFromMarkdownString(content, [...TRANSFORMERS, COLLAPSIBLE]);
|
|
36
|
+
});
|
|
37
|
+
},
|
|
38
|
+
});
|
package/src/i18n/en.json
CHANGED
package/src/i18n/fr.json
CHANGED
|
@@ -22,5 +22,7 @@
|
|
|
22
22
|
"tooltipIndent": "Indentation",
|
|
23
23
|
"tooltipOutdent": "Suppression d'indentation",
|
|
24
24
|
"indent": "Indentation",
|
|
25
|
-
"outdent": "Suppression d'indentation"
|
|
25
|
+
"outdent": "Suppression d'indentation",
|
|
26
|
+
"tooltipInsertCollapsible": "Insérer un conteneur réductible",
|
|
27
|
+
"insertCollapsible": "Insérer un conteneur réductible"
|
|
26
28
|
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export const CollapsibleIcon = () => (
|
|
2
|
+
<svg aria-hidden="true" width="20" height="20" viewBox="0 0 20 20" fill="none">
|
|
3
|
+
<path
|
|
4
|
+
d="m5 7 5 5 5-5"
|
|
5
|
+
stroke="currentColor"
|
|
6
|
+
strokeWidth="1.8"
|
|
7
|
+
strokeLinecap="round"
|
|
8
|
+
strokeLinejoin="round"
|
|
9
|
+
/>
|
|
10
|
+
</svg>
|
|
11
|
+
);
|
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
|
|
10
|
+
.Collapsible__container {
|
|
11
|
+
background: #fcfcfc;
|
|
12
|
+
border: 1px solid #eee;
|
|
13
|
+
border-radius: 10px;
|
|
14
|
+
margin-bottom: 8px;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
.Collapsible__title {
|
|
18
|
+
cursor: pointer;
|
|
19
|
+
padding: 5px 5px 5px 20px;
|
|
20
|
+
position: relative;
|
|
21
|
+
font-weight: bold;
|
|
22
|
+
list-style: none;
|
|
23
|
+
outline: none;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/* Lexical titles contain a paragraph. Remove its browser margin so the
|
|
27
|
+
disclosure marker aligns with the title text rather than the whole block. */
|
|
28
|
+
.Collapsible__title > * {
|
|
29
|
+
margin-block: 0;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.Collapsible__title p {
|
|
33
|
+
margin: 0;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
.Collapsible__content > p:last-child {
|
|
37
|
+
margin-bottom: 0;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
.Collapsible__title::marker,
|
|
41
|
+
.Collapsible__title::-webkit-details-marker {
|
|
42
|
+
display: none;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
.Collapsible__title:before {
|
|
46
|
+
border-style: solid;
|
|
47
|
+
border-color: transparent;
|
|
48
|
+
border-width: 4px 6px 4px 6px;
|
|
49
|
+
border-left-color: #000;
|
|
50
|
+
display: block;
|
|
51
|
+
content: "";
|
|
52
|
+
position: absolute;
|
|
53
|
+
left: 7px;
|
|
54
|
+
top: 50%;
|
|
55
|
+
transform-origin: 2px 50%;
|
|
56
|
+
transform: translateY(-50%);
|
|
57
|
+
transition: transform 0.3s ease;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
.Collapsible__container[open] > .Collapsible__title:before {
|
|
61
|
+
transform: translateY(-50%) rotate(90deg);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.Collapsible__content {
|
|
65
|
+
padding: 10px 5px 5px 20px;
|
|
66
|
+
box-sizing: border-box;
|
|
67
|
+
interpolate-size: allow-keywords;
|
|
68
|
+
overflow: hidden;
|
|
69
|
+
transition:
|
|
70
|
+
height 0.3s ease-out,
|
|
71
|
+
opacity 0.3s ease-out,
|
|
72
|
+
visibility 0.3s ease-out allow-discrete;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* Chrome uses div with hidden attribute — override display to allow animation */
|
|
76
|
+
.Collapsible__content[hidden] {
|
|
77
|
+
content-visibility: visible;
|
|
78
|
+
display: block;
|
|
79
|
+
height: 0;
|
|
80
|
+
opacity: 0;
|
|
81
|
+
visibility: hidden;
|
|
82
|
+
pointer-events: none;
|
|
83
|
+
transition:
|
|
84
|
+
height 0.3s ease-in,
|
|
85
|
+
opacity 0.3s ease-in,
|
|
86
|
+
visibility 0.3s ease-in allow-discrete;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/* Non-Chrome uses <details> — animate via ::details-content */
|
|
90
|
+
details.Collapsible__container::details-content {
|
|
91
|
+
interpolate-size: allow-keywords;
|
|
92
|
+
overflow: hidden;
|
|
93
|
+
transition:
|
|
94
|
+
height 0.3s ease-out,
|
|
95
|
+
opacity 0.3s ease-out,
|
|
96
|
+
content-visibility 0.3s ease-out allow-discrete;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
details.Collapsible__container:not([open])::details-content {
|
|
100
|
+
height: 0;
|
|
101
|
+
opacity: 0;
|
|
102
|
+
content-visibility: hidden;
|
|
103
|
+
transition:
|
|
104
|
+
height 0.3s ease-in,
|
|
105
|
+
opacity 0.3s ease-in,
|
|
106
|
+
content-visibility 0.3s ease-in allow-discrete;
|
|
107
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
$getDocument,
|
|
12
|
+
$getSiblingCaret,
|
|
13
|
+
$isElementNode,
|
|
14
|
+
$rewindSiblingCaret,
|
|
15
|
+
type DOMExportOutput,
|
|
16
|
+
type EditorConfig,
|
|
17
|
+
ElementNode,
|
|
18
|
+
IS_CHROME,
|
|
19
|
+
IS_FIREFOX,
|
|
20
|
+
isHTMLElement,
|
|
21
|
+
type LexicalEditor,
|
|
22
|
+
type LexicalNode,
|
|
23
|
+
type NodeKey,
|
|
24
|
+
type RangeSelection,
|
|
25
|
+
type SerializedElementNode,
|
|
26
|
+
type Spread,
|
|
27
|
+
} from "lexical";
|
|
28
|
+
|
|
29
|
+
import { setDomHiddenUntilFound } from "./CollapsibleUtils";
|
|
30
|
+
|
|
31
|
+
type SerializedCollapsibleContainerNode = Spread<
|
|
32
|
+
{
|
|
33
|
+
open: boolean;
|
|
34
|
+
},
|
|
35
|
+
SerializedElementNode
|
|
36
|
+
>;
|
|
37
|
+
|
|
38
|
+
export class CollapsibleContainerNode extends ElementNode {
|
|
39
|
+
__open: boolean;
|
|
40
|
+
|
|
41
|
+
constructor(open: boolean, key?: NodeKey) {
|
|
42
|
+
super(key);
|
|
43
|
+
this.__open = open;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
$config() {
|
|
47
|
+
return this.config("collapsible-container", { extends: ElementNode });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
static clone(node: CollapsibleContainerNode): CollapsibleContainerNode {
|
|
51
|
+
return new CollapsibleContainerNode(node.__open, node.__key);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
isShadowRoot(): boolean {
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
collapseAtStart(selection: RangeSelection): boolean {
|
|
59
|
+
// Unwrap the CollapsibleContainerNode by replacing it with the children
|
|
60
|
+
// of its children (CollapsibleTitleNode, CollapsibleContentNode)
|
|
61
|
+
const nodesToInsert: LexicalNode[] = [];
|
|
62
|
+
for (const child of this.getChildren()) {
|
|
63
|
+
if ($isElementNode(child)) {
|
|
64
|
+
nodesToInsert.push(...child.getChildren());
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const caret = $rewindSiblingCaret($getSiblingCaret(this, "previous"));
|
|
68
|
+
caret.splice(1, nodesToInsert);
|
|
69
|
+
// Merge the first child of the CollapsibleTitleNode with the
|
|
70
|
+
// previous sibling of the CollapsibleContainerNode
|
|
71
|
+
const [firstChild] = nodesToInsert;
|
|
72
|
+
if (firstChild) {
|
|
73
|
+
firstChild.selectStart().deleteCharacter(true);
|
|
74
|
+
}
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
createDOM(config: EditorConfig, editor: LexicalEditor): HTMLElement {
|
|
79
|
+
// details is not well supported in Chrome #5582 and Firefox #8348
|
|
80
|
+
let dom: HTMLElement;
|
|
81
|
+
if (IS_CHROME || IS_FIREFOX) {
|
|
82
|
+
dom = $getDocument().createElement("div");
|
|
83
|
+
dom.setAttribute("open", "");
|
|
84
|
+
} else {
|
|
85
|
+
const detailsDom = $getDocument().createElement("details");
|
|
86
|
+
detailsDom.open = this.__open;
|
|
87
|
+
detailsDom.addEventListener("toggle", () => {
|
|
88
|
+
const open = editor.read("latest", () => this.getOpen());
|
|
89
|
+
if (open !== detailsDom.open) {
|
|
90
|
+
editor.update(() => this.toggleOpen());
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
dom = detailsDom;
|
|
94
|
+
}
|
|
95
|
+
dom.classList.add("Collapsible__container");
|
|
96
|
+
|
|
97
|
+
return dom;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
updateDOM(prevNode: this, dom: HTMLDetailsElement): boolean {
|
|
101
|
+
const currentOpen = this.__open;
|
|
102
|
+
if (prevNode.__open !== currentOpen) {
|
|
103
|
+
// details is not well supported in Chrome #5582 and Firefox #8348
|
|
104
|
+
if (IS_CHROME || IS_FIREFOX) {
|
|
105
|
+
// Look up the content element by class rather than positional index.
|
|
106
|
+
// The shape `Title + Content` is invariant per the structure-enforcing
|
|
107
|
+
// transformer; if a slot-aware extension prepends a leading
|
|
108
|
+
// decoration (via `slot.after`) the content child would no longer sit
|
|
109
|
+
// at `children[1]`. Scoped `:scope >` avoids matching content of a
|
|
110
|
+
// nested CollapsibleContainer.
|
|
111
|
+
const contentDom = dom.querySelector(":scope > .Collapsible__content");
|
|
112
|
+
if (!isHTMLElement(contentDom)) {
|
|
113
|
+
throw new Error("Expected contentDom to be an HTMLElement");
|
|
114
|
+
}
|
|
115
|
+
if (currentOpen) {
|
|
116
|
+
dom.setAttribute("open", "");
|
|
117
|
+
contentDom.hidden = false;
|
|
118
|
+
} else {
|
|
119
|
+
dom.removeAttribute("open");
|
|
120
|
+
setDomHiddenUntilFound(contentDom);
|
|
121
|
+
}
|
|
122
|
+
} else {
|
|
123
|
+
dom.open = this.__open;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return false;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
static importJSON(serializedNode: SerializedCollapsibleContainerNode): CollapsibleContainerNode {
|
|
131
|
+
return $createCollapsibleContainerNode(serializedNode.open).updateFromJSON(serializedNode);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
exportDOM(): DOMExportOutput {
|
|
135
|
+
const element = $getDocument().createElement("details");
|
|
136
|
+
element.classList.add("Collapsible__container");
|
|
137
|
+
// `open` is an HTML boolean attribute — its presence is what makes the
|
|
138
|
+
// <details> open, whatever its value. Writing `open="false"` on a closed
|
|
139
|
+
// container reads back (and renders) as open, so omit it instead. This
|
|
140
|
+
// matches createDOM/updateDOM, which already set '' / removeAttribute.
|
|
141
|
+
if (this.__open) {
|
|
142
|
+
element.setAttribute("open", "");
|
|
143
|
+
}
|
|
144
|
+
return { element };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
exportJSON(): SerializedCollapsibleContainerNode {
|
|
148
|
+
return {
|
|
149
|
+
...super.exportJSON(),
|
|
150
|
+
open: this.__open,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
setOpen(open: boolean): this {
|
|
155
|
+
const writable = this.getWritable();
|
|
156
|
+
writable.__open = open;
|
|
157
|
+
return writable;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
getOpen(): boolean {
|
|
161
|
+
return this.getLatest().__open;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
toggleOpen(): this {
|
|
165
|
+
return this.setOpen(!this.getOpen());
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function $createCollapsibleContainerNode(isOpen: boolean): CollapsibleContainerNode {
|
|
170
|
+
return new CollapsibleContainerNode(isOpen);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function $isCollapsibleContainerNode(
|
|
174
|
+
node: LexicalNode | null | undefined
|
|
175
|
+
): node is CollapsibleContainerNode {
|
|
176
|
+
return node instanceof CollapsibleContainerNode;
|
|
177
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
$getDocument,
|
|
12
|
+
type DOMExportOutput,
|
|
13
|
+
type EditorConfig,
|
|
14
|
+
ElementNode,
|
|
15
|
+
IS_CHROME,
|
|
16
|
+
IS_FIREFOX,
|
|
17
|
+
type LexicalEditor,
|
|
18
|
+
type LexicalNode,
|
|
19
|
+
} from "lexical";
|
|
20
|
+
|
|
21
|
+
import { $isCollapsibleContainerNode } from "./CollapsibleContainerNode";
|
|
22
|
+
import { domOnBeforeMatch, setDomHiddenUntilFound } from "./CollapsibleUtils";
|
|
23
|
+
|
|
24
|
+
export class CollapsibleContentNode extends ElementNode {
|
|
25
|
+
$config() {
|
|
26
|
+
return this.config("collapsible-content", { extends: ElementNode });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
createDOM(config: EditorConfig, editor: LexicalEditor): HTMLElement {
|
|
30
|
+
const dom = $getDocument().createElement("div");
|
|
31
|
+
dom.classList.add("Collapsible__content");
|
|
32
|
+
if (IS_CHROME || IS_FIREFOX) {
|
|
33
|
+
editor.read("latest", () => {
|
|
34
|
+
const containerNode = this.getParentOrThrow();
|
|
35
|
+
if (!$isCollapsibleContainerNode(containerNode)) {
|
|
36
|
+
throw new Error("Expected parent node to be a CollapsibleContainerNode");
|
|
37
|
+
}
|
|
38
|
+
if (!containerNode.getOpen()) {
|
|
39
|
+
setDomHiddenUntilFound(dom);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
domOnBeforeMatch(dom, () => {
|
|
43
|
+
editor.update(() => {
|
|
44
|
+
const containerNode = this.getParentOrThrow().getLatest();
|
|
45
|
+
if (!$isCollapsibleContainerNode(containerNode)) {
|
|
46
|
+
throw new Error("Expected parent node to be a CollapsibleContainerNode");
|
|
47
|
+
}
|
|
48
|
+
if (!containerNode.getOpen()) {
|
|
49
|
+
containerNode.toggleOpen();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return dom;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
updateDOM(prevNode: this, dom: HTMLElement): boolean {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
exportDOM(): DOMExportOutput {
|
|
62
|
+
const element = $getDocument().createElement("div");
|
|
63
|
+
element.classList.add("Collapsible__content");
|
|
64
|
+
element.setAttribute("data-lexical-collapsible-content", "true");
|
|
65
|
+
return { element };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
isShadowRoot(): boolean {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function $createCollapsibleContentNode(): CollapsibleContentNode {
|
|
74
|
+
return new CollapsibleContentNode();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function $isCollapsibleContentNode(
|
|
78
|
+
node: LexicalNode | null | undefined
|
|
79
|
+
): node is CollapsibleContentNode {
|
|
80
|
+
return node instanceof CollapsibleContentNode;
|
|
81
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/* eslint-disable @typescript-eslint/no-unused-vars */
|
|
2
|
+
/**
|
|
3
|
+
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
4
|
+
*
|
|
5
|
+
* This source code is licensed under the MIT license found in the
|
|
6
|
+
* LICENSE file in the root directory of this source tree.
|
|
7
|
+
*
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
$createParagraphNode,
|
|
12
|
+
$getDocument,
|
|
13
|
+
$isElementNode,
|
|
14
|
+
type EditorConfig,
|
|
15
|
+
ElementNode,
|
|
16
|
+
IS_CHROME,
|
|
17
|
+
IS_FIREFOX,
|
|
18
|
+
type LexicalEditor,
|
|
19
|
+
type LexicalNode,
|
|
20
|
+
type RangeSelection,
|
|
21
|
+
} from "lexical";
|
|
22
|
+
|
|
23
|
+
import { $isCollapsibleContainerNode } from "./CollapsibleContainerNode";
|
|
24
|
+
import { $isCollapsibleContentNode } from "./CollapsibleContentNode";
|
|
25
|
+
|
|
26
|
+
/** @noInheritDoc */
|
|
27
|
+
export class CollapsibleTitleNode extends ElementNode {
|
|
28
|
+
/** @internal */
|
|
29
|
+
$config() {
|
|
30
|
+
return this.config("collapsible-title", {
|
|
31
|
+
$transform(node: CollapsibleTitleNode) {
|
|
32
|
+
if (node.isEmpty()) {
|
|
33
|
+
node.remove();
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
extends: ElementNode,
|
|
37
|
+
});
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
createDOM(config: EditorConfig, editor: LexicalEditor): HTMLElement {
|
|
41
|
+
const dom = $getDocument().createElement("summary");
|
|
42
|
+
dom.classList.add("Collapsible__title");
|
|
43
|
+
if (IS_CHROME || IS_FIREFOX) {
|
|
44
|
+
dom.addEventListener("click", () => {
|
|
45
|
+
editor.update(() => {
|
|
46
|
+
const collapsibleContainer = this.getLatest().getParentOrThrow();
|
|
47
|
+
if (!$isCollapsibleContainerNode(collapsibleContainer)) {
|
|
48
|
+
throw new Error("Expected parent node to be a CollapsibleContainerNode");
|
|
49
|
+
}
|
|
50
|
+
collapsibleContainer.toggleOpen();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return dom;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
updateDOM(prevNode: this, dom: HTMLElement): boolean {
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
insertNewAfter(_: RangeSelection, restoreSelection = true): ElementNode {
|
|
62
|
+
const containerNode = this.getParentOrThrow();
|
|
63
|
+
|
|
64
|
+
if (!$isCollapsibleContainerNode(containerNode)) {
|
|
65
|
+
throw new Error("CollapsibleTitleNode expects to be child of CollapsibleContainerNode");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (containerNode.getOpen()) {
|
|
69
|
+
const contentNode = this.getNextSibling();
|
|
70
|
+
if (!$isCollapsibleContentNode(contentNode)) {
|
|
71
|
+
throw new Error("CollapsibleTitleNode expects to have CollapsibleContentNode sibling");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const firstChild = contentNode.getFirstChild();
|
|
75
|
+
if ($isElementNode(firstChild)) {
|
|
76
|
+
return firstChild;
|
|
77
|
+
} else {
|
|
78
|
+
const paragraph = $createParagraphNode();
|
|
79
|
+
contentNode.append(paragraph);
|
|
80
|
+
return paragraph;
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
const paragraph = $createParagraphNode();
|
|
84
|
+
containerNode.insertAfter(paragraph, restoreSelection);
|
|
85
|
+
return paragraph;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function $createCollapsibleTitleNode(): CollapsibleTitleNode {
|
|
91
|
+
return new CollapsibleTitleNode();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function $isCollapsibleTitleNode(
|
|
95
|
+
node: LexicalNode | null | undefined
|
|
96
|
+
): node is CollapsibleTitleNode {
|
|
97
|
+
return node instanceof CollapsibleTitleNode;
|
|
98
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
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
|
+
export function setDomHiddenUntilFound(dom: HTMLElement): void {
|
|
10
|
+
dom.hidden = "until-found";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function domOnBeforeMatch(dom: HTMLElement, callback: () => void): void {
|
|
14
|
+
dom.onbeforematch = callback;
|
|
15
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
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 "./Collapsible.css";
|
|
10
|
+
|
|
11
|
+
import { BlockSchema, defineImportRule, DOMImportExtension, sel } from "@lexical/html";
|
|
12
|
+
import { $insertNodeToNearestRoot } from "@lexical/utils";
|
|
13
|
+
import {
|
|
14
|
+
$createParagraphNode,
|
|
15
|
+
$findMatchingParent,
|
|
16
|
+
$getSelection,
|
|
17
|
+
$isRangeSelection,
|
|
18
|
+
COMMAND_PRIORITY_LOW,
|
|
19
|
+
configExtension,
|
|
20
|
+
createCommand,
|
|
21
|
+
defineExtension,
|
|
22
|
+
INSERT_PARAGRAPH_COMMAND,
|
|
23
|
+
KEY_ARROW_DOWN_COMMAND,
|
|
24
|
+
KEY_ARROW_LEFT_COMMAND,
|
|
25
|
+
KEY_ARROW_RIGHT_COMMAND,
|
|
26
|
+
KEY_ARROW_UP_COMMAND,
|
|
27
|
+
type LexicalEditor,
|
|
28
|
+
type LexicalNode,
|
|
29
|
+
mergeRegister,
|
|
30
|
+
} from "lexical";
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
$createCollapsibleContainerNode,
|
|
34
|
+
$isCollapsibleContainerNode,
|
|
35
|
+
CollapsibleContainerNode,
|
|
36
|
+
} from "./CollapsibleContainerNode";
|
|
37
|
+
import {
|
|
38
|
+
$createCollapsibleContentNode,
|
|
39
|
+
$isCollapsibleContentNode,
|
|
40
|
+
CollapsibleContentNode,
|
|
41
|
+
} from "./CollapsibleContentNode";
|
|
42
|
+
import {
|
|
43
|
+
$createCollapsibleTitleNode,
|
|
44
|
+
$isCollapsibleTitleNode,
|
|
45
|
+
CollapsibleTitleNode,
|
|
46
|
+
} from "./CollapsibleTitleNode";
|
|
47
|
+
|
|
48
|
+
export {
|
|
49
|
+
$createCollapsibleContainerNode,
|
|
50
|
+
$isCollapsibleContainerNode,
|
|
51
|
+
CollapsibleContainerNode,
|
|
52
|
+
$createCollapsibleContentNode,
|
|
53
|
+
$isCollapsibleContentNode,
|
|
54
|
+
CollapsibleContentNode,
|
|
55
|
+
$createCollapsibleTitleNode,
|
|
56
|
+
$isCollapsibleTitleNode,
|
|
57
|
+
CollapsibleTitleNode,
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const SummaryRule = /* @__PURE__ */ defineImportRule({
|
|
61
|
+
$import: (ctx, el) => [$createCollapsibleTitleNode().splice(0, 0, ctx.$importChildren(el))],
|
|
62
|
+
match: sel.tag("summary"),
|
|
63
|
+
name: "@lexical/playground/summary",
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const CollapsibleContentRule = /* @__PURE__ */ defineImportRule({
|
|
67
|
+
$import: (ctx, el) => [
|
|
68
|
+
$createCollapsibleContentNode().splice(0, 0, ctx.$importChildren(el, { schema: BlockSchema })),
|
|
69
|
+
],
|
|
70
|
+
match: sel.tag("div").attr("data-lexical-collapsible-content", true),
|
|
71
|
+
name: "@lexical/playground/collapsible-content",
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const DetailsRule = /* @__PURE__ */ defineImportRule({
|
|
75
|
+
$import: (ctx, el) => {
|
|
76
|
+
let titleNode: CollapsibleTitleNode | null = null;
|
|
77
|
+
// BlockSchema wraps inline runs in paragraphs, and `$onChild` siphons
|
|
78
|
+
// the synthesized CollapsibleTitleNode out before it ever reaches the
|
|
79
|
+
// ContentNode below. CollapsibleContentNode is itself block-level so
|
|
80
|
+
// BlockSchema leaves it intact in `bodyNodes`.
|
|
81
|
+
const bodyNodes = ctx.$importChildren(el, {
|
|
82
|
+
$onChild: (child) => {
|
|
83
|
+
if (titleNode === null && $isCollapsibleTitleNode(child)) {
|
|
84
|
+
titleNode = child;
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
return child;
|
|
88
|
+
},
|
|
89
|
+
schema: BlockSchema,
|
|
90
|
+
});
|
|
91
|
+
let contentNode: CollapsibleContentNode | null = null;
|
|
92
|
+
const restBody: LexicalNode[] = [];
|
|
93
|
+
for (const child of bodyNodes) {
|
|
94
|
+
if ($isCollapsibleContentNode(child)) {
|
|
95
|
+
if (contentNode === null) {
|
|
96
|
+
contentNode = child;
|
|
97
|
+
} else {
|
|
98
|
+
// Multiple content nodes (rare): fold the extras into restBody so
|
|
99
|
+
// they get appended to the canonical one below.
|
|
100
|
+
for (const grand of child.getChildren()) {
|
|
101
|
+
restBody.push(grand);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
restBody.push(child);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
if (titleNode === null) {
|
|
109
|
+
titleNode = $createCollapsibleTitleNode();
|
|
110
|
+
}
|
|
111
|
+
if (contentNode === null) {
|
|
112
|
+
contentNode = $createCollapsibleContentNode();
|
|
113
|
+
}
|
|
114
|
+
for (const node of restBody) {
|
|
115
|
+
contentNode.append(node);
|
|
116
|
+
}
|
|
117
|
+
return [$createCollapsibleContainerNode(el.open).append(titleNode, contentNode)];
|
|
118
|
+
},
|
|
119
|
+
match: sel.tag("details"),
|
|
120
|
+
name: "@lexical/playground/details",
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
export const INSERT_COLLAPSIBLE_COMMAND = /* @__PURE__ */ createCommand<void>(
|
|
124
|
+
"INSERT_COLLAPSIBLE_COMMAND"
|
|
125
|
+
);
|
|
126
|
+
|
|
127
|
+
export const COLLAPSIBLE_NODES = [
|
|
128
|
+
CollapsibleContainerNode,
|
|
129
|
+
CollapsibleTitleNode,
|
|
130
|
+
CollapsibleContentNode,
|
|
131
|
+
] as const;
|
|
132
|
+
|
|
133
|
+
const $onEscapeUp = () => {
|
|
134
|
+
const selection = $getSelection();
|
|
135
|
+
if ($isRangeSelection(selection) && selection.isCollapsed() && selection.anchor.offset === 0) {
|
|
136
|
+
const container = $findMatchingParent(selection.anchor.getNode(), $isCollapsibleContainerNode);
|
|
137
|
+
|
|
138
|
+
if ($isCollapsibleContainerNode(container)) {
|
|
139
|
+
const parent = container.getParent();
|
|
140
|
+
if (
|
|
141
|
+
parent !== null &&
|
|
142
|
+
parent.getFirstChild() === container &&
|
|
143
|
+
selection.anchor.key === container.getFirstDescendant()?.getKey()
|
|
144
|
+
) {
|
|
145
|
+
container.insertBefore($createParagraphNode());
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return false;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const $onEscapeDown = () => {
|
|
154
|
+
const selection = $getSelection();
|
|
155
|
+
if ($isRangeSelection(selection) && selection.isCollapsed()) {
|
|
156
|
+
const container = $findMatchingParent(selection.anchor.getNode(), $isCollapsibleContainerNode);
|
|
157
|
+
|
|
158
|
+
if ($isCollapsibleContainerNode(container)) {
|
|
159
|
+
const parent = container.getParent();
|
|
160
|
+
if (parent !== null && parent.getLastChild() === container) {
|
|
161
|
+
const titleParagraph = container.getFirstDescendant();
|
|
162
|
+
const contentParagraph = container.getLastDescendant();
|
|
163
|
+
|
|
164
|
+
if (
|
|
165
|
+
(contentParagraph !== null &&
|
|
166
|
+
selection.anchor.key === contentParagraph.getKey() &&
|
|
167
|
+
selection.anchor.offset === contentParagraph.getTextContentSize()) ||
|
|
168
|
+
(titleParagraph !== null &&
|
|
169
|
+
selection.anchor.key === titleParagraph.getKey() &&
|
|
170
|
+
selection.anchor.offset === titleParagraph.getTextContentSize() &&
|
|
171
|
+
!container.getOpen())
|
|
172
|
+
) {
|
|
173
|
+
container.insertAfter($createParagraphNode());
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
return false;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
export const registerCollapsibleExtension = (editor: LexicalEditor) =>
|
|
183
|
+
mergeRegister(
|
|
184
|
+
// Structure enforcing transformers for each node type. In case nesting structure is not
|
|
185
|
+
// "Container > Title + Content" it'll unwrap nodes and convert it back
|
|
186
|
+
// to regular content.
|
|
187
|
+
editor.registerNodeTransform(CollapsibleContentNode, (node) => {
|
|
188
|
+
const parent = node.getParent();
|
|
189
|
+
if (!$isCollapsibleContainerNode(parent)) {
|
|
190
|
+
const children = node.getChildren();
|
|
191
|
+
for (const child of children) {
|
|
192
|
+
node.insertBefore(child);
|
|
193
|
+
}
|
|
194
|
+
node.remove();
|
|
195
|
+
} else if (node.isEmpty()) {
|
|
196
|
+
node.append($createParagraphNode());
|
|
197
|
+
}
|
|
198
|
+
}),
|
|
199
|
+
|
|
200
|
+
editor.registerNodeTransform(CollapsibleTitleNode, (node) => {
|
|
201
|
+
const parent = node.getParent();
|
|
202
|
+
if (!$isCollapsibleContainerNode(parent)) {
|
|
203
|
+
node.replace($createParagraphNode().append(...node.getChildren()));
|
|
204
|
+
}
|
|
205
|
+
}),
|
|
206
|
+
|
|
207
|
+
editor.registerNodeTransform(CollapsibleContainerNode, (node) => {
|
|
208
|
+
const children = node.getChildren();
|
|
209
|
+
if (
|
|
210
|
+
children.length !== 2 ||
|
|
211
|
+
!$isCollapsibleTitleNode(children[0]) ||
|
|
212
|
+
!$isCollapsibleContentNode(children[1])
|
|
213
|
+
) {
|
|
214
|
+
for (const child of children) {
|
|
215
|
+
node.insertBefore(child);
|
|
216
|
+
}
|
|
217
|
+
node.remove();
|
|
218
|
+
}
|
|
219
|
+
}),
|
|
220
|
+
|
|
221
|
+
// When collapsible is the last child pressing down/right arrow will insert paragraph
|
|
222
|
+
// below it to allow adding more content. It's similar what $insertBlockNode
|
|
223
|
+
// (mainly for decorators), except it'll always be possible to continue adding
|
|
224
|
+
// new content even if trailing paragraph is accidentally deleted
|
|
225
|
+
editor.registerCommand(KEY_ARROW_DOWN_COMMAND, $onEscapeDown, COMMAND_PRIORITY_LOW),
|
|
226
|
+
|
|
227
|
+
editor.registerCommand(KEY_ARROW_RIGHT_COMMAND, $onEscapeDown, COMMAND_PRIORITY_LOW),
|
|
228
|
+
|
|
229
|
+
// When collapsible is the first child pressing up/left arrow will insert paragraph
|
|
230
|
+
// above it to allow adding more content. It's similar what $insertBlockNode
|
|
231
|
+
// (mainly for decorators), except it'll always be possible to continue adding
|
|
232
|
+
// new content even if leading paragraph is accidentally deleted
|
|
233
|
+
editor.registerCommand(KEY_ARROW_UP_COMMAND, $onEscapeUp, COMMAND_PRIORITY_LOW),
|
|
234
|
+
|
|
235
|
+
editor.registerCommand(KEY_ARROW_LEFT_COMMAND, $onEscapeUp, COMMAND_PRIORITY_LOW),
|
|
236
|
+
|
|
237
|
+
// Enter goes from Title to Content rather than a new line inside Title
|
|
238
|
+
editor.registerCommand(
|
|
239
|
+
INSERT_PARAGRAPH_COMMAND,
|
|
240
|
+
() => {
|
|
241
|
+
const selection = $getSelection();
|
|
242
|
+
if ($isRangeSelection(selection)) {
|
|
243
|
+
const titleNode = $findMatchingParent(selection.anchor.getNode(), (node) =>
|
|
244
|
+
$isCollapsibleTitleNode(node)
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
if ($isCollapsibleTitleNode(titleNode)) {
|
|
248
|
+
const container = titleNode.getParent();
|
|
249
|
+
if (container && $isCollapsibleContainerNode(container)) {
|
|
250
|
+
if (!container.getOpen()) {
|
|
251
|
+
container.toggleOpen();
|
|
252
|
+
}
|
|
253
|
+
titleNode.getNextSibling()?.selectEnd();
|
|
254
|
+
return true;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
return false;
|
|
260
|
+
},
|
|
261
|
+
COMMAND_PRIORITY_LOW
|
|
262
|
+
),
|
|
263
|
+
editor.registerCommand(
|
|
264
|
+
INSERT_COLLAPSIBLE_COMMAND,
|
|
265
|
+
() => {
|
|
266
|
+
editor.update(() => {
|
|
267
|
+
const title = $createCollapsibleTitleNode();
|
|
268
|
+
const paragraph = $createParagraphNode();
|
|
269
|
+
$insertNodeToNearestRoot(
|
|
270
|
+
$createCollapsibleContainerNode(true).append(
|
|
271
|
+
title.append(paragraph),
|
|
272
|
+
$createCollapsibleContentNode().append($createParagraphNode())
|
|
273
|
+
)
|
|
274
|
+
);
|
|
275
|
+
paragraph.select();
|
|
276
|
+
});
|
|
277
|
+
return true;
|
|
278
|
+
},
|
|
279
|
+
COMMAND_PRIORITY_LOW
|
|
280
|
+
)
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
export const CollapsibleExtension = /* @__PURE__ */ defineExtension({
|
|
284
|
+
dependencies: [
|
|
285
|
+
/* @__PURE__ */ configExtension(DOMImportExtension, {
|
|
286
|
+
rules: [DetailsRule, SummaryRule, CollapsibleContentRule],
|
|
287
|
+
}),
|
|
288
|
+
],
|
|
289
|
+
name: "@lexical/playground/Collapsible",
|
|
290
|
+
nodes: COLLAPSIBLE_NODES,
|
|
291
|
+
register: registerCollapsibleExtension,
|
|
292
|
+
});
|
|
@@ -35,6 +35,7 @@ import {
|
|
|
35
35
|
formatIndent,
|
|
36
36
|
formatNumberedList,
|
|
37
37
|
formatOutdent,
|
|
38
|
+
insertCollapsible,
|
|
38
39
|
} from "./utils";
|
|
39
40
|
import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
|
|
40
41
|
import "./styles.css";
|
|
@@ -42,13 +43,16 @@ import { SHORTCUTS } from "../ShortcutsPlugin/shortcuts";
|
|
|
42
43
|
import { blockTypeToBlockName, useToolbarState } from "../../context/ToolbarContext";
|
|
43
44
|
import { IndentIcon } from "../../icons/IndentIcon";
|
|
44
45
|
import { OutdentIcon } from "../../icons/OutdentIcon";
|
|
46
|
+
import { CollapsibleIcon } from "../../icons/CollapsibleIcon";
|
|
45
47
|
|
|
46
48
|
export default function ToolbarPlugin({
|
|
47
49
|
editorId,
|
|
48
50
|
setIsLinkEditMode,
|
|
51
|
+
enableCollapsibleBlocks,
|
|
49
52
|
}: {
|
|
50
53
|
editorId: string;
|
|
51
54
|
setIsLinkEditMode: (isLinkEditMode: boolean) => void;
|
|
55
|
+
enableCollapsibleBlocks: boolean;
|
|
52
56
|
}) {
|
|
53
57
|
const [editor] = useLexicalComposerContext();
|
|
54
58
|
|
|
@@ -69,17 +73,7 @@ export default function ToolbarPlugin({
|
|
|
69
73
|
}
|
|
70
74
|
}, [activeEditor, setIsLinkEditMode, toolbarState.isLink]);
|
|
71
75
|
|
|
72
|
-
const
|
|
73
|
-
{ id: 1, txt: "heading2" },
|
|
74
|
-
{ id: 2, txt: "heading3" },
|
|
75
|
-
{ id: 3, txt: "bold" },
|
|
76
|
-
{ id: 4, txt: "italic" },
|
|
77
|
-
{ id: 5, txt: "bulletedList" },
|
|
78
|
-
{ id: 6, txt: "numberedList" },
|
|
79
|
-
{ id: 7, txt: "link" },
|
|
80
|
-
{ id: 8, txt: "indent" },
|
|
81
|
-
{ id: 9, txt: "outdent" },
|
|
82
|
-
]);
|
|
76
|
+
const toolbarItemCount = enableCollapsibleBlocks ? 10 : 9;
|
|
83
77
|
|
|
84
78
|
const itemsRef = useRef<[HTMLButtonElement] | []>([]);
|
|
85
79
|
const [currentFocusIndex, setCurrentFocusIndex] = useState(0);
|
|
@@ -107,10 +101,10 @@ export default function ToolbarPlugin({
|
|
|
107
101
|
setCurrentFocusIndex((index) => Math.max(0, index - 1));
|
|
108
102
|
} else if (key === "ArrowRight") {
|
|
109
103
|
evt.preventDefault();
|
|
110
|
-
setCurrentFocusIndex((index) => Math.min(
|
|
104
|
+
setCurrentFocusIndex((index) => Math.min(toolbarItemCount - 1, index + 1));
|
|
111
105
|
}
|
|
112
106
|
},
|
|
113
|
-
[
|
|
107
|
+
[setCurrentFocusIndex, setToolbarInit, toolbarInit, toolbarItemCount]
|
|
114
108
|
);
|
|
115
109
|
|
|
116
110
|
const $updateToolbar = useCallback(() => {
|
|
@@ -419,6 +413,25 @@ export default function ToolbarPlugin({
|
|
|
419
413
|
<OutdentIcon />
|
|
420
414
|
</button>
|
|
421
415
|
</ToolTip>
|
|
416
|
+
|
|
417
|
+
{enableCollapsibleBlocks && (
|
|
418
|
+
<ToolTip text={t("tooltipInsertCollapsible")}>
|
|
419
|
+
<button
|
|
420
|
+
tabIndex={currentFocusIndex == 9 ? 0 : -1}
|
|
421
|
+
ref={(el) => {
|
|
422
|
+
const index = "button-9" as unknown as number;
|
|
423
|
+
if (el && itemsRef.current) itemsRef.current[index] = el;
|
|
424
|
+
}}
|
|
425
|
+
disabled={!isEditable}
|
|
426
|
+
onClick={() => insertCollapsible(editor)}
|
|
427
|
+
className="toolbar-item"
|
|
428
|
+
aria-label={t("insertCollapsible")}
|
|
429
|
+
data-testid="collapsible-button"
|
|
430
|
+
>
|
|
431
|
+
<CollapsibleIcon />
|
|
432
|
+
</button>
|
|
433
|
+
</ToolTip>
|
|
434
|
+
)}
|
|
422
435
|
</div>
|
|
423
436
|
</>
|
|
424
437
|
);
|
|
@@ -30,6 +30,7 @@ import {
|
|
|
30
30
|
LexicalEditor,
|
|
31
31
|
} from "lexical";
|
|
32
32
|
import { INDENT_CONTENT_COMMAND, OUTDENT_CONTENT_COMMAND } from "lexical";
|
|
33
|
+
import { INSERT_COLLAPSIBLE_COMMAND } from "../CollapsibleExtension";
|
|
33
34
|
|
|
34
35
|
export const formatParagraph = (editor: LexicalEditor) => {
|
|
35
36
|
editor.update(() => {
|
|
@@ -179,3 +180,7 @@ export const formatIndent = (editor: LexicalEditor) => {
|
|
|
179
180
|
export const formatOutdent = (editor: LexicalEditor) => {
|
|
180
181
|
editor.dispatchCommand(OUTDENT_CONTENT_COMMAND, undefined);
|
|
181
182
|
};
|
|
183
|
+
|
|
184
|
+
export const insertCollapsible = (editor: LexicalEditor) => {
|
|
185
|
+
editor.dispatchCommand(INSERT_COLLAPSIBLE_COMMAND, undefined);
|
|
186
|
+
};
|
|
@@ -18,7 +18,12 @@ describe("Lexical Editor", () => {
|
|
|
18
18
|
it("Renders the Lexical Editor", async () => {
|
|
19
19
|
//
|
|
20
20
|
const rendered = render(
|
|
21
|
-
<Editor
|
|
21
|
+
<Editor
|
|
22
|
+
ariaLabel="AriaLabel"
|
|
23
|
+
ariaDescribedBy="AriaDescribedBy"
|
|
24
|
+
id="editor-test"
|
|
25
|
+
content="Here is some test content"
|
|
26
|
+
/>
|
|
22
27
|
);
|
|
23
28
|
|
|
24
29
|
await act(async () => {
|
|
@@ -45,8 +50,9 @@ describe("Lexical Editor", () => {
|
|
|
45
50
|
// Toolbar has aria-controls attribute
|
|
46
51
|
expect(toolbar).toHaveAttribute("aria-controls", contentArea.id);
|
|
47
52
|
|
|
48
|
-
//
|
|
53
|
+
// Collapsible content is disabled by default.
|
|
49
54
|
expect(toolbarButtons).toHaveLength(9);
|
|
55
|
+
expect(within(toolbar).queryByTestId("collapsible-button")).not.toBeInTheDocument();
|
|
50
56
|
|
|
51
57
|
// Content area has default content and attributes
|
|
52
58
|
expect(contentArea).toHaveAttribute("aria-label", "AriaLabel");
|
|
@@ -57,9 +63,60 @@ describe("Lexical Editor", () => {
|
|
|
57
63
|
expect(contentArea).toHaveAttribute("data-lexical-editor", "true");
|
|
58
64
|
});
|
|
59
65
|
|
|
66
|
+
it("can insert collapsible content from the toolbar", async () => {
|
|
67
|
+
const onChange = vi.fn();
|
|
68
|
+
const rendered = render(
|
|
69
|
+
<Editor
|
|
70
|
+
id="editor-test"
|
|
71
|
+
content="Here is some content"
|
|
72
|
+
onChange={onChange}
|
|
73
|
+
enableCollapsibleBlocks
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
|
|
77
|
+
await act(async () => {
|
|
78
|
+
await promise;
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
await userEvent.click(screen.getByTestId("collapsible-button"));
|
|
82
|
+
|
|
83
|
+
expect(rendered.container.querySelector(".Collapsible__container")).toBeInTheDocument();
|
|
84
|
+
expect(rendered.container.querySelector(".Collapsible__title")).toBeInTheDocument();
|
|
85
|
+
expect(rendered.container.querySelector(".Collapsible__content")).toBeInTheDocument();
|
|
86
|
+
expect(onChange).toHaveBeenLastCalledWith(expect.stringContaining(":::collapsible"));
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("renders collapsible Markdown blocks", async () => {
|
|
90
|
+
const rendered = render(
|
|
91
|
+
<Editor
|
|
92
|
+
content={":::collapsible summary test\nDetails body\n:::"}
|
|
93
|
+
ariaLabel="AriaLabel"
|
|
94
|
+
enableCollapsibleBlocks
|
|
95
|
+
/>
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
await act(async () => {
|
|
99
|
+
await promise;
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(rendered.container.querySelector(".Collapsible__container")).toBeInTheDocument();
|
|
103
|
+
expect(rendered.container.querySelector(".Collapsible__title")).toHaveTextContent(
|
|
104
|
+
"summary test"
|
|
105
|
+
);
|
|
106
|
+
expect(rendered.container.querySelector(".Collapsible__content")).toHaveTextContent(
|
|
107
|
+
"Details body"
|
|
108
|
+
);
|
|
109
|
+
});
|
|
110
|
+
|
|
60
111
|
it("can keyboard navigate the RichTextEditor", async () => {
|
|
61
112
|
render(
|
|
62
|
-
|
|
113
|
+
<div>
|
|
114
|
+
<Editor
|
|
115
|
+
ariaLabel="AriaLabel"
|
|
116
|
+
ariaDescribedBy="AriaDescribedBy"
|
|
117
|
+
content="Here is some test content"
|
|
118
|
+
/>
|
|
119
|
+
</div>
|
|
63
120
|
);
|
|
64
121
|
|
|
65
122
|
await act(async () => {
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import {
|
|
2
|
+
$convertFromMarkdownString,
|
|
3
|
+
MultilineElementTransformer,
|
|
4
|
+
TRANSFORMERS,
|
|
5
|
+
} from "@lexical/markdown";
|
|
6
|
+
import { $createParagraphNode, $createTextNode } from "lexical";
|
|
7
|
+
import {
|
|
8
|
+
$createCollapsibleContainerNode,
|
|
9
|
+
$createCollapsibleContentNode,
|
|
10
|
+
$createCollapsibleTitleNode,
|
|
11
|
+
$isCollapsibleContainerNode,
|
|
12
|
+
$isCollapsibleContentNode,
|
|
13
|
+
$isCollapsibleTitleNode,
|
|
14
|
+
CollapsibleContentNode,
|
|
15
|
+
} from "../plugins/CollapsibleExtension";
|
|
16
|
+
|
|
17
|
+
const collapsibleTransformers = () => [...TRANSFORMERS, COLLAPSIBLE];
|
|
18
|
+
|
|
19
|
+
export const COLLAPSIBLE: MultilineElementTransformer = {
|
|
20
|
+
dependencies: [CollapsibleContentNode],
|
|
21
|
+
type: "multiline-element",
|
|
22
|
+
regExpStart: /^:::collapsible(?:\s+(.*))?$/,
|
|
23
|
+
regExpEnd: /^:::\s*$/,
|
|
24
|
+
export: (node, traverseChildren) => {
|
|
25
|
+
if (!$isCollapsibleContainerNode(node)) return null;
|
|
26
|
+
|
|
27
|
+
const [title, content] = node.getChildren();
|
|
28
|
+
if (!$isCollapsibleTitleNode(title) || !$isCollapsibleContentNode(content)) return null;
|
|
29
|
+
|
|
30
|
+
const titleText = title.getTextContent().trim();
|
|
31
|
+
return `:::collapsible${titleText ? ` ${titleText}` : ""}\n${traverseChildren(content)}\n:::`;
|
|
32
|
+
},
|
|
33
|
+
handleImportAfterStartMatch: ({ rootNode, startMatch, lines, startLineIndex }) => {
|
|
34
|
+
const content: string[] = [];
|
|
35
|
+
let endLineIndex = startLineIndex + 1;
|
|
36
|
+
|
|
37
|
+
while (endLineIndex < lines.length && !/^:::\s*$/.test(lines[endLineIndex])) {
|
|
38
|
+
content.push(lines[endLineIndex]);
|
|
39
|
+
endLineIndex++;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const container = $createCollapsibleContainerNode(true);
|
|
43
|
+
const title = $createCollapsibleTitleNode();
|
|
44
|
+
title.append($createParagraphNode().append($createTextNode(startMatch[1] || "Details")));
|
|
45
|
+
|
|
46
|
+
const body = $createCollapsibleContentNode();
|
|
47
|
+
$convertFromMarkdownString(content.join("\n"), collapsibleTransformers(), body);
|
|
48
|
+
if (body.getChildrenSize() === 0) body.append($createParagraphNode());
|
|
49
|
+
|
|
50
|
+
rootNode.append(container.append(title, body));
|
|
51
|
+
return [true, Math.min(endLineIndex, lines.length - 1)];
|
|
52
|
+
},
|
|
53
|
+
replace: () => false,
|
|
54
|
+
};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { TextMatchTransformer } from "@lexical/markdown";
|
|
2
2
|
import { $createTextNode, $isLineBreakNode, LineBreakNode } from "lexical";
|
|
3
|
+
export { COLLAPSIBLE } from "./collapsible";
|
|
3
4
|
|
|
4
5
|
export const LINE_BREAK_FIX: TextMatchTransformer = {
|
|
5
6
|
dependencies: [LineBreakNode],
|