@remit/ui 0.0.100 → 0.0.102

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,8 @@
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 { ComposeLanguageChip } from "./compose-language-chip.js";
5
+ import { ComposeModeToggle } from "./compose-mode-toggle.js";
4
6
  import { RichTextEditor } from "./rich-text-editor.js";
5
7
 
6
8
  /**
@@ -102,3 +104,101 @@ export const ClickBelowTheText: Story = {
102
104
  await expect(editable).toHaveFocus();
103
105
  },
104
106
  };
107
+
108
+ /**
109
+ * The two pinned controls, in the order compose ships them: the chip first, so
110
+ * one Shift+Tab out of the body still reaches the mode toggle and two reach the
111
+ * chip.
112
+ */
113
+ const pinnedControls = (
114
+ <>
115
+ <ComposeLanguageChip
116
+ language="nl"
117
+ languages={["nl", "en", "de"]}
118
+ onSelect={() => undefined}
119
+ />
120
+ <ComposeModeToggle mode="rich" onToggle={() => undefined} />
121
+ </>
122
+ );
123
+
124
+ /** The toolbar as compose ships it: the formatting cluster, then the two pinned controls. */
125
+ export const ToolbarInRich: Story = {
126
+ name: "Toolbar with the language chip and the mode toggle",
127
+ args: { initialHtml: RICH_DOCUMENT, lang: "nl", trailing: pinnedControls },
128
+ };
129
+
130
+ /**
131
+ * At 390 the formatting cluster runs out of room. It scrolls inside its own
132
+ * strip and both pinned controls stay at the right edge, rather than the
133
+ * cluster pushing them off the screen — which is what a flex child without
134
+ * `min-w-0` does. Two letters is what makes room for a second pinned item here.
135
+ */
136
+ export const NarrowToolbar: Story = {
137
+ name: "Toolbar at 390",
138
+ args: { initialHtml: RICH_DOCUMENT, lang: "nl", trailing: pinnedControls },
139
+ decorators: [
140
+ (Story) => (
141
+ <div
142
+ data-testid="body-area"
143
+ className="flex h-[420px] w-[390px] flex-col overflow-auto rounded-md border border-line bg-canvas"
144
+ >
145
+ <Story />
146
+ </div>
147
+ ),
148
+ ],
149
+ play: async ({ canvasElement }) => {
150
+ const frame = canvasElement.querySelector<HTMLElement>(
151
+ "[data-testid=body-area]",
152
+ );
153
+ const cluster = canvasElement.querySelector<HTMLElement>(
154
+ "[data-testid=compose-format-cluster]",
155
+ );
156
+ const toggle = canvasElement.querySelector<HTMLElement>(
157
+ "[data-testid=compose-mode-toggle]",
158
+ );
159
+ const chip = canvasElement.querySelector<HTMLElement>(
160
+ "[data-testid=compose-language-chip]",
161
+ );
162
+ if (!frame || !cluster || !toggle || !chip)
163
+ throw new Error("the toolbar is not mounted");
164
+
165
+ await expect(cluster.scrollWidth).toBeGreaterThan(cluster.clientWidth);
166
+ const edge = frame.getBoundingClientRect().right + 1;
167
+ await expect(toggle.getBoundingClientRect().right).toBeLessThanOrEqual(
168
+ edge,
169
+ );
170
+ await expect(chip.getBoundingClientRect().left).toBeGreaterThanOrEqual(
171
+ frame.getBoundingClientRect().left,
172
+ );
173
+ await expect(chip.getBoundingClientRect().right).toBeLessThanOrEqual(edge);
174
+ },
175
+ };
176
+
177
+ /**
178
+ * The toolbar and the body share one scroller, so twenty lines of typing would
179
+ * carry the toolbar off the top with them. It stays at the top of the body
180
+ * while the text moves under it.
181
+ */
182
+ export const StickyToolbar: Story = {
183
+ name: "Toolbar over a scrolled body",
184
+ args: {
185
+ initialHtml: `${RICH_DOCUMENT}${"<p>Another line of the message.</p>".repeat(30)}`,
186
+ lang: "nl",
187
+ trailing: pinnedControls,
188
+ },
189
+ play: async ({ canvasElement }) => {
190
+ const frame = canvasElement.querySelector<HTMLElement>(
191
+ "[data-testid=body-area]",
192
+ );
193
+ const toggle = canvasElement.querySelector<HTMLElement>(
194
+ "[data-testid=compose-mode-toggle]",
195
+ );
196
+ if (!frame || !toggle) throw new Error("the toolbar is not mounted");
197
+
198
+ frame.scrollTop = 400;
199
+ await expect(frame.scrollTop).toBeGreaterThan(0);
200
+ await expect(
201
+ toggle.getBoundingClientRect().top - frame.getBoundingClientRect().top,
202
+ ).toBeLessThan(60);
203
+ },
204
+ };
@@ -36,6 +36,14 @@ 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;
41
+ /**
42
+ * BCP 47 tag of the language the message is being written in. Firefox picks
43
+ * a dictionary from it among the ones the user installed; Chrome and Safari
44
+ * ignore it. Every screen reader picks a voice from it.
45
+ */
46
+ lang?: string;
39
47
  }
40
48
 
41
49
  /**
@@ -161,6 +169,8 @@ export const RichTextEditor = ({
161
169
  autoFocus = false,
162
170
  placeholder = "Write your message…",
163
171
  ariaLabel = "Message body",
172
+ trailing,
173
+ lang,
164
174
  }: RichTextEditorProps) => (
165
175
  <LexicalComposer
166
176
  initialConfig={{
@@ -177,11 +187,12 @@ export const RichTextEditor = ({
177
187
  height of its own text. What is under the last line is the document, so
178
188
  a click there reaches it instead of an unfocusable parent. */}
179
189
  <div className="flex shrink-0 grow flex-col">
180
- <RichTextToolbar />
190
+ <RichTextToolbar trailing={trailing} />
181
191
  <div className="relative flex shrink-0 grow flex-col">
182
192
  <RichTextPlugin
183
193
  contentEditable={
184
194
  <ContentEditable
195
+ lang={lang}
185
196
  aria-label={ariaLabel}
186
197
  aria-placeholder={placeholder}
187
198
  data-testid="compose-body"
@@ -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
+ });