Composer: smarter suggestions, links over selections, big images attached
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 3m5s
ci / announce (pull_request) Skipped
ci / node (pull_request) Skipped
ci / version (pull_request) Skipped
ci / docker-build (pull_request) Skipped
ci / publish (pull_request) Skipped
github/ci (branch) GitHub Actions
ci / github (pull_request) Successful in 3m5s
ci / announce (pull_request) Skipped
Recipient suggestions: - the words typed match in any order, each one the start of a word in the name, a nickname, the organization or the address: "jane smi" finds "Smith, Jane", and "globex" finds the people at Globex; - someone written to lately ranks a little above an equal match; - an address already in To, Cc or Bcc is no longer offered in the other two fields. Pasting: - a single web or mailto address pasted over selected words makes those words the link, instead of replacing them with the address; - a pasted or dropped image over 10 MB goes in as an attachment rather than inline, where it would swell every reply. No new strings.
This commit is contained in:
1 parent
3434a5ed39
commit
b11f3997d4
7 files changed
+275
-15
No files matched your search
@@ -0,0 +1,102 @@
|
||||
import { act } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { INLINE_IMAGE_MAX, RichEditor, isLinkToPaste } from "../RichEditor";
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.ResizeObserver ??= class { observe() {} unobserve() {} disconnect() {} } as unknown as typeof ResizeObserver;
|
||||
|
||||
/*
|
||||
* Two things pasting into a message now does: a link pasted over selected
|
||||
* words turns those words into the link, and an image too large to belong
|
||||
* in the text goes in as an attachment. jsdom does no editing, so these check
|
||||
* the command and the callback the editor reaches for.
|
||||
*/
|
||||
|
||||
describe("what counts as a link to paste", () => {
|
||||
it("takes one web or mailto address", () => {
|
||||
expect(isLinkToPaste("https://example.com/a?b=1")).toBe(true);
|
||||
expect(isLinkToPaste("http://example.com")).toBe(true);
|
||||
expect(isLinkToPaste("mailto:[email protected]")).toBe(true);
|
||||
});
|
||||
|
||||
it("leaves ordinary text and anything with spaces alone", () => {
|
||||
expect(isLinkToPaste("see https://example.com")).toBe(false);
|
||||
expect(isLinkToPaste("example.com")).toBe(false);
|
||||
expect(isLinkToPaste("javascript:alert(1)")).toBe(false);
|
||||
expect(isLinkToPaste("")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pasting into the editor", () => {
|
||||
let host: HTMLDivElement;
|
||||
let root: Root;
|
||||
let onFiles: ReturnType<typeof vi.fn<(files: File[]) => void>>;
|
||||
let exec: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
host = document.createElement("div");
|
||||
document.body.appendChild(host);
|
||||
root = createRoot(host);
|
||||
onFiles = vi.fn<(files: File[]) => void>();
|
||||
exec = vi.fn(() => true);
|
||||
(document as unknown as { execCommand: unknown }).execCommand = exec;
|
||||
await act(async () => {
|
||||
root.render(<RichEditor html="<p>read the docs here</p>" onChange={() => {}} onFiles={onFiles} showToolbar={false} />);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await act(async () => root.unmount());
|
||||
host.remove();
|
||||
});
|
||||
|
||||
const editor = () => host.querySelector<HTMLElement>('[contenteditable="true"]')!;
|
||||
|
||||
const paste = async (data: { text?: string; html?: string; files?: File[] }) => {
|
||||
const items = (data.files ?? []).map((f) => ({ type: f.type, kind: "file", getAsFile: () => f }));
|
||||
const ev = new Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(ev, "clipboardData", {
|
||||
value: { items, getData: (t: string) => (t === "text/plain" ? data.text ?? "" : t === "text/html" ? data.html ?? "" : "") },
|
||||
});
|
||||
await act(async () => editor().dispatchEvent(ev));
|
||||
return ev;
|
||||
};
|
||||
|
||||
const select = (word: string) => {
|
||||
const text = editor().querySelector("p")!.firstChild!;
|
||||
const i = text.textContent!.indexOf(word);
|
||||
const r = document.createRange();
|
||||
r.setStart(text, i);
|
||||
r.setEnd(text, i + word.length);
|
||||
const sel = window.getSelection()!;
|
||||
sel.removeAllRanges();
|
||||
sel.addRange(r);
|
||||
};
|
||||
|
||||
it("turns selected words into the pasted link", async () => {
|
||||
select("docs");
|
||||
const ev = await paste({ text: "https://example.com/docs" });
|
||||
expect(ev.defaultPrevented).toBe(true);
|
||||
expect(exec).toHaveBeenCalledWith("createLink", false, "https://example.com/docs");
|
||||
});
|
||||
|
||||
it("pastes a link as text when nothing is selected", async () => {
|
||||
window.getSelection()!.removeAllRanges();
|
||||
await paste({ text: "https://example.com/docs" });
|
||||
expect(exec).not.toHaveBeenCalledWith("createLink", expect.anything(), expect.anything());
|
||||
});
|
||||
|
||||
it("attaches a pasted image too large to sit in the text", async () => {
|
||||
const big = new File([new Uint8Array(8)], "huge.png", { type: "image/png" });
|
||||
Object.defineProperty(big, "size", { value: INLINE_IMAGE_MAX + 1 });
|
||||
await paste({ files: [big] });
|
||||
expect(onFiles).toHaveBeenCalledWith([big]);
|
||||
});
|
||||
|
||||
it("keeps a normal-sized pasted image in the text", async () => {
|
||||
const small = new File([new Uint8Array(8)], "shot.png", { type: "image/png" });
|
||||
await paste({ files: [small] });
|
||||
expect(onFiles).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user