@remit/ui 0.0.100 → 0.0.101

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.
@@ -1,7 +1,21 @@
1
1
  import { $generateHtmlFromNodes, $generateNodesFromDOM } from "@lexical/html";
2
- import { $convertToMarkdownString, TRANSFORMERS } from "@lexical/markdown";
3
- import type { LexicalEditor, LexicalNode } from "lexical";
2
+ import {
3
+ $convertFromMarkdownString,
4
+ $convertToMarkdownString,
5
+ } from "@lexical/markdown";
6
+ import {
7
+ $getRoot,
8
+ $insertNodes,
9
+ $isElementNode,
10
+ $isTextNode,
11
+ createEditor,
12
+ type LexicalEditor,
13
+ type LexicalNode,
14
+ type TextFormatType,
15
+ } from "lexical";
4
16
  import { sanitizeAdoptedHtml } from "../lib/adopted-html.js";
17
+ import { COMPOSE_TRANSFORMERS } from "./rich-text-markdown.js";
18
+ import { RICH_TEXT_NODES } from "./rich-text-nodes.js";
5
19
  import type { RichTextValue } from "./rich-text-value.js";
6
20
 
7
21
  export const $adoptHtml = (
@@ -15,6 +29,55 @@ export const $adoptHtml = (
15
29
  return $generateNodesFromDOM(editor, document);
16
30
  };
17
31
 
32
+ /**
33
+ * What survives a conversion to Markdown unchanged: a run of paragraphs, the
34
+ * line breaks inside them, and unformatted characters. Anything else is
35
+ * formatting that becomes visible syntax, or is lost outright.
36
+ */
37
+ const PLAIN_NODE_TYPES = new Set(["root", "paragraph", "text", "linebreak"]);
38
+
39
+ const TEXT_FORMATS: readonly TextFormatType[] = [
40
+ "bold",
41
+ "italic",
42
+ "strikethrough",
43
+ "underline",
44
+ "code",
45
+ "subscript",
46
+ "superscript",
47
+ "highlight",
48
+ ];
49
+
50
+ /**
51
+ * Every node type and text format in the document that a plain-text
52
+ * representation cannot carry, as an inventory rather than a verdict — the
53
+ * caller decides what to do with a document that holds any.
54
+ *
55
+ * A comparison of the Markdown export against the plain text would be the
56
+ * obvious rule and is the wrong one in both directions. `@lexical/markdown`
57
+ * ships no underline transformer, so an underlined word exports identical to
58
+ * its own characters and would be destroyed in silence; a trailing empty
59
+ * paragraph makes the two strings differ and would raise a warning over
60
+ * ordinary prose.
61
+ */
62
+ export const $documentFormatting = (): string[] => {
63
+ const found = new Set<string>();
64
+
65
+ const visit = (node: LexicalNode): void => {
66
+ const type = node.getType();
67
+ if (!PLAIN_NODE_TYPES.has(type)) found.add(type);
68
+ if ($isTextNode(node)) {
69
+ for (const format of TEXT_FORMATS) {
70
+ if (node.hasFormat(format)) found.add(format);
71
+ }
72
+ }
73
+ if (!$isElementNode(node)) return;
74
+ for (const child of node.getChildren()) visit(child);
75
+ };
76
+
77
+ visit($getRoot());
78
+ return [...found].sort();
79
+ };
80
+
18
81
  /**
19
82
  * Lexical's export writes the editor's own theme onto every element — the app's
20
83
  * Tailwind class names, a `white-space` span around each text run, computed
@@ -24,5 +87,50 @@ export const $adoptHtml = (
24
87
  */
25
88
  export const $readRichText = (editor: LexicalEditor): RichTextValue => ({
26
89
  html: sanitizeAdoptedHtml($generateHtmlFromNodes(editor, null)),
27
- text: $convertToMarkdownString(TRANSFORMERS),
90
+ text: $convertToMarkdownString(COMPOSE_TRANSFORMERS),
91
+ formatting: $documentFormatting(),
28
92
  });
93
+
94
+ /**
95
+ * A document held only long enough to convert one representation into the
96
+ * other. It is never attached to the DOM, so it has no theme, no history and
97
+ * no plugins — only the node types the composer registers, which is what makes
98
+ * the result the same document the editor would have produced.
99
+ */
100
+ const scratchEditor = (): LexicalEditor =>
101
+ createEditor({
102
+ namespace: "compose-conversion",
103
+ nodes: [...RICH_TEXT_NODES],
104
+ onError: (error) => {
105
+ throw error;
106
+ },
107
+ });
108
+
109
+ /** Adopted HTML as Markdown: a table as pipe rows, a heading as `##`. */
110
+ export const htmlToMarkdown = (html: string): string => {
111
+ const editor = scratchEditor();
112
+ editor.update(
113
+ () => {
114
+ const root = $getRoot();
115
+ root.clear();
116
+ root.select();
117
+ $insertNodes($adoptHtml(editor, html));
118
+ },
119
+ { discrete: true },
120
+ );
121
+ return editor.read(() => $convertToMarkdownString(COMPOSE_TRANSFORMERS));
122
+ };
123
+
124
+ /** Markdown as the HTML a rich draft stores and sends. */
125
+ export const markdownToHtml = (markdown: string): string => {
126
+ const editor = scratchEditor();
127
+ editor.update(
128
+ () => {
129
+ $convertFromMarkdownString(markdown, COMPOSE_TRANSFORMERS);
130
+ },
131
+ { discrete: true },
132
+ );
133
+ return editor.read(() =>
134
+ sanitizeAdoptedHtml($generateHtmlFromNodes(editor, null)),
135
+ );
136
+ };
@@ -1,6 +1,7 @@
1
1
  import type { Meta, StoryObj } from "@storybook/react";
2
2
  import { expect, userEvent } from "storybook/test";
3
3
  import { sanitizeAdoptedHtml } from "../lib/adopted-html.js";
4
+ import { ComposeModeToggle } from "./compose-mode-toggle.js";
4
5
  import { RichTextEditor } from "./rich-text-editor.js";
5
6
 
6
7
  /**
@@ -102,3 +103,78 @@ export const ClickBelowTheText: Story = {
102
103
  await expect(editable).toHaveFocus();
103
104
  },
104
105
  };
106
+
107
+ const modeToggle = <ComposeModeToggle mode="rich" onToggle={() => undefined} />;
108
+
109
+ /** The toolbar as compose ships it: the formatting cluster, then the mode. */
110
+ export const ToolbarInRich: Story = {
111
+ name: "Toolbar with the mode toggle",
112
+ args: { initialHtml: RICH_DOCUMENT, trailing: modeToggle },
113
+ };
114
+
115
+ /**
116
+ * At 390 the formatting cluster runs out of room. It scrolls inside its own
117
+ * strip and the toggle stays pinned at the right edge, rather than the cluster
118
+ * pushing the toggle off the screen — which is what a flex child without
119
+ * `min-w-0` does.
120
+ */
121
+ export const NarrowToolbar: Story = {
122
+ name: "Toolbar at 390",
123
+ args: { initialHtml: RICH_DOCUMENT, trailing: modeToggle },
124
+ decorators: [
125
+ (Story) => (
126
+ <div
127
+ data-testid="body-area"
128
+ className="flex h-[420px] w-[390px] flex-col overflow-auto rounded-md border border-line bg-canvas"
129
+ >
130
+ <Story />
131
+ </div>
132
+ ),
133
+ ],
134
+ play: async ({ canvasElement }) => {
135
+ const frame = canvasElement.querySelector<HTMLElement>(
136
+ "[data-testid=body-area]",
137
+ );
138
+ const cluster = canvasElement.querySelector<HTMLElement>(
139
+ "[data-testid=compose-format-cluster]",
140
+ );
141
+ const toggle = canvasElement.querySelector<HTMLElement>(
142
+ "[data-testid=compose-mode-toggle]",
143
+ );
144
+ if (!frame || !cluster || !toggle)
145
+ throw new Error("the toolbar is not mounted");
146
+
147
+ await expect(cluster.scrollWidth).toBeGreaterThan(cluster.clientWidth);
148
+ await expect(toggle.getBoundingClientRect().right).toBeLessThanOrEqual(
149
+ frame.getBoundingClientRect().right + 1,
150
+ );
151
+ },
152
+ };
153
+
154
+ /**
155
+ * The toolbar and the body share one scroller, so twenty lines of typing would
156
+ * carry the toolbar off the top with them. It stays at the top of the body
157
+ * while the text moves under it.
158
+ */
159
+ export const StickyToolbar: Story = {
160
+ name: "Toolbar over a scrolled body",
161
+ args: {
162
+ initialHtml: `${RICH_DOCUMENT}${"<p>Another line of the message.</p>".repeat(30)}`,
163
+ trailing: modeToggle,
164
+ },
165
+ play: async ({ canvasElement }) => {
166
+ const frame = canvasElement.querySelector<HTMLElement>(
167
+ "[data-testid=body-area]",
168
+ );
169
+ const toggle = canvasElement.querySelector<HTMLElement>(
170
+ "[data-testid=compose-mode-toggle]",
171
+ );
172
+ if (!frame || !toggle) throw new Error("the toolbar is not mounted");
173
+
174
+ frame.scrollTop = 400;
175
+ await expect(frame.scrollTop).toBeGreaterThan(0);
176
+ await expect(
177
+ toggle.getBoundingClientRect().top - frame.getBoundingClientRect().top,
178
+ ).toBeLessThan(60);
179
+ },
180
+ };
@@ -36,6 +36,8 @@ export interface RichTextEditorProps {
36
36
  autoFocus?: boolean;
37
37
  placeholder?: string;
38
38
  ariaLabel?: string;
39
+ /** Pinned to the right of the toolbar strip. The mode toggle rides here. */
40
+ trailing?: React.ReactNode;
39
41
  }
40
42
 
41
43
  /**
@@ -161,6 +163,7 @@ export const RichTextEditor = ({
161
163
  autoFocus = false,
162
164
  placeholder = "Write your message…",
163
165
  ariaLabel = "Message body",
166
+ trailing,
164
167
  }: RichTextEditorProps) => (
165
168
  <LexicalComposer
166
169
  initialConfig={{
@@ -177,7 +180,7 @@ export const RichTextEditor = ({
177
180
  height of its own text. What is under the last line is the document, so
178
181
  a click there reaches it instead of an unfocusable parent. */}
179
182
  <div className="flex shrink-0 grow flex-col">
180
- <RichTextToolbar />
183
+ <RichTextToolbar trailing={trailing} />
181
184
  <div className="relative flex shrink-0 grow flex-col">
182
185
  <RichTextPlugin
183
186
  contentEditable={
@@ -0,0 +1,153 @@
1
+ /**
2
+ * The rule that decides whether a message's formatting is destroyed without
3
+ * asking. It fires on what the document holds, not on a comparison of the
4
+ * Markdown export against the plain text: `@lexical/markdown` ships no
5
+ * underline transformer, so an underlined word exports identical to its own
6
+ * characters and a string comparison would switch in silence over exactly the
7
+ * document this warning exists for. In the other direction a trailing empty
8
+ * paragraph makes the two strings differ, and the same comparison would
9
+ * interrupt the ordinary prose it is meant to leave alone.
10
+ */
11
+ import assert from "node:assert/strict";
12
+ import { before, describe, it } from "node:test";
13
+ import type { JSDOM } from "jsdom";
14
+ import type { LexicalEditor } from "lexical";
15
+
16
+ interface Row {
17
+ what: string;
18
+ html: string;
19
+ /** What the switch does: warn first, or switch in silence. */
20
+ warns: boolean;
21
+ /** The formatting the document is expected to hold, when it holds any. */
22
+ holds?: string[];
23
+ }
24
+
25
+ const ROWS: Row[] = [
26
+ { what: "an empty document", html: "", warns: false },
27
+ {
28
+ what: "one paragraph of prose",
29
+ html: "<p>See you at 12:30.</p>",
30
+ warns: false,
31
+ },
32
+ {
33
+ what: "a double Enter between two paragraphs",
34
+ html: "<p>First.</p><p></p><p>Second.</p>",
35
+ warns: false,
36
+ },
37
+ {
38
+ what: "a trailing empty paragraph",
39
+ html: "<p>Thanks.</p><p></p>",
40
+ warns: false,
41
+ },
42
+ {
43
+ what: "a line break inside a paragraph",
44
+ html: "<p>One<br>Two</p>",
45
+ warns: false,
46
+ },
47
+ {
48
+ what: "a bare URL left as characters",
49
+ html: "<p>See https://example.com for the report.</p>",
50
+ warns: false,
51
+ },
52
+ {
53
+ what: "an image, which no registered node can hold",
54
+ html: '<p>Chart: <img src="https://example.com/chart.png" alt="chart"></p>',
55
+ warns: false,
56
+ },
57
+ {
58
+ what: "an underlined word",
59
+ html: "<p>Please <u>read this</u>.</p>",
60
+ warns: true,
61
+ holds: ["underline"],
62
+ },
63
+ {
64
+ what: "a bold word",
65
+ html: "<p>Due <strong>Friday</strong>.</p>",
66
+ warns: true,
67
+ holds: ["bold"],
68
+ },
69
+ {
70
+ what: "a URL Lexical turned into a link",
71
+ html: '<p>See <a href="https://example.com">https://example.com</a>.</p>',
72
+ warns: true,
73
+ holds: ["link"],
74
+ },
75
+ {
76
+ what: "a heading",
77
+ html: "<h2>Quarterly numbers</h2><p>Below.</p>",
78
+ warns: true,
79
+ holds: ["heading"],
80
+ },
81
+ {
82
+ what: "a table",
83
+ html: "<table><tbody><tr><td>EMEA</td><td>412</td></tr></tbody></table>",
84
+ warns: true,
85
+ },
86
+ ];
87
+
88
+ let formattingOf: (html: string) => string[];
89
+
90
+ before(async () => {
91
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
92
+ const dom: JSDOM = new JSDOMCtor(
93
+ "<!doctype html><html><body></body></html>",
94
+ {
95
+ url: "http://localhost/",
96
+ },
97
+ );
98
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
99
+ globalThis.document = dom.window.document;
100
+ globalThis.DOMParser = dom.window.DOMParser;
101
+ globalThis.HTMLElement = dom.window.HTMLElement;
102
+ globalThis.Element = dom.window.Element;
103
+ globalThis.Node = dom.window.Node;
104
+
105
+ const { $getRoot, $insertNodes, createEditor } = await import("lexical");
106
+ const { $adoptHtml, $documentFormatting } = await import(
107
+ "./rich-text-document.js"
108
+ );
109
+ const { RICH_TEXT_NODES } = await import("./rich-text-nodes.js");
110
+
111
+ formattingOf = (html: string) => {
112
+ const editor: LexicalEditor = createEditor({
113
+ namespace: "test",
114
+ nodes: [...RICH_TEXT_NODES],
115
+ onError: (error) => {
116
+ throw error;
117
+ },
118
+ });
119
+ editor.update(
120
+ () => {
121
+ const root = $getRoot();
122
+ root.clear();
123
+ root.select();
124
+ if (html !== "") $insertNodes($adoptHtml(editor, html));
125
+ },
126
+ { discrete: true },
127
+ );
128
+ return editor.read(() => $documentFormatting());
129
+ };
130
+ });
131
+
132
+ describe("what switching to plain text warns about", () => {
133
+ for (const row of ROWS) {
134
+ it(`${row.warns ? "warns about" : "says nothing about"} ${row.what}`, () => {
135
+ const formatting = formattingOf(row.html);
136
+ assert.equal(
137
+ formatting.length > 0,
138
+ row.warns,
139
+ `formatting was ${JSON.stringify(formatting)}`,
140
+ );
141
+ if (row.holds) {
142
+ for (const held of row.holds) assert.ok(formatting.includes(held));
143
+ }
144
+ });
145
+ }
146
+
147
+ it("names what a formatted document holds rather than reducing it to a flag", () => {
148
+ assert.deepEqual(
149
+ formattingOf("<p><strong>Due</strong> <em>Friday</em>.</p>"),
150
+ ["bold", "italic"],
151
+ );
152
+ });
153
+ });
@@ -0,0 +1,131 @@
1
+ /**
2
+ * `@lexical/markdown` exports `isTableRowDivider` and no table transformer, so
3
+ * the composer supplies one. Both directions are pinned here: what a recipient
4
+ * reads in the plain part of a rich send, and what comes back when a plain
5
+ * draft is switched to rich.
6
+ *
7
+ * The same transformer set drives the down-conversion of a paste in plain mode,
8
+ * so a table pasted there and a table pasted in rich mode and then switched
9
+ * produce the same characters.
10
+ */
11
+ import assert from "node:assert/strict";
12
+ import { before, describe, it } from "node:test";
13
+ import type { JSDOM } from "jsdom";
14
+
15
+ const TABLE_HTML = [
16
+ "<table><thead><tr><th>Region</th><th>Total</th></tr></thead>",
17
+ "<tbody><tr><td>EMEA</td><td>412</td></tr>",
18
+ "<tr><td>Americas</td><td>388</td></tr></tbody></table>",
19
+ ].join("");
20
+
21
+ const TABLE_MARKDOWN = [
22
+ "| Region | Total |",
23
+ "| --- | --- |",
24
+ "| EMEA | 412 |",
25
+ "| Americas | 388 |",
26
+ ].join("\n");
27
+
28
+ let htmlToMarkdown: (html: string) => string;
29
+ let markdownToHtml: (markdown: string) => string;
30
+
31
+ before(async () => {
32
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
33
+ const dom: JSDOM = new JSDOMCtor(
34
+ "<!doctype html><html><body></body></html>",
35
+ {
36
+ url: "http://localhost/",
37
+ },
38
+ );
39
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
40
+ globalThis.document = dom.window.document;
41
+ globalThis.DOMParser = dom.window.DOMParser;
42
+ globalThis.HTMLElement = dom.window.HTMLElement;
43
+ globalThis.Element = dom.window.Element;
44
+ globalThis.Node = dom.window.Node;
45
+
46
+ ({ htmlToMarkdown, markdownToHtml } = await import(
47
+ "./rich-text-document.js"
48
+ ));
49
+ });
50
+
51
+ describe("HTML down-converted for the plain surface", () => {
52
+ it("writes a table as pipe rows under a divider", () => {
53
+ assert.equal(htmlToMarkdown(TABLE_HTML).trim(), TABLE_MARKDOWN);
54
+ });
55
+
56
+ it("writes headings, lists and emphasis as Markdown", () => {
57
+ const markdown = htmlToMarkdown(
58
+ [
59
+ "<h2>Release checklist</h2>",
60
+ "<p>Ship <strong>Friday</strong>.</p>",
61
+ "<ol><li>Cut the tag</li><li>Publish the images</li></ol>",
62
+ ].join(""),
63
+ );
64
+
65
+ assert.match(markdown, /## Release checklist/);
66
+ assert.match(markdown, /\*\*Friday\*\*/);
67
+ assert.match(markdown, /1\. Cut the tag/);
68
+ });
69
+
70
+ it("carries no markup through as characters", () => {
71
+ const markdown = htmlToMarkdown(
72
+ '<h2 style="color:#c00">Numbers</h2><script>alert(1)</script>',
73
+ );
74
+
75
+ assert.equal(markdown.includes("<h2"), false);
76
+ assert.equal(markdown.includes("<script"), false);
77
+ assert.equal(markdown.includes("alert(1)"), false);
78
+ assert.equal(markdown.includes("color:#c00"), false);
79
+ });
80
+
81
+ it("keeps a cell's own pipe out of the row it sits in", () => {
82
+ const markdown = htmlToMarkdown(
83
+ "<table><tbody><tr><td>a|b</td><td>c</td></tr></tbody></table>",
84
+ );
85
+ const cells = markdownToHtml(markdown).match(/<t[dh][^>]*>/g) ?? [];
86
+
87
+ assert.match(markdown, /a\\\|b/);
88
+ assert.equal(cells.length, 2);
89
+ });
90
+ });
91
+
92
+ describe("Markdown read back into a document", () => {
93
+ it("renders a pipe table as a table with a header row", () => {
94
+ const html = markdownToHtml(TABLE_MARKDOWN);
95
+
96
+ assert.match(html, /<table/);
97
+ assert.match(html, /<th[^>]*><p>Region/);
98
+ assert.match(html, /EMEA/);
99
+ assert.match(html, /388/);
100
+ });
101
+
102
+ it("renders a heading and emphasis", () => {
103
+ const html = markdownToHtml("## Numbers\n\nDue **Friday**.");
104
+
105
+ assert.match(html, /<h2/);
106
+ assert.match(html, /<strong/);
107
+ });
108
+
109
+ it("returns a table unchanged over a round trip", () => {
110
+ assert.equal(
111
+ htmlToMarkdown(markdownToHtml(TABLE_MARKDOWN)).trim(),
112
+ TABLE_MARKDOWN,
113
+ );
114
+ });
115
+
116
+ it("keeps a divider that heads no table as the characters it is", () => {
117
+ const html = markdownToHtml("| --- | --- |");
118
+
119
+ assert.match(html, /\| --- \| --- \|/);
120
+ assert.equal(html.includes("<table"), false);
121
+ });
122
+
123
+ it("leaves prose that matches no transformer looking the same", () => {
124
+ const prose = "Thanks — that works.\n\nI'll send the deck tomorrow.";
125
+ const html = markdownToHtml(prose);
126
+
127
+ assert.equal((html.match(/<p>/g) ?? []).length, 2);
128
+ assert.match(html, /Thanks/);
129
+ assert.match(html, /send the deck tomorrow/);
130
+ });
131
+ });
@@ -0,0 +1,155 @@
1
+ import {
2
+ $convertFromMarkdownString,
3
+ $convertToMarkdownString,
4
+ type ElementTransformer,
5
+ isTableRowDivider,
6
+ TRANSFORMERS,
7
+ type Transformer,
8
+ } from "@lexical/markdown";
9
+ import {
10
+ $createTableCellNode,
11
+ $createTableNode,
12
+ $createTableRowNode,
13
+ $isTableCellNode,
14
+ $isTableNode,
15
+ $isTableRowNode,
16
+ TableCellHeaderStates,
17
+ TableCellNode,
18
+ TableNode,
19
+ TableRowNode,
20
+ } from "@lexical/table";
21
+ import { $isTextNode, type ElementNode, type LexicalNode } from "lexical";
22
+
23
+ const TABLE_ROW = /^\|(.+)\|\s*$/;
24
+
25
+ /**
26
+ * A cell is a document of its own, so its content goes through the same
27
+ * transformers. A newline or a bare pipe inside one would end the row early,
28
+ * which is the difference between a table and a wall of broken syntax.
29
+ */
30
+ const exportCell = (cell: TableCellNode): string =>
31
+ $convertToMarkdownString(COMPOSE_TRANSFORMERS, cell)
32
+ .replace(/\n+/g, " ")
33
+ .replace(/\|/g, "\\|")
34
+ .trim();
35
+
36
+ const splitRow = (line: string): string[] => {
37
+ const inner = line.trim().replace(/^\|/, "").replace(/\|$/, "");
38
+ const cells: string[] = [];
39
+ let current = "";
40
+ for (let index = 0; index < inner.length; index++) {
41
+ const character = inner[index];
42
+ if (character === "\\" && inner[index + 1] === "|") {
43
+ current += "|";
44
+ index++;
45
+ continue;
46
+ }
47
+ if (character === "|") {
48
+ cells.push(current);
49
+ current = "";
50
+ continue;
51
+ }
52
+ current += character;
53
+ }
54
+ cells.push(current);
55
+ return cells;
56
+ };
57
+
58
+ const $createCell = (text: string): TableCellNode => {
59
+ const cell = $createTableCellNode(TableCellHeaderStates.NO_STATUS);
60
+ $convertFromMarkdownString(text.trim(), COMPOSE_TRANSFORMERS, cell);
61
+ return cell;
62
+ };
63
+
64
+ const $createRow = (texts: string[], columns: number): TableRowNode => {
65
+ const row = $createTableRowNode();
66
+ for (let index = 0; index < columns; index++) {
67
+ row.append($createCell(texts[index] ?? ""));
68
+ }
69
+ return row;
70
+ };
71
+
72
+ const columnsOf = (table: TableNode): number => {
73
+ const first = table.getFirstChild();
74
+ return $isTableRowNode(first) ? first.getChildrenSize() : 0;
75
+ };
76
+
77
+ const $markFirstRowAsHeader = (table: TableNode): void => {
78
+ const first = table.getFirstChild();
79
+ if (!$isTableRowNode(first)) return;
80
+ for (const cell of first.getChildren()) {
81
+ if (!$isTableCellNode(cell)) continue;
82
+ cell.setHeaderStyles(TableCellHeaderStates.ROW, TableCellHeaderStates.ROW);
83
+ }
84
+ };
85
+
86
+ /**
87
+ * `@lexical/markdown` 0.49 exports `isTableRowDivider` but no table
88
+ * transformer, so the composer supplies one over `@lexical/table` nodes. One
89
+ * list drives Markdown export, Markdown import and the cell round trip, which
90
+ * is what keeps a table that left as pipes coming back as a table.
91
+ *
92
+ * Import runs a line at a time, each line arriving as a paragraph. A row
93
+ * appended to the table its predecessor left behind is how the rows join up.
94
+ * A divider carries no content of its own: it says the row above is a header.
95
+ */
96
+ export const TABLE: ElementTransformer = {
97
+ dependencies: [TableNode, TableRowNode, TableCellNode],
98
+ export: (node: LexicalNode): string | null => {
99
+ if (!$isTableNode(node)) return null;
100
+ const lines: string[] = [];
101
+ for (const row of node.getChildren()) {
102
+ if (!$isTableRowNode(row)) continue;
103
+ const cells = row.getChildren().filter($isTableCellNode);
104
+ if (cells.length === 0) continue;
105
+ lines.push(`| ${cells.map(exportCell).join(" | ")} |`);
106
+ // GFM only reads a block of pipe rows as a table when a divider follows
107
+ // the first one, so it is written whether or not that row was a header.
108
+ // Without it a recipient sees the pipes and no table.
109
+ if (lines.length === 1) {
110
+ lines.push(`| ${cells.map(() => "---").join(" | ")} |`);
111
+ }
112
+ }
113
+ return lines.length === 0 ? null : lines.join("\n");
114
+ },
115
+ regExp: TABLE_ROW,
116
+ replace: (parentNode: ElementNode, children, match): void => {
117
+ const line = match[0].trimEnd();
118
+ const previous = parentNode.getPreviousSibling();
119
+
120
+ if (isTableRowDivider(line)) {
121
+ if ($isTableNode(previous)) {
122
+ $markFirstRowAsHeader(previous);
123
+ parentNode.remove();
124
+ return;
125
+ }
126
+ // A divider with no rows above it is not a table. The import emptied
127
+ // this line's text node before calling here, so declining the match
128
+ // would drop the characters rather than leave them alone.
129
+ const first = children[0];
130
+ if ($isTextNode(first)) first.setTextContent(line);
131
+ return;
132
+ }
133
+
134
+ const texts = splitRow(line);
135
+ if ($isTableNode(previous)) {
136
+ previous.append($createRow(texts, columnsOf(previous)));
137
+ parentNode.remove();
138
+ return;
139
+ }
140
+
141
+ const table = $createTableNode();
142
+ table.append($createRow(texts, texts.length));
143
+ parentNode.replace(table);
144
+ },
145
+ type: "element",
146
+ };
147
+
148
+ /**
149
+ * The composer's own transformer set. Everything that reads or writes Markdown
150
+ * in compose uses this one list — the plain alternative on a rich send, the
151
+ * body of a plain send, the down-conversion of a paste, and both directions of
152
+ * the mode switch — so the conversions are inverses rather than four
153
+ * independent best efforts.
154
+ */
155
+ export const COMPOSE_TRANSFORMERS: Transformer[] = [TABLE, ...TRANSFORMERS];