@remit/ui 0.0.109 → 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.
@@ -121,7 +121,7 @@ const mount = async (
121
121
  initial = "",
122
122
  extra: {
123
123
  onSubmit?: () => void;
124
- autoFocus?: boolean;
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
- autoFocus: extra.autoFocus,
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 ?? "", /Plain text · Markdown/);
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.", { autoFocus: true });
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));
@@ -50,6 +50,7 @@ const Surface = ({
50
50
  <ComposeLanguageChip
51
51
  language={language}
52
52
  languages={["nl", "en", "de"]}
53
+ source="detected"
53
54
  onSelect={setLanguage}
54
55
  />
55
56
  <ComposeModeToggle
@@ -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
- /** Takes focus on mount, caret after the last character. */
18
- autoFocus?: boolean;
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
- autoFocus = false,
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 (!autoFocus) return;
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 end = textarea.value.length;
96
- textarea.setSelectionRange(end, end);
99
+ const at = initialCaret === "start" ? 0 : textarea.value.length;
100
+ textarea.setSelectionRange(at, at);
97
101
  }, 0);
98
102
  return () => clearTimeout(timer);
99
- }, [autoFocus]);
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
- Plain text · Markdown
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 with no soft wrap, because a pipe
172
- table in a proportional face that breaks mid-row reads as the broken
173
- output this mode exists to avoid. */}
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 overflow-x-auto whitespace-pre bg-canvas px-3 py-2 font-mono text-base text-fg outline-none placeholder:text-fg-subtle"
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
- autoFocus?: boolean;
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 = ({ enabled }: { enabled: boolean }) => {
442
+ const AutoFocus = ({ caret }: { caret?: ComposeCaret }) => {
437
443
  const [editor] = useLexicalComposerContext();
438
444
 
439
445
  useEffect(() => {
440
- if (!enabled) return;
441
- const timer = setTimeout(() => editor.focus(), 0);
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, enabled]);
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
- autoFocus = false,
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 enabled={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 no registered node can hold",
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: false,
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",
@@ -10,6 +10,15 @@ import { before, describe, it } from "node:test";
10
10
  import type { JSDOM } from "jsdom";
11
11
  import type { LexicalEditor } from "lexical";
12
12
 
13
+ const PASTED_IMAGE = [
14
+ "<p>before</p>",
15
+ '<p><img src="https://example.com/logo.png" alt="The logo"></p>',
16
+ "<p>after</p>",
17
+ ].join("");
18
+
19
+ const PIXEL =
20
+ "data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
21
+
13
22
  const PASTED = [
14
23
  '<meta charset="utf-8">',
15
24
  '<h2 style="color:#111">Quarterly numbers</h2>',
@@ -106,3 +115,42 @@ describe("adopting pasted HTML", () => {
106
115
  assert.match(text, /\*\*this quarter\*\*/);
107
116
  });
108
117
  });
118
+
119
+ /**
120
+ * #684: the sanitizer admitted the element and the editor had nowhere to put
121
+ * it, so the picture was gone by the time the paste landed — between two
122
+ * paragraphs that both survived.
123
+ */
124
+ describe("adopting a pasted image", () => {
125
+ it("keeps the image between the text around it", () => {
126
+ const { html } = adoptAndSerialize(PASTED_IMAGE);
127
+
128
+ assert.match(html, /<img[^>]+src="https:\/\/example\.com\/logo\.png"/);
129
+ assert.match(html, /<img[^>]+alt="The logo"/);
130
+ assert.match(html, /before/);
131
+ assert.match(html, /after/);
132
+ });
133
+
134
+ it("keeps an image the clipboard carried as its own data", () => {
135
+ const { html } = adoptAndSerialize(
136
+ `<p><img src="${PIXEL}" alt="A pixel"></p>`,
137
+ );
138
+
139
+ assert.ok(html.includes(`src="${PIXEL}"`));
140
+ assert.match(html, /alt="A pixel"/);
141
+ });
142
+
143
+ it("sends the image with a width the recipient's layout survives", () => {
144
+ const { html } = adoptAndSerialize(PASTED_IMAGE);
145
+
146
+ assert.match(html, /<img[^>]+style="max-width:100%;height:auto"/);
147
+ });
148
+
149
+ it("drops an image this app would not send", () => {
150
+ const { html } = adoptAndSerialize(
151
+ '<p><img src="http://tracker.example/px.gif"></p>',
152
+ );
153
+
154
+ assert.equal(html.includes("<img"), false);
155
+ });
156
+ });
@@ -17,6 +17,12 @@ export interface RichTextValue {
17
17
  formatting: readonly string[];
18
18
  }
19
19
 
20
+ /**
21
+ * Where a writing surface puts the caret when it takes focus on mount. Both
22
+ * surfaces read it, so it lives with the value rather than with either one.
23
+ */
24
+ export type ComposeCaret = "start" | "end";
25
+
20
26
  export const EMPTY_RICH_TEXT: RichTextValue = {
21
27
  html: "",
22
28
  text: "",