@gcforms/editor 1.0.18 → 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 CHANGED
@@ -5,6 +5,10 @@ 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
+
8
12
  ## [1.0.18] - 2026-08-17
9
13
 
10
14
  - Update Lexical to version 0.49.0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gcforms/editor",
3
- "version": "1.0.18",
3
+ "version": "1.0.19",
4
4
  "author": "Canadian Digital Service",
5
5
  "license": "MIT",
6
6
  "publishConfig": {
@@ -9,6 +9,7 @@
9
9
  "packageManager": "yarn@4.10.3",
10
10
  "dependencies": {
11
11
  "@lexical/code": "^0.49.0",
12
+ "@lexical/html": "^0.49.0",
12
13
  "@lexical/link": "^0.49.0",
13
14
  "@lexical/list": "^0.49.0",
14
15
  "@lexical/markdown": "^0.49.0",
package/src/Editor.tsx CHANGED
@@ -1,11 +1,7 @@
1
1
  "use client";
2
2
  import React, { useId, useState } from "react";
3
- import {
4
- $convertFromMarkdownString,
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 { editorConfig } from "./config";
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
- <LexicalComposer
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 editorId={editorId} setIsLinkEditMode={setIsLinkEditMode} />
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([...TRANSFORMERS, LINE_BREAK_FIX]);
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
- </LexicalComposer>
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
@@ -22,5 +22,7 @@
22
22
  "tooltipIndent": "Indent",
23
23
  "tooltipOutdent": "Outdent",
24
24
  "indent": "Indent",
25
- "outdent": "Outdent"
25
+ "outdent": "Outdent",
26
+ "tooltipInsertCollapsible": "Insert collapsible container",
27
+ "insertCollapsible": "Insert collapsible container"
26
28
  }
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 [items] = useState([
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(items.length - 1, index + 1));
104
+ setCurrentFocusIndex((index) => Math.min(toolbarItemCount - 1, index + 1));
111
105
  }
112
106
  },
113
- [items, setCurrentFocusIndex, setToolbarInit, toolbarInit]
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 ariaLabel="AriaLabel" ariaDescribedBy="AriaDescribedBy" id="editor-test" content="Here is some test content" />
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
- // Toolbar contains 9 formatting buttons
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
- <div><Editor ariaLabel="AriaLabel" ariaDescribedBy="AriaDescribedBy" content="Here is some test content" /></div>
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],