mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 10:13:47 +10:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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中 文 \t </p>`
|
||||
: `<p dir="rtl" ${preserve}>\u3000中 文 \t </p>`,
|
||||
);
|
||||
expect(editor.getText()).toBe("\u3000中\u00a0文 \t ");
|
||||
} finally {
|
||||
act(() => i18n.loadAndActivate({ locale: "en", messages: {} }));
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user