@remit/ui 0.0.96 → 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 +13 -1
- package/src/components/rich-text-document.ts +28 -0
- package/src/components/rich-text-editor.mount.test.ts +174 -0
- package/src/components/rich-text-editor.stories.tsx +66 -0
- package/src/components/rich-text-editor.tsx +210 -0
- package/src/components/rich-text-nodes.ts +59 -0
- package/src/components/rich-text-paste.test.ts +108 -0
- package/src/components/rich-text-toolbar.tsx +237 -0
- package/src/components/rich-text-value.ts +14 -0
- package/src/index.ts +8 -0
- package/src/lib/adopted-html.test.ts +134 -0
- package/src/lib/adopted-html.ts +123 -0
- package/src/rich-text.ts +13 -0
package/package.json
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@remit/ui",
|
|
3
|
-
"version": "0.0.
|
|
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",
|
|
@@ -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("<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");
|
package/src/rich-text.ts
ADDED
|
@@ -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";
|