@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.
- package/package.json +1 -1
- package/src/components/compose-mode-toggle.tsx +35 -0
- package/src/components/plain-text-editor.mount.test.ts +238 -0
- package/src/components/plain-text-editor.stories.tsx +227 -0
- package/src/components/plain-text-editor.tsx +178 -0
- package/src/components/rich-text-document.ts +111 -3
- package/src/components/rich-text-editor.stories.tsx +76 -0
- package/src/components/rich-text-editor.tsx +4 -1
- package/src/components/rich-text-formatting.test.ts +153 -0
- package/src/components/rich-text-markdown.test.ts +131 -0
- package/src/components/rich-text-markdown.ts +155 -0
- package/src/components/rich-text-toolbar.tsx +68 -46
- package/src/components/rich-text-value.ts +11 -1
- package/src/rich-text.ts +17 -3
- package/src/tokens.css +4 -0
|
@@ -1,7 +1,21 @@
|
|
|
1
1
|
import { $generateHtmlFromNodes, $generateNodesFromDOM } from "@lexical/html";
|
|
2
|
-
import {
|
|
3
|
-
|
|
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(
|
|
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];
|