@remit/ui 0.0.95 → 0.0.97

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 CHANGED
@@ -1,12 +1,13 @@
1
1
  {
2
2
  "name": "@remit/ui",
3
- "version": "0.0.95",
3
+ "version": "0.0.97",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "src"
7
7
  ],
8
8
  "exports": {
9
9
  ".": "./src/index.ts",
10
+ "./rich-text": "./src/rich-text.ts",
10
11
  "./tokens.css": "./src/tokens.css"
11
12
  },
12
13
  "scripts": {
@@ -18,9 +19,20 @@
18
19
  "@fontsource-variable/geist": "^5",
19
20
  "@fontsource-variable/hanken-grotesk": "^5",
20
21
  "@fontsource-variable/inter": "^5",
22
+ "@lexical/code-core": "0.49.0",
23
+ "@lexical/html": "0.49.0",
24
+ "@lexical/link": "0.49.0",
25
+ "@lexical/list": "0.49.0",
26
+ "@lexical/markdown": "0.49.0",
27
+ "@lexical/react": "0.49.0",
28
+ "@lexical/rich-text": "0.49.0",
29
+ "@lexical/selection": "0.49.0",
30
+ "@lexical/table": "0.49.0",
31
+ "@lexical/utils": "0.49.0",
21
32
  "@react-types/shared": "^3.36.0",
22
33
  "clsx": "^2",
23
34
  "dompurify": "^3.4.7",
35
+ "lexical": "0.49.0",
24
36
  "lucide-react": "^0.468",
25
37
  "react-aria": "^3.50.0",
26
38
  "react-resizable-panels": "^2.1.9",
@@ -245,9 +245,11 @@ export const SpamActionUnavailable: Story = {
245
245
  * action (a report against a message already in Junk is a real no-op-move,
246
246
  * issue #648), so without a pending label the button gives no visible
247
247
  * response at all until the request lands — the dead-button failure the
248
- * coding standards call the worst outcome. The button stays clickable
249
- * throughout, same as the undo direction's `isUndoing`: the operation is
250
- * idempotent, so a second press is safe, never a queued duplicate.
248
+ * coding standards call the worst outcome. The button is disabled for the
249
+ * duration: the server dedupes message ids only within a single request, so
250
+ * a second press mid-flight would fire a second, concurrent request rather
251
+ * than join the first (issue #648 review). Still visibly a button, never
252
+ * dead — the "Reporting…" label carries that.
251
253
  */
252
254
  export const ReportSpamPending: Story = {
253
255
  args: {
@@ -188,10 +188,12 @@ export interface IntelligencePanelProps {
188
188
  * update for this action (issue #648: the client can't predict whether a
189
189
  * report against a message already in Junk is a no-op), so without this
190
190
  * the button gives no visible response at all until the request lands — a
191
- * dead button is the worst outcome. Swaps the label to "Reporting…"; the
192
- * handler stays wired throughout (same as `AutoMovedBadge`'s `isUndoing`)
193
- * because the operation is idempotent, so a second press mid-flight is
194
- * safe rather than a queued duplicate.
191
+ * dead button is the worst outcome. Swaps the label to "Reporting…" AND
192
+ * disables the button: the server dedupes message ids only within a
193
+ * single request, so a second press mid-flight fires a second, concurrent
194
+ * request rather than joining the first (issue #648 review — for "Not
195
+ * spam" this races the restore move and can surface a spurious
196
+ * "try again in a moment" for an undo that already succeeded).
195
197
  */
196
198
  reportSpamPending?: boolean;
197
199
  /** True while a "Not spam" (undo) press is in flight. Same treatment as `reportSpamPending`. */
@@ -336,17 +338,21 @@ function QuickAction({
336
338
  active,
337
339
  danger,
338
340
  onClick,
341
+ pending,
339
342
  }: {
340
343
  icon: ReactNode;
341
344
  label: string;
342
345
  active?: boolean;
343
346
  danger?: boolean;
344
347
  onClick?: () => void;
348
+ /** Disable the button without hiding it — a request for this action is already in flight. */
349
+ pending?: boolean;
345
350
  }) {
346
351
  // No handler means the action cannot be serviced yet (the sender's address
347
352
  // record has not resolved). Render it visibly unavailable rather than as a
348
- // live button that swallows the click.
349
- const disabled = onClick === undefined;
353
+ // live button that swallows the click. `pending` disables it for the same
354
+ // reason once a press is in flight.
355
+ const disabled = onClick === undefined || pending === true;
350
356
  return (
351
357
  <button
352
358
  type="button"
@@ -465,6 +471,7 @@ export function IntelligencePanel({
465
471
  label={notSpamPending ? "Undoing…" : "Not spam"}
466
472
  active
467
473
  onClick={actions.onNotSpam}
474
+ pending={notSpamPending}
468
475
  />
469
476
  )}
470
477
  {actions?.onReportSpam && (
@@ -473,6 +480,7 @@ export function IntelligencePanel({
473
480
  label={reportSpamPending ? "Reporting…" : "Report spam"}
474
481
  danger
475
482
  onClick={actions.onReportSpam}
483
+ pending={reportSpamPending}
476
484
  />
477
485
  )}
478
486
  <QuickAction
@@ -0,0 +1,28 @@
1
+ import { $generateHtmlFromNodes, $generateNodesFromDOM } from "@lexical/html";
2
+ import { $convertToMarkdownString, TRANSFORMERS } from "@lexical/markdown";
3
+ import type { LexicalEditor, LexicalNode } from "lexical";
4
+ import { sanitizeAdoptedHtml } from "../lib/adopted-html.js";
5
+ import type { RichTextValue } from "./rich-text-value.js";
6
+
7
+ export const $adoptHtml = (
8
+ editor: LexicalEditor,
9
+ html: string,
10
+ ): LexicalNode[] => {
11
+ const document = new DOMParser().parseFromString(
12
+ sanitizeAdoptedHtml(html),
13
+ "text/html",
14
+ );
15
+ return $generateNodesFromDOM(editor, document);
16
+ };
17
+
18
+ /**
19
+ * Lexical's export writes the editor's own theme onto every element — the app's
20
+ * Tailwind class names, a `white-space` span around each text run, computed
21
+ * table widths. None of that means anything in a recipient's client, so the
22
+ * outgoing document goes back through the same profile a paste comes in
23
+ * through.
24
+ */
25
+ export const $readRichText = (editor: LexicalEditor): RichTextValue => ({
26
+ html: sanitizeAdoptedHtml($generateHtmlFromNodes(editor, null)),
27
+ text: $convertToMarkdownString(TRANSFORMERS),
28
+ });
@@ -0,0 +1,174 @@
1
+ /**
2
+ * The editor mounted for real: a document it opens on renders as structure, and
3
+ * the value it reports back is the HTML that would be sent. React is imported
4
+ * after the jsdom globals are installed so its DOM bindings bind to jsdom's
5
+ * prototypes.
6
+ */
7
+ import assert from "node:assert/strict";
8
+ import { after, afterEach, before, beforeEach, describe, it } from "node:test";
9
+ import type { JSDOM } from "jsdom";
10
+ import type {
11
+ act as reactAct,
12
+ createElement as reactCreateElement,
13
+ } from "react";
14
+ import type { Root, createRoot as reactCreateRoot } from "react-dom/client";
15
+ import type { RichTextEditor as RichTextEditorType } from "./rich-text-editor.js";
16
+ import type { RichTextValue } from "./rich-text-value.js";
17
+
18
+ const DOCUMENT = [
19
+ "<h2>Quarterly numbers</h2>",
20
+ "<ul><li>Revenue up</li></ul>",
21
+ "<table><tbody><tr><td>EMEA</td><td>412</td></tr></tbody></table>",
22
+ ].join("");
23
+
24
+ let dom: JSDOM;
25
+ let container: HTMLElement;
26
+ let root: Root;
27
+ let act: typeof reactAct;
28
+ let createElement: typeof reactCreateElement;
29
+ let createRoot: typeof reactCreateRoot;
30
+ let RichTextEditor: typeof RichTextEditorType;
31
+
32
+ /** The toolbar acts on mousedown, so a click alone never reaches it. */
33
+ const press = (scope: HTMLElement, label: string): void => {
34
+ const button = scope.querySelector<HTMLButtonElement>(
35
+ `button[aria-label="${label}"]`,
36
+ );
37
+ if (!button) throw new Error(`no toolbar button labelled ${label}`);
38
+ button.dispatchEvent(
39
+ new dom.window.MouseEvent("mousedown", { bubbles: true, cancelable: true }),
40
+ );
41
+ };
42
+
43
+ before(async () => {
44
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
45
+ dom = new JSDOMCtor(
46
+ "<!doctype html><html><body><div id=root></div></body></html>",
47
+ { url: "http://localhost/", pretendToBeVisual: true },
48
+ );
49
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
50
+ globalThis.document = dom.window.document;
51
+ globalThis.HTMLElement = dom.window.HTMLElement;
52
+ globalThis.Element = dom.window.Element;
53
+ globalThis.Node = dom.window.Node;
54
+ globalThis.Event = dom.window.Event;
55
+ globalThis.MouseEvent = dom.window.MouseEvent;
56
+ globalThis.DOMParser = dom.window.DOMParser;
57
+ globalThis.MutationObserver = dom.window.MutationObserver;
58
+ globalThis.Range = dom.window.Range;
59
+ // jsdom rejects a listener whose `signal` is not one of its own, and the
60
+ // table plugin abort-signals its cell listeners.
61
+ globalThis.AbortController = dom.window.AbortController;
62
+ globalThis.AbortSignal = dom.window.AbortSignal;
63
+ globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window);
64
+ globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(
65
+ dom.window,
66
+ );
67
+ globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(
68
+ dom.window,
69
+ );
70
+ Object.defineProperty(globalThis, "navigator", {
71
+ value: dom.window.navigator,
72
+ configurable: true,
73
+ });
74
+ (
75
+ globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
76
+ ).IS_REACT_ACT_ENVIRONMENT = true;
77
+
78
+ ({ act, createElement } = await import("react"));
79
+ ({ createRoot } = await import("react-dom/client"));
80
+ ({ RichTextEditor } = await import("./rich-text-editor.js"));
81
+ });
82
+
83
+ // A container is used once. React refuses to create a second root over one it
84
+ // has already owned, and the editor holds a contenteditable that outlives the
85
+ // unmount otherwise.
86
+ beforeEach(() => {
87
+ container = dom.window.document.createElement("div");
88
+ dom.window.document.body.append(container);
89
+ });
90
+
91
+ afterEach(async () => {
92
+ await act(async () => {
93
+ root.unmount();
94
+ });
95
+ container.remove();
96
+ });
97
+
98
+ after(() => {
99
+ dom.window.close();
100
+ });
101
+
102
+ describe("RichTextEditor", () => {
103
+ it("renders the document it opens on and reports it back", async () => {
104
+ const seen: RichTextValue[] = [];
105
+
106
+ await act(async () => {
107
+ root = createRoot(container);
108
+ root.render(
109
+ createElement(RichTextEditor, {
110
+ initialHtml: DOCUMENT,
111
+ onChange: (value: RichTextValue) => {
112
+ seen.push(value);
113
+ },
114
+ }),
115
+ );
116
+ });
117
+
118
+ const editable = container.querySelector("[data-testid=compose-body]");
119
+ assert.ok(editable, "the editable surface is mounted");
120
+ assert.ok(editable.querySelector("h2"), "the heading renders as a heading");
121
+ assert.ok(editable.querySelector("ul li"), "the list renders as a list");
122
+ assert.ok(
123
+ editable.querySelector("table td"),
124
+ "the table renders as a table",
125
+ );
126
+
127
+ const latest = seen.at(-1);
128
+ assert.ok(latest, "the editor reported its value");
129
+ assert.match(latest.html, /<table/);
130
+ assert.match(latest.text, /## Quarterly numbers/);
131
+ });
132
+
133
+ it("offers the formatting controls the composer ships with", async () => {
134
+ await act(async () => {
135
+ root = createRoot(container);
136
+ root.render(createElement(RichTextEditor, {}));
137
+ });
138
+
139
+ const labels = [...container.querySelectorAll("button")].map((button) =>
140
+ button.getAttribute("aria-label"),
141
+ );
142
+ assert.deepEqual(labels, [
143
+ "Bold (Ctrl+B)",
144
+ "Italic (Ctrl+I)",
145
+ "Link (Ctrl+K)",
146
+ "Blockquote",
147
+ "Undo (Ctrl+Z)",
148
+ "Redo (Ctrl+Y)",
149
+ ]);
150
+ });
151
+
152
+ it("asks for an address rather than inserting an empty link", async () => {
153
+ await act(async () => {
154
+ root = createRoot(container);
155
+ root.render(createElement(RichTextEditor, {}));
156
+ });
157
+
158
+ assert.equal(
159
+ container.querySelector("[aria-label='Link address']"),
160
+ null,
161
+ "the address field stays out of the way until it is asked for",
162
+ );
163
+
164
+ await act(async () => {
165
+ press(container, "Link (Ctrl+K)");
166
+ });
167
+
168
+ const field = container.querySelector<HTMLInputElement>(
169
+ "[aria-label='Link address']",
170
+ );
171
+ assert.ok(field, "clicking Link opens the address field");
172
+ assert.equal(field.value, "https://");
173
+ });
174
+ });
@@ -0,0 +1,66 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { sanitizeAdoptedHtml } from "../lib/adopted-html.js";
3
+ import { RichTextEditor } from "./rich-text-editor.js";
4
+
5
+ const meta: Meta<typeof RichTextEditor> = {
6
+ title: "Mail/RichTextEditor",
7
+ component: RichTextEditor,
8
+ parameters: { layout: "centered" },
9
+ decorators: [
10
+ (Story) => (
11
+ <div className="w-[640px] overflow-hidden rounded-md border border-line bg-canvas">
12
+ <Story />
13
+ </div>
14
+ ),
15
+ ],
16
+ };
17
+ export default meta;
18
+
19
+ type Story = StoryObj<typeof RichTextEditor>;
20
+
21
+ const RICH_DOCUMENT = [
22
+ "<h2>Quarterly numbers</h2>",
23
+ "<p>Highlights <strong>this quarter</strong>, with the detail below.</p>",
24
+ "<ul><li>Revenue up 14%</li><li>Costs flat</li></ul>",
25
+ "<table><thead><tr><th>Region</th><th>Total</th></tr></thead>",
26
+ "<tbody><tr><td>EMEA</td><td>412</td></tr>",
27
+ "<tr><td>Americas</td><td>388</td></tr></tbody></table>",
28
+ '<p>Source: <a href="https://example.com/report">the full report</a>.</p>',
29
+ ].join("");
30
+
31
+ /**
32
+ * What a web page actually puts on the clipboard: presentation on every
33
+ * element, a tracking pixel, a script, and Word's conditional markup.
34
+ */
35
+ const CLIPBOARD_HTML = [
36
+ '<meta charset="utf-8">',
37
+ "<!--[if gte mso 9]><xml><w:WordDocument/></xml><![endif]-->",
38
+ "<style>.hdr{color:#c00}</style>",
39
+ '<h3 class="hdr" style="color:#c00;font-family:Verdana">Release checklist</h3>',
40
+ '<ol><li style="margin:0">Cut the tag</li><li>Publish the images</li></ol>',
41
+ '<table style="border:2px dashed #c00"><tbody>',
42
+ "<tr><th>Step</th><th>Owner</th></tr>",
43
+ "<tr><td>Tag</td><td>Ada</td></tr></tbody></table>",
44
+ '<img src="http://tracker.example/px.gif" width="1" height="1">',
45
+ '<script>fetch("https://tracker.example/steal")</script>',
46
+ ].join("");
47
+
48
+ export const Empty: Story = {
49
+ name: "Empty",
50
+ args: {},
51
+ };
52
+
53
+ export const RichContent: Story = {
54
+ name: "Rich content with a table",
55
+ args: { initialHtml: RICH_DOCUMENT },
56
+ };
57
+
58
+ /**
59
+ * The document after that clipboard has gone through the paste profile: the
60
+ * heading, the list and the table survive; the styling, the pixel and the
61
+ * script do not.
62
+ */
63
+ export const PasteResult: Story = {
64
+ name: "After pasting a web page",
65
+ args: { initialHtml: sanitizeAdoptedHtml(CLIPBOARD_HTML) },
66
+ };
@@ -0,0 +1,210 @@
1
+ import { LexicalComposer } from "@lexical/react/LexicalComposer";
2
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
3
+ import { ContentEditable } from "@lexical/react/LexicalContentEditable";
4
+ import { LexicalErrorBoundary } from "@lexical/react/LexicalErrorBoundary";
5
+ import { HistoryPlugin } from "@lexical/react/LexicalHistoryPlugin";
6
+ import { LinkPlugin } from "@lexical/react/LexicalLinkPlugin";
7
+ import { ListPlugin } from "@lexical/react/LexicalListPlugin";
8
+ import { RichTextPlugin } from "@lexical/react/LexicalRichTextPlugin";
9
+ import { TablePlugin } from "@lexical/react/LexicalTablePlugin";
10
+ import { mergeRegister } from "@lexical/utils";
11
+ import {
12
+ $getRoot,
13
+ $getSelection,
14
+ $insertNodes,
15
+ $isRangeSelection,
16
+ COMMAND_PRIORITY_CRITICAL,
17
+ COMMAND_PRIORITY_LOW,
18
+ KEY_DOWN_COMMAND,
19
+ type LexicalEditor,
20
+ PASTE_COMMAND,
21
+ } from "lexical";
22
+ import { useEffect, useRef } from "react";
23
+ import { $adoptHtml, $readRichText } from "./rich-text-document.js";
24
+ import { RICH_TEXT_NODES, richTextTheme } from "./rich-text-nodes.js";
25
+ import { RichTextToolbar } from "./rich-text-toolbar.js";
26
+ import type { RichTextValue } from "./rich-text-value.js";
27
+
28
+ export interface RichTextEditorProps {
29
+ /**
30
+ * The document the editor opens on. Read once — reopen a different document
31
+ * by remounting under a different `key`.
32
+ */
33
+ initialHtml?: string;
34
+ onChange?: (value: RichTextValue) => void;
35
+ onSubmit?: () => void;
36
+ autoFocus?: boolean;
37
+ placeholder?: string;
38
+ ariaLabel?: string;
39
+ }
40
+
41
+ /**
42
+ * Adopted HTML enters the document as structure, never as characters. Without
43
+ * this, a clipboard whose only markup lives in its text flavour puts the source
44
+ * of a web page into the message (#671).
45
+ *
46
+ * `Shift` on the paste keystroke selects the text flavour, matching Gmail and
47
+ * Apple Mail. The clipboard event carries no modifier state, so the keystroke
48
+ * that triggered it is what records the intent.
49
+ */
50
+ const PastePlugin = () => {
51
+ const [editor] = useLexicalComposerContext();
52
+ const plainRequested = useRef(false);
53
+
54
+ useEffect(
55
+ () =>
56
+ mergeRegister(
57
+ editor.registerCommand(
58
+ KEY_DOWN_COMMAND,
59
+ (event) => {
60
+ if (
61
+ (event.metaKey || event.ctrlKey) &&
62
+ event.key.toLowerCase() === "v"
63
+ ) {
64
+ plainRequested.current = event.shiftKey;
65
+ }
66
+ return false;
67
+ },
68
+ COMMAND_PRIORITY_LOW,
69
+ ),
70
+ editor.registerCommand(
71
+ PASTE_COMMAND,
72
+ (event) => {
73
+ // Lexical raises this command for `beforeinput` too, so the
74
+ // modifier is read only once the event carrying the clipboard has
75
+ // arrived — anything else would consume the intent.
76
+ if (!(event instanceof ClipboardEvent)) return false;
77
+ const clipboard = event.clipboardData;
78
+ if (!clipboard) return false;
79
+ const wasPlainRequested = plainRequested.current;
80
+ plainRequested.current = false;
81
+
82
+ if (wasPlainRequested) {
83
+ const selection = $getSelection();
84
+ if (!$isRangeSelection(selection)) return false;
85
+ event.preventDefault();
86
+ selection.insertRawText(clipboard.getData("text/plain"));
87
+ return true;
88
+ }
89
+
90
+ const html = clipboard.getData("text/html");
91
+ if (!html) return false;
92
+
93
+ event.preventDefault();
94
+ $insertNodes($adoptHtml(editor, html));
95
+ return true;
96
+ },
97
+ COMMAND_PRIORITY_CRITICAL,
98
+ ),
99
+ ),
100
+ [editor],
101
+ );
102
+
103
+ return null;
104
+ };
105
+
106
+ /**
107
+ * Reports the document on mount as well as on every edit. What the caller
108
+ * handed in as `initialHtml` is not what the editor holds — it has been through
109
+ * the paste profile and Lexical's own import — so a caller that assumed
110
+ * otherwise would autosave a body the composer is not showing.
111
+ */
112
+ const ChangePlugin = ({
113
+ onChange,
114
+ }: {
115
+ onChange: (value: RichTextValue) => void;
116
+ }) => {
117
+ const [editor] = useLexicalComposerContext();
118
+ const report = useRef(onChange);
119
+
120
+ useEffect(() => {
121
+ report.current = onChange;
122
+ }, [onChange]);
123
+
124
+ useEffect(() => {
125
+ const emit = () => report.current(editor.read(() => $readRichText(editor)));
126
+ emit();
127
+ return editor.registerUpdateListener(({ dirtyElements, dirtyLeaves }) => {
128
+ if (dirtyElements.size === 0 && dirtyLeaves.size === 0) return;
129
+ emit();
130
+ });
131
+ }, [editor]);
132
+
133
+ return null;
134
+ };
135
+
136
+ const AutoFocus = ({ enabled }: { enabled: boolean }) => {
137
+ const [editor] = useLexicalComposerContext();
138
+
139
+ useEffect(() => {
140
+ if (!enabled) return;
141
+ const timer = setTimeout(() => editor.focus(), 0);
142
+ return () => clearTimeout(timer);
143
+ }, [editor, enabled]);
144
+
145
+ return null;
146
+ };
147
+
148
+ const seedDocument =
149
+ (html: string) =>
150
+ (editor: LexicalEditor): void => {
151
+ const nodes = $adoptHtml(editor, html);
152
+ if (nodes.length === 0) return;
153
+ $getRoot().select();
154
+ $insertNodes(nodes);
155
+ };
156
+
157
+ export const RichTextEditor = ({
158
+ initialHtml,
159
+ onChange,
160
+ onSubmit,
161
+ autoFocus = false,
162
+ placeholder = "Write your message…",
163
+ ariaLabel = "Message body",
164
+ }: RichTextEditorProps) => (
165
+ <LexicalComposer
166
+ initialConfig={{
167
+ namespace: "compose",
168
+ nodes: RICH_TEXT_NODES,
169
+ theme: richTextTheme,
170
+ editorState: initialHtml ? seedDocument(initialHtml) : undefined,
171
+ onError: (error) => {
172
+ throw error;
173
+ },
174
+ }}
175
+ >
176
+ <RichTextToolbar />
177
+ <div className="relative">
178
+ <RichTextPlugin
179
+ contentEditable={
180
+ <ContentEditable
181
+ aria-label={ariaLabel}
182
+ aria-placeholder={placeholder}
183
+ data-testid="compose-body"
184
+ className="min-h-[120px] w-full bg-canvas px-3 py-2 text-sm text-fg outline-none"
185
+ placeholder={
186
+ <div className="pointer-events-none absolute inset-x-0 top-0 px-3 py-2 text-sm text-fg-subtle">
187
+ {placeholder}
188
+ </div>
189
+ }
190
+ onKeyDown={(event) => {
191
+ if (!onSubmit) return;
192
+ if (!(event.metaKey || event.ctrlKey) || event.key !== "Enter")
193
+ return;
194
+ event.preventDefault();
195
+ onSubmit();
196
+ }}
197
+ />
198
+ }
199
+ ErrorBoundary={LexicalErrorBoundary}
200
+ />
201
+ </div>
202
+ <HistoryPlugin />
203
+ <ListPlugin />
204
+ <LinkPlugin />
205
+ <TablePlugin />
206
+ <PastePlugin />
207
+ <AutoFocus enabled={autoFocus} />
208
+ {onChange && <ChangePlugin onChange={onChange} />}
209
+ </LexicalComposer>
210
+ );
@@ -0,0 +1,59 @@
1
+ import { CodeHighlightNode, CodeNode } from "@lexical/code-core";
2
+ import { AutoLinkNode, LinkNode } from "@lexical/link";
3
+ import { ListItemNode, ListNode } from "@lexical/list";
4
+ import { HorizontalRuleNode } from "@lexical/react/LexicalHorizontalRuleNode";
5
+ import { HeadingNode, QuoteNode } from "@lexical/rich-text";
6
+ import { TableCellNode, TableNode, TableRowNode } from "@lexical/table";
7
+ import type { EditorThemeClasses, Klass, LexicalNode } from "lexical";
8
+
9
+ /**
10
+ * Lexical maps a pasted element only to a node type the editor has registered;
11
+ * anything else collapses to its text. Registering headings, lists, tables,
12
+ * code and rules is what lets adopted structure survive the paste (#671).
13
+ */
14
+ export const RICH_TEXT_NODES: ReadonlyArray<Klass<LexicalNode>> = [
15
+ HeadingNode,
16
+ QuoteNode,
17
+ ListNode,
18
+ ListItemNode,
19
+ LinkNode,
20
+ AutoLinkNode,
21
+ TableNode,
22
+ TableRowNode,
23
+ TableCellNode,
24
+ CodeNode,
25
+ CodeHighlightNode,
26
+ HorizontalRuleNode,
27
+ ];
28
+
29
+ export const richTextTheme: EditorThemeClasses = {
30
+ code: "block my-2 rounded bg-surface-sunken p-2 font-mono text-xs whitespace-pre-wrap",
31
+ heading: {
32
+ h1: "mt-3 mb-1 text-xl font-semibold",
33
+ h2: "mt-3 mb-1 text-lg font-semibold",
34
+ h3: "mt-2 mb-1 text-base font-semibold",
35
+ h4: "mt-2 mb-1 text-sm font-semibold",
36
+ h5: "mt-2 mb-1 text-sm font-semibold",
37
+ h6: "mt-2 mb-1 text-xs font-semibold uppercase tracking-wide",
38
+ },
39
+ hr: "my-3 border-t border-line",
40
+ link: "text-accent underline",
41
+ list: {
42
+ listitem: "ml-2",
43
+ nested: { listitem: "list-none" },
44
+ ol: "my-1 list-decimal pl-6",
45
+ ul: "my-1 list-disc pl-6",
46
+ },
47
+ paragraph: "my-1",
48
+ quote: "my-1 border-l-2 border-fg-subtle/30 pl-3 text-fg-muted [&_p]:my-0.5",
49
+ table: "my-2 w-full table-fixed border-collapse",
50
+ tableCell: "border border-line px-2 py-1 align-top",
51
+ tableCellHeader: "bg-surface-sunken font-semibold",
52
+ text: {
53
+ bold: "font-semibold",
54
+ code: "rounded bg-surface-sunken px-1 font-mono text-xs",
55
+ italic: "italic",
56
+ strikethrough: "line-through",
57
+ underline: "underline",
58
+ },
59
+ };
@@ -0,0 +1,108 @@
1
+ /**
2
+ * #671: pasting a web page into compose put its HTML source into the message.
3
+ * Structure only survives when the editor has a node registered for the element
4
+ * it came from, so this drives the same pipeline the paste handler does —
5
+ * sanitize, parse, convert — and reads the document back out as the HTML that
6
+ * would be sent.
7
+ */
8
+ import assert from "node:assert/strict";
9
+ import { before, describe, it } from "node:test";
10
+ import type { JSDOM } from "jsdom";
11
+ import type { LexicalEditor } from "lexical";
12
+
13
+ const PASTED = [
14
+ '<meta charset="utf-8">',
15
+ '<h2 style="color:#111">Quarterly numbers</h2>',
16
+ "<p>Highlights <strong>this quarter</strong>:</p>",
17
+ "<ul><li>Revenue up</li><li>Costs flat</li></ul>",
18
+ "<table><thead><tr><th>Region</th><th>Total</th></tr></thead>",
19
+ "<tbody><tr><td>EMEA</td><td>412</td></tr></tbody></table>",
20
+ "<script>alert(1)</script>",
21
+ ].join("");
22
+
23
+ let adoptAndSerialize: (html: string) => { html: string; text: string };
24
+
25
+ before(async () => {
26
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
27
+ const dom: JSDOM = new JSDOMCtor(
28
+ "<!doctype html><html><body></body></html>",
29
+ { url: "http://localhost/" },
30
+ );
31
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
32
+ globalThis.document = dom.window.document;
33
+ globalThis.DOMParser = dom.window.DOMParser;
34
+ globalThis.HTMLElement = dom.window.HTMLElement;
35
+ globalThis.Element = dom.window.Element;
36
+ globalThis.Node = dom.window.Node;
37
+
38
+ const { $getRoot, $insertNodes, createEditor } = await import("lexical");
39
+ const { $adoptHtml, $readRichText } = await import("./rich-text-document.js");
40
+ const { RICH_TEXT_NODES } = await import("./rich-text-nodes.js");
41
+
42
+ adoptAndSerialize = (html: string) => {
43
+ const editor: LexicalEditor = createEditor({
44
+ namespace: "test",
45
+ nodes: [...RICH_TEXT_NODES],
46
+ onError: (error) => {
47
+ throw error;
48
+ },
49
+ });
50
+ editor.update(
51
+ () => {
52
+ const root = $getRoot();
53
+ root.clear();
54
+ root.select();
55
+ $insertNodes($adoptHtml(editor, html));
56
+ },
57
+ { discrete: true },
58
+ );
59
+ return editor.read(() => $readRichText(editor));
60
+ };
61
+ });
62
+
63
+ describe("adopting pasted HTML", () => {
64
+ it("keeps the heading, the list and the table", () => {
65
+ const { html } = adoptAndSerialize(PASTED);
66
+
67
+ assert.match(html, /<h2/);
68
+ assert.match(html, /Quarterly numbers/);
69
+ assert.match(html, /<ul/);
70
+ assert.match(html, /Revenue up/);
71
+ assert.match(html, /<table/);
72
+ assert.match(html, /EMEA/);
73
+ assert.match(html, /412/);
74
+ assert.match(html, /<strong/);
75
+ });
76
+
77
+ it("never carries the markup through as characters", () => {
78
+ const { html, text } = adoptAndSerialize(PASTED);
79
+
80
+ assert.equal(html.includes("&lt;h2"), false);
81
+ assert.equal(text.includes("<h2"), false);
82
+ assert.equal(text.includes("<table"), false);
83
+ });
84
+
85
+ it("leaves out the script and the author's presentation", () => {
86
+ const { html, text } = adoptAndSerialize(PASTED);
87
+
88
+ assert.equal(html.includes("<script"), false);
89
+ assert.equal(text.includes("alert(1)"), false);
90
+ assert.equal(html.includes("color:#111"), false);
91
+ });
92
+
93
+ it("leaves out this app's own presentation too", () => {
94
+ const { html } = adoptAndSerialize(PASTED);
95
+
96
+ assert.equal(html.includes('class="'), false);
97
+ assert.equal(html.includes("white-space"), false);
98
+ assert.equal(html.includes("style="), false);
99
+ });
100
+
101
+ it("writes the plain alternative as Markdown", () => {
102
+ const { text } = adoptAndSerialize(PASTED);
103
+
104
+ assert.match(text, /## Quarterly numbers/);
105
+ assert.match(text, /- Revenue up/);
106
+ assert.match(text, /\*\*this quarter\*\*/);
107
+ });
108
+ });
@@ -0,0 +1,237 @@
1
+ import { $isLinkNode, TOGGLE_LINK_COMMAND } from "@lexical/link";
2
+ import { useLexicalComposerContext } from "@lexical/react/LexicalComposerContext";
3
+ import { $createQuoteNode, $isQuoteNode } from "@lexical/rich-text";
4
+ import { $setBlocksType } from "@lexical/selection";
5
+ import { $findMatchingParent, mergeRegister } from "@lexical/utils";
6
+ import {
7
+ $createParagraphNode,
8
+ $getSelection,
9
+ $isRangeSelection,
10
+ CAN_REDO_COMMAND,
11
+ CAN_UNDO_COMMAND,
12
+ COMMAND_PRIORITY_LOW,
13
+ FORMAT_TEXT_COMMAND,
14
+ REDO_COMMAND,
15
+ SELECTION_CHANGE_COMMAND,
16
+ UNDO_COMMAND,
17
+ } from "lexical";
18
+ import { Bold, Italic, Link, Quote, Redo2, Undo2 } from "lucide-react";
19
+ import { useCallback, useEffect, useRef, useState } from "react";
20
+
21
+ const ToolbarButton = ({
22
+ isActive,
23
+ onClick,
24
+ children,
25
+ title,
26
+ }: {
27
+ isActive: boolean;
28
+ onClick: () => void;
29
+ children: React.ReactNode;
30
+ title: string;
31
+ }) => (
32
+ <button
33
+ type="button"
34
+ onMouseDown={(e) => {
35
+ e.preventDefault();
36
+ onClick();
37
+ }}
38
+ title={title}
39
+ aria-label={title}
40
+ aria-pressed={isActive}
41
+ className={`p-1.5 rounded transition-colors ${
42
+ isActive
43
+ ? "text-fg bg-accent-2-soft"
44
+ : "text-fg-muted hover:text-fg hover:bg-surface-raised"
45
+ }`}
46
+ >
47
+ {children}
48
+ </button>
49
+ );
50
+
51
+ interface ToolbarState {
52
+ bold: boolean;
53
+ italic: boolean;
54
+ quote: boolean;
55
+ link: string | null;
56
+ canUndo: boolean;
57
+ canRedo: boolean;
58
+ }
59
+
60
+ const INITIAL_STATE: ToolbarState = {
61
+ bold: false,
62
+ italic: false,
63
+ quote: false,
64
+ link: null,
65
+ canUndo: false,
66
+ canRedo: false,
67
+ };
68
+
69
+ export const RichTextToolbar = () => {
70
+ const [editor] = useLexicalComposerContext();
71
+ const [state, setState] = useState<ToolbarState>(INITIAL_STATE);
72
+ const [linkDraft, setLinkDraft] = useState<string | null>(null);
73
+ const linkInput = useRef<HTMLInputElement>(null);
74
+
75
+ const readSelection = useCallback(() => {
76
+ const selection = $getSelection();
77
+ if (!$isRangeSelection(selection)) return;
78
+ const anchor = selection.anchor.getNode();
79
+ const link = $findMatchingParent(anchor, $isLinkNode);
80
+ const quote = $findMatchingParent(anchor, $isQuoteNode);
81
+ setState((previous) => ({
82
+ ...previous,
83
+ bold: selection.hasFormat("bold"),
84
+ italic: selection.hasFormat("italic"),
85
+ quote: quote !== null,
86
+ link: $isLinkNode(link) ? link.getURL() : null,
87
+ }));
88
+ }, []);
89
+
90
+ useEffect(
91
+ () =>
92
+ mergeRegister(
93
+ editor.registerUpdateListener(({ editorState }) => {
94
+ editorState.read(readSelection);
95
+ }),
96
+ editor.registerCommand(
97
+ SELECTION_CHANGE_COMMAND,
98
+ () => {
99
+ readSelection();
100
+ return false;
101
+ },
102
+ COMMAND_PRIORITY_LOW,
103
+ ),
104
+ editor.registerCommand(
105
+ CAN_UNDO_COMMAND,
106
+ (canUndo) => {
107
+ setState((previous) => ({ ...previous, canUndo }));
108
+ return false;
109
+ },
110
+ COMMAND_PRIORITY_LOW,
111
+ ),
112
+ editor.registerCommand(
113
+ CAN_REDO_COMMAND,
114
+ (canRedo) => {
115
+ setState((previous) => ({ ...previous, canRedo }));
116
+ return false;
117
+ },
118
+ COMMAND_PRIORITY_LOW,
119
+ ),
120
+ ),
121
+ [editor, readSelection],
122
+ );
123
+
124
+ useEffect(() => {
125
+ if (linkDraft !== null) linkInput.current?.focus();
126
+ }, [linkDraft]);
127
+
128
+ const toggleQuote = () => {
129
+ editor.update(() => {
130
+ const selection = $getSelection();
131
+ if (!$isRangeSelection(selection)) return;
132
+ $setBlocksType(selection, () =>
133
+ state.quote ? $createParagraphNode() : $createQuoteNode(),
134
+ );
135
+ });
136
+ };
137
+
138
+ const applyLink = (url: string) => {
139
+ const trimmed = url.trim();
140
+ editor.dispatchCommand(
141
+ TOGGLE_LINK_COMMAND,
142
+ trimmed === "" ? null : trimmed,
143
+ );
144
+ setLinkDraft(null);
145
+ editor.focus();
146
+ };
147
+
148
+ return (
149
+ <div className="border-b border-line">
150
+ <div className="flex items-center gap-0.5 px-3 py-1">
151
+ <ToolbarButton
152
+ isActive={state.bold}
153
+ onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "bold")}
154
+ title="Bold (Ctrl+B)"
155
+ >
156
+ <Bold className="size-4" />
157
+ </ToolbarButton>
158
+ <ToolbarButton
159
+ isActive={state.italic}
160
+ onClick={() => editor.dispatchCommand(FORMAT_TEXT_COMMAND, "italic")}
161
+ title="Italic (Ctrl+I)"
162
+ >
163
+ <Italic className="size-4" />
164
+ </ToolbarButton>
165
+ <ToolbarButton
166
+ isActive={state.link !== null}
167
+ onClick={() => setLinkDraft(state.link ?? "https://")}
168
+ title="Link (Ctrl+K)"
169
+ >
170
+ <Link className="size-4" />
171
+ </ToolbarButton>
172
+ <ToolbarButton
173
+ isActive={state.quote}
174
+ onClick={toggleQuote}
175
+ title="Blockquote"
176
+ >
177
+ <Quote className="size-4" />
178
+ </ToolbarButton>
179
+ <div className="mx-1.5 h-4 w-px bg-line" />
180
+ <ToolbarButton
181
+ isActive={false}
182
+ onClick={() => editor.dispatchCommand(UNDO_COMMAND, undefined)}
183
+ title="Undo (Ctrl+Z)"
184
+ >
185
+ <Undo2 className={`size-4 ${state.canUndo ? "" : "opacity-40"}`} />
186
+ </ToolbarButton>
187
+ <ToolbarButton
188
+ isActive={false}
189
+ onClick={() => editor.dispatchCommand(REDO_COMMAND, undefined)}
190
+ title="Redo (Ctrl+Y)"
191
+ >
192
+ <Redo2 className={`size-4 ${state.canRedo ? "" : "opacity-40"}`} />
193
+ </ToolbarButton>
194
+ </div>
195
+ {linkDraft !== null && (
196
+ <div className="flex items-center gap-2 border-t border-line px-3 py-1.5">
197
+ <input
198
+ ref={linkInput}
199
+ type="url"
200
+ value={linkDraft}
201
+ aria-label="Link address"
202
+ placeholder="https://example.com"
203
+ className="min-w-0 flex-1 bg-transparent text-sm text-fg outline-none placeholder:text-fg-subtle"
204
+ onChange={(e) => setLinkDraft(e.target.value)}
205
+ onKeyDown={(e) => {
206
+ if (e.key === "Enter") {
207
+ e.preventDefault();
208
+ applyLink(linkDraft);
209
+ }
210
+ if (e.key === "Escape") {
211
+ e.preventDefault();
212
+ setLinkDraft(null);
213
+ editor.focus();
214
+ }
215
+ }}
216
+ />
217
+ <button
218
+ type="button"
219
+ className="text-xs text-accent hover:underline"
220
+ onClick={() => applyLink(linkDraft)}
221
+ >
222
+ Apply
223
+ </button>
224
+ {state.link !== null && (
225
+ <button
226
+ type="button"
227
+ className="text-xs text-fg-muted hover:text-fg"
228
+ onClick={() => applyLink("")}
229
+ >
230
+ Remove
231
+ </button>
232
+ )}
233
+ </div>
234
+ )}
235
+ </div>
236
+ );
237
+ };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * What the composer holds. `html` is what gets sent and what a draft stores —
3
+ * not the editor's own JSON, which changes shape between Lexical versions.
4
+ * `text` is the plain alternative, as Markdown over the same document.
5
+ *
6
+ * Kept apart from the editor so a caller can name the shape without pulling
7
+ * the editor, and its dependencies, out of their own chunk.
8
+ */
9
+ export interface RichTextValue {
10
+ html: string;
11
+ text: string;
12
+ }
13
+
14
+ export const EMPTY_RICH_TEXT: RichTextValue = { html: "", text: "" };
package/src/index.ts CHANGED
@@ -442,6 +442,10 @@ export {
442
442
  ResizablePanel,
443
443
  ResizablePanelGroup,
444
444
  } from "./components/resizable.js";
445
+ export {
446
+ EMPTY_RICH_TEXT,
447
+ type RichTextValue,
448
+ } from "./components/rich-text-value.js";
445
449
  export {
446
450
  APPOINTABLE_ROLES,
447
451
  type CandidateFolder,
@@ -633,6 +637,10 @@ export {
633
637
  flaggedFilterConfig,
634
638
  inboxFilterConfig,
635
639
  } from "./filter-presets.js";
640
+ export {
641
+ sanitizeAdoptedHtml,
642
+ sanitizeQuotedHtml,
643
+ } from "./lib/adopted-html.js";
636
644
  export {
637
645
  buildCidResolver,
638
646
  type CidResolvableBodyPart,
@@ -0,0 +1,134 @@
1
+ /**
2
+ * The paste profile decides what a message this app sends may contain. It runs
3
+ * over content nobody here wrote — a web page, another mail client, Word — so
4
+ * the fixtures below are the shapes those actually put on the clipboard.
5
+ */
6
+ import assert from "node:assert/strict";
7
+ import { before, describe, it } from "node:test";
8
+ import type { JSDOM } from "jsdom";
9
+ import type {
10
+ sanitizeAdoptedHtml as SanitizeAdoptedHtml,
11
+ sanitizeQuotedHtml as SanitizeQuotedHtml,
12
+ } from "./adopted-html.js";
13
+
14
+ let sanitizeAdoptedHtml: typeof SanitizeAdoptedHtml;
15
+ let sanitizeQuotedHtml: typeof SanitizeQuotedHtml;
16
+
17
+ before(async () => {
18
+ const { JSDOM: JSDOMCtor } = await import("jsdom");
19
+ const dom: JSDOM = new JSDOMCtor(
20
+ "<!doctype html><html><body></body></html>",
21
+ { url: "http://localhost/" },
22
+ );
23
+ globalThis.window = dom.window as unknown as typeof globalThis.window;
24
+ globalThis.document = dom.window.document;
25
+ ({ sanitizeAdoptedHtml, sanitizeQuotedHtml } = await import(
26
+ "./adopted-html.js"
27
+ ));
28
+ });
29
+
30
+ describe("sanitizeAdoptedHtml", () => {
31
+ it("keeps the structure a pasted document is made of", () => {
32
+ const result = sanitizeAdoptedHtml(
33
+ [
34
+ "<h2>Quarterly numbers</h2>",
35
+ "<ul><li>Revenue up</li><li>Costs flat</li></ul>",
36
+ "<table><thead><tr><th>Region</th><th>Total</th></tr></thead>",
37
+ '<tbody><tr><td colspan="2">EMEA 412</td></tr></tbody></table>',
38
+ "<blockquote><p>As discussed</p></blockquote>",
39
+ "<pre><code>npm run build</code></pre>",
40
+ "<hr>",
41
+ ].join(""),
42
+ );
43
+
44
+ assert.match(result, /<h2>Quarterly numbers<\/h2>/);
45
+ assert.match(result, /<li>Revenue up<\/li>/);
46
+ assert.match(result, /<th>Region<\/th>/);
47
+ assert.match(result, /colspan="2"/);
48
+ assert.match(result, /<blockquote>/);
49
+ assert.match(result, /<pre>/);
50
+ assert.match(result, /<hr>/);
51
+ });
52
+
53
+ it("keeps each div on its own line", () => {
54
+ const result = sanitizeAdoptedHtml("<div>first</div><div>second</div>");
55
+ assert.match(result, /<div>first<\/div><div>second<\/div>/);
56
+ });
57
+
58
+ it("drops script, style and the presentation a receiving client rewrites", () => {
59
+ const result = sanitizeAdoptedHtml(
60
+ [
61
+ "<style>.x{color:red}</style>",
62
+ "<script>alert(1)</script>",
63
+ '<p class="x" id="y" data-track="z" style="color:red" onclick="steal()">Hello</p>',
64
+ '<span style="font-size:48px">world</span>',
65
+ ].join(""),
66
+ );
67
+
68
+ assert.equal(result.includes("<script"), false);
69
+ assert.equal(result.includes("<style"), false);
70
+ assert.equal(result.includes("color:red"), false);
71
+ assert.equal(result.includes("onclick"), false);
72
+ assert.equal(result.includes("class="), false);
73
+ assert.equal(result.includes("data-track"), false);
74
+ assert.match(result, /Hello/);
75
+ assert.match(result, /world/);
76
+ });
77
+
78
+ it("drops Word's conditional markup and comments", () => {
79
+ const result = sanitizeAdoptedHtml(
80
+ "<!--[if gte mso 9]><xml><w:WordDocument/></xml><![endif]--><p>Memo</p>",
81
+ );
82
+ assert.equal(result.includes("WordDocument"), false);
83
+ assert.match(result, /<p>Memo<\/p>/);
84
+ });
85
+
86
+ it("keeps mail and web links and defuses every other scheme", () => {
87
+ const result = sanitizeAdoptedHtml(
88
+ [
89
+ '<a href="https://example.com/a">web</a>',
90
+ '<a href="mailto:ada@example.com">mail</a>',
91
+ '<a href="javascript:alert(1)">bad</a>',
92
+ '<a href="file:///etc/passwd">local</a>',
93
+ ].join(""),
94
+ );
95
+
96
+ assert.match(result, /href="https:\/\/example.com\/a"/);
97
+ assert.match(result, /href="mailto:ada@example.com"/);
98
+ assert.equal(result.includes("javascript:"), false);
99
+ assert.equal(result.includes("file:"), false);
100
+ assert.match(result, /bad/);
101
+ });
102
+
103
+ it("keeps images the recipient can actually load", () => {
104
+ const result = sanitizeAdoptedHtml(
105
+ [
106
+ '<img src="https://example.com/logo.png" alt="Logo">',
107
+ '<img src="data:image/png;base64,iVBORw0KGgo=" alt="Inline">',
108
+ '<img src="http://example.com/tracker.gif" alt="Tracker">',
109
+ '<img src="cid:part1" alt="Attached">',
110
+ ].join(""),
111
+ );
112
+
113
+ assert.match(result, /src="https:\/\/example.com\/logo.png"/);
114
+ assert.match(result, /src="data:image\/png;base64,/);
115
+ assert.equal(result.includes("tracker.gif"), false);
116
+ assert.equal(result.includes("cid:part1"), false);
117
+ });
118
+ });
119
+
120
+ describe("sanitizeQuotedHtml", () => {
121
+ it("sends a quoted link to its own context", () => {
122
+ const result = sanitizeQuotedHtml('<a href="https://example.com">go</a>');
123
+ assert.match(result, /target="_blank"/);
124
+ assert.match(result, /rel="noopener noreferrer nofollow"/);
125
+ });
126
+
127
+ it("applies the same allowlist as an ordinary paste", () => {
128
+ const result = sanitizeQuotedHtml(
129
+ '<p style="color:red">Hi</p><script>alert(1)</script>',
130
+ );
131
+ assert.equal(result.includes("<script"), false);
132
+ assert.equal(result.includes("color:red"), false);
133
+ });
134
+ });
@@ -0,0 +1,123 @@
1
+ import DOMPurify from "dompurify";
2
+
3
+ /**
4
+ * Adopted content is HTML from outside — a paste, a quoted reply — that becomes
5
+ * part of a message this app sends under the user's name. That is the opposite
6
+ * case from `createEmailSanitizer`, which renders untrusted mail inside a
7
+ * sandboxed frame and keeps author `style`, `bgcolor` and `<style>` so a
8
+ * newsletter still looks like itself. Output this app signs has to survive the
9
+ * sanitizer of every receiving client, so it keeps structure and drops
10
+ * presentation.
11
+ */
12
+ const ADOPTED_TAGS = [
13
+ "p",
14
+ "br",
15
+ // A web page and a Gmail clipboard put each line in its own `div`. Unwrapped,
16
+ // those lines arrive as one run of adjacent text and the editor merges them
17
+ // into a single paragraph; kept, Lexical's HTML import reads each one as its
18
+ // own block.
19
+ "div",
20
+ "h1",
21
+ "h2",
22
+ "h3",
23
+ "h4",
24
+ "h5",
25
+ "h6",
26
+ "strong",
27
+ "b",
28
+ "em",
29
+ "i",
30
+ "u",
31
+ "s",
32
+ "ul",
33
+ "ol",
34
+ "li",
35
+ "blockquote",
36
+ "pre",
37
+ "code",
38
+ "hr",
39
+ "table",
40
+ "thead",
41
+ "tbody",
42
+ "tr",
43
+ "th",
44
+ "td",
45
+ "a",
46
+ "img",
47
+ ];
48
+
49
+ const ADOPTED_ATTR = ["href", "src", "alt", "colspan", "rowspan"];
50
+
51
+ const LINK_SCHEMES = /^(?:https?:|mailto:)/i;
52
+ const IMAGE_SCHEMES = /^(?:https:|data:image\/)/i;
53
+
54
+ /**
55
+ * `"quoted"` is mail rendered back at its sender, and it renders in the app's
56
+ * own document rather than in the reading pane's sandboxed frame. A link in it
57
+ * would otherwise navigate the app window itself to wherever the sender points,
58
+ * and launched from a home screen there is no address bar and no back button to
59
+ * return from that — so quoted links leave for a separate context and take no
60
+ * handle on this one with them. Images are dropped outright there: the frame's
61
+ * remote-content gate does not cover this document, and loading one tells the
62
+ * sender when the reply was opened.
63
+ */
64
+ type Profile = "outgoing" | "quoted";
65
+
66
+ const createPurifier = (profile: Profile): ReturnType<typeof DOMPurify> => {
67
+ const instance = DOMPurify();
68
+ if (!instance.isSupported) {
69
+ throw new Error("adopted HTML cannot be sanitized: no DOM is available");
70
+ }
71
+ instance.addHook("afterSanitizeAttributes", (node) => {
72
+ if (node.tagName === "A") {
73
+ if (!LINK_SCHEMES.test(node.getAttribute("href") ?? "")) {
74
+ node.removeAttribute("href");
75
+ }
76
+ if (profile === "quoted") {
77
+ node.setAttribute("target", "_blank");
78
+ node.setAttribute("rel", "noopener noreferrer nofollow");
79
+ }
80
+ return;
81
+ }
82
+ if (node.tagName !== "IMG") return;
83
+ if (profile === "quoted") {
84
+ node.remove();
85
+ return;
86
+ }
87
+ if (!IMAGE_SCHEMES.test(node.getAttribute("src") ?? "")) node.remove();
88
+ });
89
+ return instance;
90
+ };
91
+
92
+ const purifiers = new Map<Profile, ReturnType<typeof DOMPurify>>();
93
+
94
+ const purifier = (profile: Profile): ReturnType<typeof DOMPurify> => {
95
+ const existing = purifiers.get(profile);
96
+ if (existing) return existing;
97
+ const created = createPurifier(profile);
98
+ purifiers.set(profile, created);
99
+ return created;
100
+ };
101
+
102
+ const sanitize = (html: string, profile: Profile): string =>
103
+ purifier(profile).sanitize(html, {
104
+ ALLOWED_TAGS: ADOPTED_TAGS,
105
+ ALLOWED_ATTR: ADOPTED_ATTR,
106
+ // Both default to true and are honoured outside ALLOWED_ATTR, so a
107
+ // `data-` or `aria-` attribute would otherwise ride along — which is where
108
+ // a web page keeps its own framework's state.
109
+ ALLOW_DATA_ATTR: false,
110
+ ALLOW_ARIA_ATTR: false,
111
+ });
112
+
113
+ /**
114
+ * The paste profile: what may enter — and leave — a document this app sends.
115
+ * It runs on the way in over a clipboard, and again on the way out over the
116
+ * editor's own serialization, which carries the app's stylesheet with it.
117
+ */
118
+ export const sanitizeAdoptedHtml = (html: string): string =>
119
+ sanitize(html, "outgoing");
120
+
121
+ /** The same profile for mail quoted back at its sender, links and images defanged. */
122
+ export const sanitizeQuotedHtml = (html: string): string =>
123
+ sanitize(html, "quoted");
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The rich-text editor and nothing else. Its own entry point so an app can load
3
+ * it on demand: reached through the package barrel it would land in whichever
4
+ * chunk already imports `@remit/ui`, which is every screen.
5
+ */
6
+ export {
7
+ RichTextEditor,
8
+ type RichTextEditorProps,
9
+ } from "./components/rich-text-editor.js";
10
+ export {
11
+ EMPTY_RICH_TEXT,
12
+ type RichTextValue,
13
+ } from "./components/rich-text-value.js";