fix(editor): preserve literal rich-text whitespace (#3472)

* fix(web): preserve imported rich-text tables

* fix(web): harden imported table preservation

* fix(web): fail closed on lossy table markup

* chore: remove plan 16 evidence reports

* fix(web): close imported table preservation gaps

* fix(editor): preserve literal rich-text whitespace

* chore: remove plan 19 evidence report

* fix: preserve literal whitespace through layout and editor transforms

* fix: preserve whitespace in bare table cells

* chore: keep orchestration evidence untracked
This commit is contained in:
Amruth Pillai
2026-09-05 19:57:50 -07:00
committed by GitHub
parent a6057abd79
commit ea97de5ec4
16 changed files with 1141 additions and 36 deletions
+2
View File
@@ -58,6 +58,8 @@
"@tanstack/react-router": "^1.170.32",
"@tiptap/extension-color": "^3.31.2",
"@tiptap/extension-highlight": "^3.31.2",
"@tiptap/extension-heading": "^3.31.2",
"@tiptap/extension-paragraph": "^3.31.2",
"@tiptap/extension-table": "^3.31.2",
"@tiptap/extension-text-align": "^3.31.2",
"@tiptap/extension-text-style": "^3.31.2",
@@ -0,0 +1,210 @@
import type { Level } from "@tiptap/extension-heading";
import type { Node as ProseMirrorNode, TagParseRule } from "@tiptap/pm/model";
import { Heading } from "@tiptap/extension-heading";
import { Paragraph } from "@tiptap/extension-paragraph";
import { DOMSerializer, DOMParser as ProseMirrorDOMParser } from "@tiptap/pm/model";
import { Plugin } from "@tiptap/pm/state";
import { ReplaceAroundStep, ReplaceStep } from "@tiptap/pm/transform";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
import { combineTransactionSteps, Extension, getChangedRanges } from "@tiptap/react";
export const whitespaceAttribute = "data-resume-whitespace";
export const whitespacePreserveValue = "preserve";
const preservedWhitespace = {
default: null,
parseHTML: (element: HTMLElement) =>
element.getAttribute(whitespaceAttribute) === whitespacePreserveValue ? whitespacePreserveValue : null,
renderHTML: (attributes: { resumeWhitespace?: string | null }) =>
attributes.resumeWhitespace === whitespacePreserveValue ? { [whitespaceAttribute]: whitespacePreserveValue } : {},
};
export const LiteralParagraph = Paragraph.extend({
addAttributes() {
return { ...this.parent?.(), resumeWhitespace: preservedWhitespace };
},
parseHTML() {
return [
{ tag: `p[${whitespaceAttribute}="${whitespacePreserveValue}"]`, preserveWhitespace: "full" },
{ tag: "p" },
] as TagParseRule[];
},
});
export const LiteralHeading = Heading.extend({
addAttributes() {
return { ...this.parent?.(), resumeWhitespace: preservedWhitespace };
},
parseHTML() {
return this.options.levels.flatMap((level: Level) => [
{
tag: `h${level}[${whitespaceAttribute}="${whitespacePreserveValue}"]`,
attrs: { level },
preserveWhitespace: "full",
},
{ tag: `h${level}`, attrs: { level } },
]) as TagParseRule[];
},
});
const isPreservableTextBlock = (node: ProseMirrorNode) =>
node.type.name === "paragraph" || node.type.name === "heading";
// Mark real text blocks before ProseMirror collapses their whitespace. Containers
// that its parser turns into paragraphs need the same explicit block boundary.
const markPastedHtml = (html: string) => {
const root = document.createElement("div");
root.innerHTML = html;
const blockTags = /^(P|H[1-6]|DIV|BLOCKQUOTE|UL|OL|LI|PRE|HR|TABLE)$/;
const normalize = (container: HTMLElement) => {
let paragraph: HTMLParagraphElement | undefined;
for (const child of Array.from(container.childNodes)) {
// Clipboard envelopes and ignored metadata do not create editor blocks.
if (
child.nodeType === Node.COMMENT_NODE ||
(child instanceof HTMLElement && /^(HEAD|META|LINK|NOSCRIPT|OBJECT|SCRIPT|STYLE|TITLE)$/.test(child.tagName))
)
continue;
if (child instanceof HTMLElement && blockTags.test(child.tagName)) {
paragraph = undefined;
if (/^(DIV|BLOCKQUOTE|LI)$/.test(child.tagName)) normalize(child);
else if (/^(UL|OL)$/.test(child.tagName)) {
for (const item of Array.from(child.children)) if (item instanceof HTMLElement) normalize(item);
} else if (child.tagName === "TABLE") {
for (const cell of child.querySelectorAll("td, th")) {
if (cell instanceof HTMLElement) normalize(cell);
}
}
continue;
}
// Formatting whitespace between blocks is not an authored paragraph.
if (
!paragraph &&
child.nodeType === Node.TEXT_NODE &&
!/[^\t\n\f\r ]/.test(child.textContent ?? "") &&
Array.from(container.children).some((element) => blockTags.test(element.tagName))
)
continue;
if (!paragraph) {
paragraph = document.createElement("p");
container.insertBefore(paragraph, child);
}
paragraph.appendChild(child);
}
};
normalize(root);
for (const block of root.querySelectorAll("p,h1,h2,h3,h4,h5,h6")) {
if (!block.hasAttribute(whitespaceAttribute)) block.setAttribute(whitespaceAttribute, whitespacePreserveValue);
}
return root.innerHTML;
};
type LiteralWhitespaceOptions = {
hasUnsupportedTableMarkup: (html: string) => boolean;
};
export const LiteralWhitespaceInput = Extension.create<LiteralWhitespaceOptions>({
name: "literalWhitespaceInput",
addOptions() {
return { hasUnsupportedTableMarkup: (html) => /<\/?table(?=\s|\/?>|$)/i.test(html) };
},
addProseMirrorPlugins() {
let rejectHtmlPaste = false;
return [
new Plugin({
props: {
decorations(state) {
const tabs: Decoration[] = [];
state.doc.descendants((block, blockPosition) => {
if (!isPreservableTextBlock(block) || block.attrs.resumeWhitespace !== whitespacePreserveValue) return;
block.descendants((node, offset) => {
if (!node.isText || !node.text) return;
for (const match of node.text.matchAll(/\t/g)) {
const from = blockPosition + 1 + offset + match.index;
// Each inline box starts its own tab stops at zero: exactly four
// ordinary spaces in the inherited font, regardless of preceding text.
// Decorations leave the single stored character and editor positions intact.
tabs.push(
Decoration.inline(from, from + 1, {
style: "display: inline-block; white-space: pre; tab-size: 4;",
}),
);
}
});
return false;
});
return DecorationSet.create(state.doc, tabs);
},
transformPastedHTML: (html) => {
// Inspect original bytes before either DOM parser can repair unsafe tables.
rejectHtmlPaste = this.options.hasUnsupportedTableMarkup(html);
return rejectHtmlPaste ? "" : markPastedHtml(html);
},
handlePaste: () => {
const reject = rejectHtmlPaste;
rejectHtmlPaste = false;
return reject;
},
clipboardTextParser(text, $context, _plain, view) {
rejectHtmlPaste = false;
const wrapper = document.createElement("div");
const serializer = DOMSerializer.fromSchema(view.state.schema);
for (const block of text.split(/(?:\r\n?|\n)+/)) {
const paragraph = document.createElement("p");
paragraph.setAttribute(whitespaceAttribute, whitespacePreserveValue);
if (block)
paragraph.appendChild(serializer.serializeNode(view.state.schema.text(block, $context.marks())));
wrapper.appendChild(paragraph);
}
return ProseMirrorDOMParser.fromSchema(view.state.schema).parseSlice(wrapper, {
context: $context,
preserveWhitespace: "full",
});
},
},
appendTransaction(transactions, oldState, newState) {
if (transactions.some((transaction) => transaction.getMeta("preventUpdate"))) return null;
const authored = transactions.some(
(transaction) =>
transaction.docChanged &&
!transaction.getMeta("appendedTransaction") &&
!transaction.getMeta("history$") &&
transaction.steps.some((step) => step instanceof ReplaceStep),
);
const structural = transactions.some(
(transaction) =>
!transaction.getMeta("appendedTransaction") &&
!transaction.getMeta("history$") &&
transaction.steps.some((step) => step instanceof ReplaceAroundStep),
);
if (!authored && !structural) return null;
const transform = combineTransactionSteps(oldState.doc, [...transactions]);
const preservedPositions: number[] = [];
oldState.doc.descendants((node, position) => {
if (isPreservableTextBlock(node) && node.attrs.resumeWhitespace === whitespacePreserveValue)
preservedPositions.push(transform.mapping.map(position + 1));
});
const changedRanges = getChangedRanges(transform).map(({ newRange }) => newRange);
if (changedRanges.length === 0) return null;
const transaction = newState.tr;
newState.doc.descendants((node, position) => {
if (!isPreservableTextBlock(node) || node.attrs.resumeWhitespace === whitespacePreserveValue) return;
const nodeEnd = position + node.nodeSize;
if (!authored && !preservedPositions.some((mapped) => mapped > position && mapped < nodeEnd)) return;
if (!changedRanges.some(({ from, to }) => from < nodeEnd && to > position)) return;
transaction.setNodeMarkup(position, undefined, {
...node.attrs,
resumeWhitespace: whitespacePreserveValue,
});
});
return transaction.steps.length > 0 ? transaction : null;
},
}),
];
},
});
@@ -221,17 +221,17 @@ describe("RichInput paragraph indentation (#3397)", () => {
}
});
it("characterizes typed leading whitespace as lost on HTML re-import", async () => {
it("preserves typed leading whitespace on HTML re-import", async () => {
const { editor, onChange } = await input("<p>First</p>");
act(() => {
editor.view.dispatch(editor.state.tr.insertText(" \t", 1));
});
const saved = editor.getHTML();
expect(saved).toBe("<p> \tFirst</p>");
expect(saved).toBe('<p data-resume-whitespace="preserve"> \tFirst</p>');
expect(onChange).toHaveBeenLastCalledWith(saved);
act(() => {
editor.commands.setContent(saved, { emitUpdate: false });
});
expect(editor.getHTML()).toBe("<p>First</p>");
expect(editor.getHTML()).toBe(saved);
});
});
+17 -5
View File
@@ -60,6 +60,13 @@ import { isRTL } from "@/libs/locale";
import { ColorPicker } from "./color-picker";
import { ParagraphIndent } from "./paragraph-indent";
import { defaultHighlightColor, resolveHighlightToolbarState } from "./rich-input.utils";
import {
LiteralHeading,
LiteralParagraph,
LiteralWhitespaceInput,
whitespaceAttribute,
whitespacePreserveValue,
} from "./rich-input-whitespace";
const defaultTextColor = "rgba(0, 0, 0, 1)";
@@ -260,7 +267,10 @@ const cellAttributes = new Map<string, ValueValidator>([
]);
const noValues: ElementRule = {};
const textBlockRule: ElementRule = {
attributes: new Map([["data-indent", supportsCanonicalInteger(1, 8)]]),
attributes: new Map([
["data-indent", supportsCanonicalInteger(1, 8)],
[whitespaceAttribute, (value) => value === whitespacePreserveValue],
]),
styles: textBlockStyles,
validate: validateTextBlock,
};
@@ -483,9 +493,8 @@ const StyledTableCell = TableCell.extend({
const extensions = [
StarterKit.configure({
heading: {
levels: [1, 2, 3, 4, 5, 6],
},
heading: false,
paragraph: false,
codeBlock: false,
link: {
openOnClick: false,
@@ -494,6 +503,9 @@ const extensions = [
protocols: ["http", "https"],
},
}),
LiteralParagraph,
LiteralHeading.configure({ levels: [1, 2, 3, 4, 5, 6] }),
LiteralWhitespaceInput.configure({ hasUnsupportedTableMarkup }),
TextStyle,
Color,
Highlight.configure({ multicolor: true }).extend({
@@ -552,7 +564,7 @@ export function RichInput({
"data-editor": "true",
"data-fullscreen": isFullscreen ? "true" : "false",
class: cn(
"wysiwyg group/editor overflow-y-auto p-3 pb-4",
"wysiwyg group/editor overflow-y-auto p-3 pb-4 [&_[data-resume-whitespace=preserve]]:whitespace-pre-wrap",
"rounded-md rounded-t-none border outline-none focus-visible:border-ring",
"[td:has(.selectedCell)]:bg-primary",
"data-[fullscreen=false]:max-h-[400px] data-[fullscreen=false]:min-h-[100px]",
@@ -0,0 +1,295 @@
// @vitest-environment happy-dom
import type { Editor } from "@tiptap/react";
import { act, render, waitFor } from "@testing-library/react";
import { beforeAll, describe, expect, it, vi } from "vitest";
import { i18n } from "@lingui/core";
import { I18nProvider } from "@lingui/react";
import { PromptDialogProvider } from "@/hooks/use-prompt";
import { RichInput } from "./rich-input";
const preserve = 'data-resume-whitespace="preserve"';
beforeAll(() => i18n.loadAndActivate({ locale: "en", messages: {} }));
function richInput(value: string, onChange: (value: string) => void, onCreate: (editor: Editor) => void) {
return (
<I18nProvider i18n={i18n}>
<PromptDialogProvider>
<RichInput value={value} onChange={onChange} onCreate={({ editor }) => onCreate(editor)} />
</PromptDialogProvider>
</I18nProvider>
);
}
async function input(value: string) {
let editor: Editor | undefined;
const onChange = vi.fn();
const rendered = render(richInput(value, onChange, (created) => (editor = created)));
await waitFor(() => expect(editor).toBeDefined());
if (!editor) throw new Error("Editor did not initialize");
return { editor, onChange, rendered };
}
describe("RichInput literal whitespace (#3397)", () => {
it.each(["p", "h2"])("round-trips exact marked %s whitespace", async (tag) => {
const html = `<${tag} ${preserve}> Lead middle\tend </${tag}>`;
const { editor, onChange } = await input(html);
expect(editor.getHTML()).toBe(html);
expect(editor.getText()).toBe(" Lead middle\tend ");
expect(onChange).not.toHaveBeenCalled();
act(() => editor.commands.setContent(html, { emitUpdate: false }));
expect(editor.getHTML()).toBe(tag === "p" ? html : `${html}<p></p>`);
expect(onChange).not.toHaveBeenCalled();
});
it("keeps marked whitespace through marks, line breaks, undo, redo, and remount", async () => {
const original = `<p ${preserve}><strong> Bold</strong> tail<br> Second\t </p>`;
const { editor, onChange, rendered } = await input(original);
expect(editor.getHTML()).toBe(original);
act(() => editor.view.dispatch(editor.state.tr.insertText("X", 3)));
const edited = editor.getHTML();
expect(edited).toContain(preserve);
expect(editor.getText({ blockSeparator: "\n" })).toBe(" XBold tail\n Second\t ");
expect(editor.getAttributes("textStyle")).toBeDefined();
act(() => editor.commands.undo());
expect(editor.getHTML()).toBe(original);
act(() => editor.commands.redo());
expect(editor.getHTML()).toBe(edited);
expect(onChange).toHaveBeenLastCalledWith(edited);
rendered.unmount();
const remounted = await input(edited);
expect(remounted.editor.getHTML()).toBe(edited);
expect(remounted.onChange).not.toHaveBeenCalled();
});
it("marks unmarked blocks only after text input and preserves the marker in history", async () => {
const { editor, onChange } = await input("<p>First</p>");
act(() => editor.view.dispatch(editor.state.tr.insertText(" \t", 1)));
const saved = `<p ${preserve}> \tFirst</p>`;
expect(editor.getHTML()).toBe(saved);
expect(onChange).toHaveBeenLastCalledWith(saved);
act(() => editor.commands.undo());
expect(editor.getHTML()).toBe("<p>First</p>");
act(() => editor.commands.redo());
expect(editor.getHTML()).toBe(saved);
});
it("marks pasted text without changing paste block or line-break semantics", async () => {
const { editor } = await input("<p>Start</p>");
act(() => {
editor.commands.selectAll();
editor.view.pasteHTML("<p> Pasted\ttext<br> next </p>");
});
expect(editor.getHTML()).toBe(`<p ${preserve}> Pasted\ttext<br> next </p>`);
expect(editor.getText({ blockSeparator: "\n" })).toBe(" Pasted\ttext\n next ");
});
it("preserves exact plain-text paste codepoints across authored blocks", async () => {
const { editor } = await input("<p>Start</p>");
act(() => {
editor.commands.selectAll();
editor.view.pasteText(" Plain\ttext \n\tSecond ");
});
expect(editor.getHTML()).toBe(`<p ${preserve}> Plain\ttext </p><p ${preserve}>\tSecond </p>`);
expect(editor.getText({ blockSeparator: "\n" })).toBe(" Plain\ttext \n\tSecond ");
});
it("marks blocks authored by Enter and keeps Shift+Enter as a line break", async () => {
const { editor } = await input("<p>First</p>");
act(() => {
editor.commands.setTextSelection(3);
editor.commands.enter();
});
expect(editor.getHTML()).toBe(`<p ${preserve}>Fi</p><p ${preserve}>rst</p>`);
act(() => {
editor.commands.setTextSelection(3);
editor.commands.setHardBreak();
});
expect(editor.getHTML()).toBe(`<p ${preserve}>Fi<br></p><p ${preserve}>rst</p>`);
});
it("preserves marked whitespace through paragraph, heading, and list transitions", async () => {
const html = `<p ${preserve}> First\t </p>`;
const { editor } = await input(html);
act(() => editor.commands.toggleHeading({ level: 2 }));
expect(editor.getHTML()).toBe(`<h2 ${preserve}> First\t </h2><p></p>`);
act(() => editor.commands.setParagraph());
expect(editor.getHTML()).toBe(`${html}<p></p>`);
act(() => editor.commands.toggleBulletList());
expect(editor.getHTML()).toBe(`<ul><li><p ${preserve}> First\t </p></li></ul><p></p>`);
act(() => editor.commands.toggleBulletList());
expect(editor.getHTML()).toBe(`${html}<p></p>`);
});
it.each(["bullet", "ordered"])("keeps heading whitespace through %s list conversion and reload", async (list) => {
const { editor } = await input(`<h2 ${preserve}> Literal\t </h2>`);
const toggle = () => (list === "bullet" ? editor.commands.toggleBulletList() : editor.commands.toggleOrderedList());
act(toggle);
const saved = editor.getHTML();
expect(saved).toContain(`<p ${preserve}> Literal\t </p>`);
act(() => editor.commands.setContent(saved, { emitUpdate: false }));
expect(editor.getHTML()).toBe(saved);
act(() => {
editor.commands.setTextSelection(5);
toggle();
editor.commands.toggleHeading({ level: 2 });
});
expect(editor.getHTML()).toContain(`<h2 ${preserve}> Literal\t </h2>`);
});
it("keeps structural markers local to previously marked blocks, including undo and redo", async () => {
const html = `<p ${preserve}> First\t </p><p>Legacy</p>`;
const { editor } = await input(html);
act(() => {
editor.commands.selectAll();
editor.commands.toggleHeading({ level: 2 });
});
const saved = `<h2 ${preserve}> First\t </h2><h2>Legacy</h2><p></p>`;
expect(editor.getHTML()).toBe(saved);
act(() => editor.commands.undo());
expect(editor.getHTML()).toBe(html);
act(() => editor.commands.redo());
expect(editor.getHTML()).toBe(saved);
act(() => editor.commands.setContent(saved, { emitUpdate: false }));
expect(editor.getHTML()).toBe(saved);
});
it("preserves both blocks through multi-block paragraph and heading conversions", async () => {
const { editor } = await input(`<p ${preserve}> First\t </p><p ${preserve}> Second\t </p>`);
act(() => {
editor.commands.selectAll();
editor.commands.toggleHeading({ level: 2 });
});
const headings = `<h2 ${preserve}> First\t </h2><h2 ${preserve}> Second\t </h2><p></p>`;
expect(editor.getHTML()).toBe(headings);
act(() => editor.commands.setContent(headings, { emitUpdate: false }));
expect(editor.getHTML()).toBe(headings);
act(() => {
editor.commands.selectAll();
editor.commands.setParagraph();
});
const saved = editor.getHTML();
expect(saved).toContain(`<p ${preserve}> First\t </p><p ${preserve}> Second\t </p>`);
act(() => editor.commands.setContent(saved, { emitUpdate: false }));
expect(editor.getHTML()).toBe(saved);
});
it.each([
['<p title="a > b"> Text\tX </p>', `<p ${preserve}> Text\tX </p>`],
["<p title='a < b > c'> Text\tX </p>", `<p ${preserve}> Text\tX </p>`],
["<div> Text\tX </div>", `<p ${preserve}> Text\tX </p>`],
['<meta charset="utf-8"><div> Text\tX </div>', `<p ${preserve}> Text\tX </p>`],
["<!--StartFragment--><div> Text\tX </div><!--EndFragment-->", `<p ${preserve}> Text\tX </p>`],
[
"<div><div> First\t </div><div> Second\t </div></div>",
`<p ${preserve}> First\t </p><p ${preserve}> Second\t </p>`,
],
["<ul><li> Text\tX </li></ul>", `<ul><li><p ${preserve}> Text\tX </p></li></ul><p></p>`],
["<ol><li> Text\tX </li></ol>", `<ol><li><p ${preserve}> Text\tX </p></li></ol><p></p>`],
["<blockquote> Text\tX </blockquote>", `<blockquote><p ${preserve}> Text\tX </p></blockquote><p></p>`],
["<div> First<br> Second\t </div>", `<p ${preserve}> First<br> Second\t </p>`],
])("preserves supported clipboard text and baseline blocks: %s", async (html, expected) => {
const { editor } = await input("<p>Start</p>");
act(() => {
editor.commands.selectAll();
editor.view.pasteHTML(html);
});
expect(editor.getHTML()).toBe(expected);
act(() => editor.commands.setContent(expected, { emitUpdate: false }));
expect(editor.getHTML()).toBe(expected);
});
it("preserves whitespace in pasted supported table paragraphs", async () => {
const { editor } = await input("<p>Start</p>");
act(() => {
editor.commands.selectAll();
editor.view.pasteHTML("<table><tbody><tr><td><p> Cell\ttext </p></td></tr></tbody></table>");
});
expect(editor.getHTML()).toContain(`<p ${preserve}> Cell\ttext </p>`);
});
it.each(["td", "th"])("preserves whitespace in pasted bare %s table cells", async (cellTag) => {
const { editor } = await input("<p>Start</p>");
act(() => {
editor.commands.selectAll();
editor.view.pasteHTML(`<table><tbody><tr><${cellTag}> Cell\ttext </${cellTag}></tr></tbody></table>`);
});
expect(editor.getHTML()).toContain(`<p ${preserve}> Cell\ttext </p>`);
expect(editor.getText()).toContain(" Cell\ttext ");
});
it.each([
"<table><tr><td><p> Cell\t </p></td></tr></table>",
'<table border="1"><tbody><tr><td><p> Cell\t </p></td></tr></tbody></table>',
])("rejects unsafe table paste before DOM normalization: %s", async (html) => {
const { editor, onChange } = await input("<p>Start</p>");
act(() => {
editor.commands.selectAll();
editor.view.pasteHTML(html);
});
expect(editor.getHTML()).toBe("<p>Start</p>");
expect(onChange).not.toHaveBeenCalled();
});
it("keeps marked paragraphs editable inside supported table cells", async () => {
const html = `<table><tbody><tr><td colspan="1" rowspan="1"><p ${preserve}> Cell\ttext </p></td></tr></tbody></table><p></p>`;
const { editor, onChange } = await input(html);
expect(editor.isEditable).toBe(true);
expect(editor.getHTML()).toBe(html);
let cellTextPosition: number | undefined;
editor.state.doc.descendants((node, position) => {
if (cellTextPosition === undefined && node.isText && node.text?.includes("Cell")) cellTextPosition = position;
});
const insertionPosition = cellTextPosition;
if (insertionPosition === undefined) throw new Error("Expected table-cell text position");
act(() => editor.view.dispatch(editor.state.tr.insertText("X", insertionPosition + 2)));
expect(editor.view.dom.querySelector("td")?.textContent).toBe(" XCell\ttext ");
expect(editor.getHTML()).toContain(preserve);
expect(onChange).toHaveBeenCalledTimes(1);
});
it("does not mark or save mount, unmarked legacy import, or controlled prop updates", async () => {
let editor: Editor | undefined;
const onChange = vi.fn();
const rendered = render(richInput("<p> Legacy\ttext</p>", onChange, (created) => (editor = created)));
await waitFor(() => expect(editor).toBeDefined());
if (!editor) throw new Error("Editor did not initialize");
expect(editor.getHTML()).toBe("<p>Legacy text</p>");
expect(onChange).not.toHaveBeenCalled();
rendered.rerender(richInput("\n <p> Updated\tlegacy</p>\n", onChange, () => {}));
await waitFor(() => expect(editor?.getHTML()).toBe("<p>Updated legacy</p>"));
expect(editor.getHTML()).not.toContain("data-resume-whitespace");
expect(onChange).not.toHaveBeenCalled();
});
it.each(["en", "he", "ar"])("preserves Unicode and marked codepoints in %s editor direction", async (locale) => {
act(() => i18n.loadAndActivate({ locale, messages: {} }));
try {
const html = `<p ${preserve}>\u3000中\u00a0文 \t </p>`;
const { editor } = await input(html);
expect(editor.getHTML()).toBe(
locale === "en"
? `<p ${preserve}>\u3000中&nbsp;文 \t </p>`
: `<p dir="rtl" ${preserve}>\u3000中&nbsp;文 \t </p>`,
);
expect(editor.getText()).toBe("\u3000中\u00a0文 \t ");
} finally {
act(() => i18n.loadAndActivate({ locale: "en", messages: {} }));
}
});
});