@remit/ui 0.0.99 → 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/attachment-list.render.test.ts +120 -0
- package/src/components/attachment-list.stories.tsx +159 -0
- package/src/components/attachment-list.tsx +182 -0
- package/src/components/compose-mode-toggle.tsx +35 -0
- package/src/components/message-row.render.test.ts +31 -0
- package/src/components/message-row.tsx +10 -2
- 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/reading-pane.stories.tsx +58 -2
- 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/index.ts +11 -0
- package/src/lib/attachment-file.test.ts +162 -0
- package/src/lib/attachment-file.ts +94 -0
- package/src/rich-text.ts +17 -3
- package/src/tokens.css +4 -0
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import type { Meta, StoryObj } from "@storybook/react";
|
|
2
2
|
import { Paperclip, Star } from "lucide-react";
|
|
3
3
|
import type { ThreadData, ThreadMessageData } from "./app-shell-types.js";
|
|
4
|
+
import { AttachmentList } from "./attachment-list.js";
|
|
5
|
+
import { MessageBodyView } from "./message-body-view.js";
|
|
4
6
|
import {
|
|
5
7
|
CollapsedMessage,
|
|
6
8
|
ExpandedMessage,
|
|
@@ -156,7 +158,11 @@ export const CollapsedRowComposed: StoryObj<typeof CollapsedMessage> = {
|
|
|
156
158
|
>
|
|
157
159
|
<Star className="size-3 fill-current" />
|
|
158
160
|
</button>
|
|
159
|
-
<Paperclip
|
|
161
|
+
<Paperclip
|
|
162
|
+
className="size-3 text-fg-subtle"
|
|
163
|
+
role="img"
|
|
164
|
+
aria-label="Has an attachment"
|
|
165
|
+
/>
|
|
160
166
|
<span className="text-2xs text-fg-subtle tabular-nums">
|
|
161
167
|
Today, 08:42
|
|
162
168
|
</span>
|
|
@@ -180,7 +186,6 @@ export const ExpandedRowComposed: StoryObj<typeof ExpandedMessage> = {
|
|
|
180
186
|
indicators={
|
|
181
187
|
<div className="mt-0.5 flex items-center gap-1">
|
|
182
188
|
<Star className="size-3.5 fill-current text-warning" />
|
|
183
|
-
<Paperclip className="size-3.5 text-fg-subtle" />
|
|
184
189
|
</div>
|
|
185
190
|
}
|
|
186
191
|
actionMenu={
|
|
@@ -192,3 +197,54 @@ export const ExpandedRowComposed: StoryObj<typeof ExpandedMessage> = {
|
|
|
192
197
|
</div>
|
|
193
198
|
),
|
|
194
199
|
};
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The expanded row as `MessageCard` composes it when the message carries files
|
|
203
|
+
* (#683): body first, attachment list under it. The indicators row holds no
|
|
204
|
+
* paperclip — the list below is the affordance, and a second paperclip beside
|
|
205
|
+
* the live star button only reads as a control that does nothing.
|
|
206
|
+
*/
|
|
207
|
+
export const ExpandedRowWithAttachments: StoryObj<typeof ExpandedMessage> = {
|
|
208
|
+
render: () => (
|
|
209
|
+
<div className="max-w-3xl border border-line">
|
|
210
|
+
<ExpandedMessage
|
|
211
|
+
message={row}
|
|
212
|
+
to={<>to Alex Rivera and 2 others</>}
|
|
213
|
+
indicators={
|
|
214
|
+
<div className="mt-0.5 flex items-center gap-1">
|
|
215
|
+
<Star className="size-3.5 fill-current text-warning" />
|
|
216
|
+
</div>
|
|
217
|
+
}
|
|
218
|
+
body={
|
|
219
|
+
<div className="mt-3">
|
|
220
|
+
<MessageBodyView
|
|
221
|
+
html={row.bodyHtml}
|
|
222
|
+
category="personal"
|
|
223
|
+
allowImages
|
|
224
|
+
/>
|
|
225
|
+
<AttachmentList
|
|
226
|
+
className="mt-4 px-2 lg:px-0"
|
|
227
|
+
attachments={[
|
|
228
|
+
{
|
|
229
|
+
attachmentId: "part-2",
|
|
230
|
+
filename: "Q3 board pack.pdf",
|
|
231
|
+
typeLabel: "PDF",
|
|
232
|
+
sizeOctets: 2_411_724,
|
|
233
|
+
download: { status: "idle" },
|
|
234
|
+
},
|
|
235
|
+
{
|
|
236
|
+
attachmentId: "part-3",
|
|
237
|
+
filename: "headcount.csv",
|
|
238
|
+
typeLabel: "CSV",
|
|
239
|
+
sizeOctets: 4_180,
|
|
240
|
+
download: { status: "idle" },
|
|
241
|
+
},
|
|
242
|
+
]}
|
|
243
|
+
onDownload={(id) => alert(`Download ${id}`)}
|
|
244
|
+
/>
|
|
245
|
+
</div>
|
|
246
|
+
}
|
|
247
|
+
/>
|
|
248
|
+
</div>
|
|
249
|
+
),
|
|
250
|
+
};
|
|
@@ -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
|
+
});
|