mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-04 02:33:47 +10:00
refactor(web): write private notes with the standard rich text editor
RichInput, the full-toolbar editor only notes used, is gone with its tests; the editor extensions it defined move to rich-text-extensions.ts, which the rich text editor already used.
This commit is contained in:
@@ -1,237 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { act, fireEvent, render, screen, 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";
|
||||
|
||||
beforeAll(() => i18n.loadAndActivate({ locale: "en", messages: {} }));
|
||||
|
||||
async function input(value: string) {
|
||||
let editor: Editor | undefined;
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<I18nProvider i18n={i18n}>
|
||||
<PromptDialogProvider>
|
||||
<RichInput
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onCreate={(event) => {
|
||||
editor = event.editor;
|
||||
}}
|
||||
/>
|
||||
</PromptDialogProvider>
|
||||
</I18nProvider>,
|
||||
);
|
||||
await waitFor(() => expect(editor).toBeDefined());
|
||||
if (!editor) throw new Error("Editor did not initialize");
|
||||
return { editor, onChange };
|
||||
}
|
||||
|
||||
const increase = () => fireEvent.click(screen.getByTitle("Increase indent"));
|
||||
const decrease = () => fireEvent.click(screen.getByTitle("Decrease indent"));
|
||||
const levels = (editor: Editor) => editor.getJSON().content?.map((node) => node.attrs?.indent ?? 0);
|
||||
|
||||
describe("RichInput paragraph indentation (#3397)", () => {
|
||||
it("indents the entire paragraph and emits round-trippable HTML", async () => {
|
||||
const { editor, onChange } = await input("<p>First</p>");
|
||||
expect(screen.getByTitle("Decrease indent")).toBeDisabled();
|
||||
expect(screen.getByTitle("Increase indent")).toBeEnabled();
|
||||
increase();
|
||||
const html = '<p data-indent="1" style="margin-inline-start: 24px;">First</p>';
|
||||
expect(editor.getHTML()).toBe(html);
|
||||
expect(onChange).toHaveBeenLastCalledWith(html);
|
||||
act(() => {
|
||||
editor.commands.setContent(html, { emitUpdate: false });
|
||||
});
|
||||
expect(editor.getHTML()).toBe(html);
|
||||
decrease();
|
||||
expect(editor.getHTML()).toBe("<p>First</p>");
|
||||
});
|
||||
|
||||
it.each(["p", "h1", "h2", "h3", "h4", "h5", "h6"])("restores saved %s indentation on initialization", async (tag) => {
|
||||
const html = `<${tag} data-indent="2" style="margin-inline-start: 48px;">First</${tag}><p>Last</p>`;
|
||||
const { editor } = await input(html);
|
||||
expect(editor.getHTML()).toBe(html);
|
||||
});
|
||||
|
||||
it("updates mixed levels in one transaction and supports undo/redo", async () => {
|
||||
const { editor, onChange } = await input('<p>First</p><h2 data-indent="3">Second</h2><p data-indent="8">Third</p>');
|
||||
act(() => {
|
||||
editor.commands.selectAll();
|
||||
});
|
||||
increase();
|
||||
expect(levels(editor)).toEqual([1, 4, 8]);
|
||||
expect(onChange).toHaveBeenCalledTimes(1);
|
||||
act(() => {
|
||||
editor.commands.undo();
|
||||
});
|
||||
expect(levels(editor)).toEqual([0, 3, 8]);
|
||||
act(() => {
|
||||
editor.commands.redo();
|
||||
});
|
||||
expect(levels(editor)).toEqual([1, 4, 8]);
|
||||
decrease();
|
||||
expect(levels(editor)).toEqual([0, 3, 7]);
|
||||
});
|
||||
|
||||
it("preserves each level through paragraph/heading conversions", async () => {
|
||||
const { editor } = await input('<p data-indent="1">First</p><p data-indent="3">Second</p>');
|
||||
act(() => {
|
||||
editor.commands.selectAll();
|
||||
editor.commands.toggleHeading({ level: 2 });
|
||||
});
|
||||
expect(levels(editor).slice(0, 2)).toEqual([1, 3]);
|
||||
act(() => {
|
||||
editor.commands.setParagraph();
|
||||
});
|
||||
expect(levels(editor).slice(0, 2)).toEqual([1, 3]);
|
||||
});
|
||||
|
||||
it("indents the selected paragraph inside a blockquote", async () => {
|
||||
const { editor } = await input("<blockquote><p>First</p><p>Second</p></blockquote>");
|
||||
act(() => {
|
||||
editor.commands.setTextSelection(2);
|
||||
});
|
||||
increase();
|
||||
expect(editor.getHTML()).toContain(
|
||||
'<blockquote><p data-indent="1" style="margin-inline-start: 24px;">First</p><p>Second</p></blockquote>',
|
||||
);
|
||||
});
|
||||
|
||||
it("disables indent at eight without an update", async () => {
|
||||
const { editor, onChange } = await input('<p data-indent="8">First</p>');
|
||||
expect(screen.getByTitle("Increase indent")).toBeDisabled();
|
||||
increase();
|
||||
expect(levels(editor)).toEqual([8]);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["ul", "ol"])("keeps %s controls as list nesting operations", async (tag) => {
|
||||
const { editor } = await input(`<${tag}><li><p>First</p></li><li><p>Second</p></li></${tag}><p>Last</p>`);
|
||||
act(() => {
|
||||
editor.commands.setTextSelection(12);
|
||||
});
|
||||
increase();
|
||||
expect(editor.getHTML()).toContain(`<${tag}><li><p>Second</p></li></${tag}>`);
|
||||
expect(editor.getHTML()).not.toContain("data-indent");
|
||||
decrease();
|
||||
expect(editor.getHTML()).toBe(`<${tag}><li><p>First</p></li><li><p>Second</p></li></${tag}><p>Last</p>`);
|
||||
});
|
||||
|
||||
it.each(["toggleBulletList", "toggleOrderedList"] as const)(
|
||||
"normalizes indentation when converting to a list with %s",
|
||||
async (command) => {
|
||||
const { editor } = await input('<p data-indent="2">First</p>');
|
||||
act(() => {
|
||||
if (command === "toggleBulletList") editor.commands.toggleBulletList();
|
||||
else editor.commands.toggleOrderedList();
|
||||
});
|
||||
expect(editor.getHTML()).not.toContain("data-indent");
|
||||
act(() => {
|
||||
editor.commands.undo();
|
||||
});
|
||||
expect(levels(editor)[0]).toBe(2);
|
||||
act(() => {
|
||||
editor.commands.redo();
|
||||
if (command === "toggleBulletList") editor.commands.toggleBulletList();
|
||||
else editor.commands.toggleOrderedList();
|
||||
});
|
||||
expect(editor.getHTML()).not.toContain("data-indent");
|
||||
expect(editor.getHTML()).not.toContain("<li>");
|
||||
},
|
||||
);
|
||||
|
||||
it("clears paragraph offsets when typing the bullet-list shortcut", async () => {
|
||||
const { editor } = await input('<p data-indent="2">First</p>');
|
||||
act(() => {
|
||||
editor.commands.setTextSelection(1);
|
||||
editor.view.someProp("handleTextInput", (handle) =>
|
||||
handle(editor.view, 1, 1, "- ", () => editor.state.tr.insertText("- ", 1)),
|
||||
);
|
||||
});
|
||||
expect(editor.getHTML()).toContain("<ul><li><p>First</p></li></ul>");
|
||||
expect(editor.getHTML()).not.toContain("data-indent");
|
||||
});
|
||||
|
||||
it("clears imported paragraph offsets inside lists", async () => {
|
||||
const { editor } = await input('<ul><li><p data-indent="2" style="margin-inline-start: 48px">First</p></li></ul>');
|
||||
expect(editor.getHTML()).not.toContain("data-indent");
|
||||
expect(editor.getHTML()).not.toContain("margin-inline-start");
|
||||
});
|
||||
|
||||
it("honors explicit indentation when changing a block type", async () => {
|
||||
const { editor } = await input('<p data-indent="2">First</p>');
|
||||
act(() => {
|
||||
editor.commands.setNode("heading", { level: 2, indent: 0 });
|
||||
});
|
||||
expect(editor.getHTML()).toContain("<h2>First</h2>");
|
||||
});
|
||||
|
||||
it("characterizes literal leading spaces and tabs in saved HTML", async () => {
|
||||
const { editor } = await input("<p> First</p><p>\tSecond</p>");
|
||||
expect(editor.getHTML()).toBe("<p>First</p><p>Second</p>");
|
||||
});
|
||||
|
||||
it("does not mutate during capability checks or beyond either bound", async () => {
|
||||
const { editor, onChange } = await input("<p>First</p>");
|
||||
expect(editor.can().increaseIndent()).toBe(true);
|
||||
expect(editor.can().decreaseIndent()).toBe(false);
|
||||
act(() => {
|
||||
expect(editor.commands.decreaseIndent()).toBe(false);
|
||||
});
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
act(() => {
|
||||
for (let step = 0; step < 8; step++) editor.commands.increaseIndent();
|
||||
});
|
||||
expect(levels(editor)).toEqual([8]);
|
||||
expect(editor.can().increaseIndent()).toBe(false);
|
||||
act(() => {
|
||||
expect(editor.commands.increaseIndent()).toBe(false);
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledTimes(8);
|
||||
});
|
||||
|
||||
it("skips list descendants when a selection starts outside the list", async () => {
|
||||
const { editor } = await input("<p>First</p><ul><li><p>Second</p></li></ul><p>Third</p>");
|
||||
act(() => {
|
||||
editor.commands.selectAll();
|
||||
});
|
||||
increase();
|
||||
expect(editor.getHTML()).toBe(
|
||||
'<p data-indent="1" style="margin-inline-start: 24px;">First</p><ul><li><p>Second</p></li></ul><p data-indent="1" style="margin-inline-start: 24px;">Third</p>',
|
||||
);
|
||||
});
|
||||
|
||||
it("renders logical margins in RTL without physical left/right margins", async () => {
|
||||
i18n.loadAndActivate({ locale: "he", messages: {} });
|
||||
try {
|
||||
const { editor } = await input('<p data-indent="1">First</p>');
|
||||
const paragraph = editor.view.dom.querySelector("p");
|
||||
expect(paragraph?.style.marginInlineStart).toBe("24px");
|
||||
expect(paragraph?.style.marginLeft).toBe("");
|
||||
expect(paragraph?.style.marginRight).toBe("");
|
||||
} finally {
|
||||
act(() => {
|
||||
i18n.loadAndActivate({ locale: "en", messages: {} });
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
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 data-resume-whitespace="preserve"> \tFirst</p>');
|
||||
expect(onChange).toHaveBeenLastCalledWith(saved);
|
||||
act(() => {
|
||||
editor.commands.setContent(saved, { emitUpdate: false });
|
||||
});
|
||||
expect(editor.getHTML()).toBe(saved);
|
||||
});
|
||||
});
|
||||
@@ -1,346 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import type { Editor, JSONContent } from "@tiptap/react";
|
||||
import { act, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { i18n } from "@lingui/core";
|
||||
import { I18nProvider } from "@lingui/react";
|
||||
import { useState } from "react";
|
||||
import { PromptDialogProvider } from "@/hooks/use-prompt";
|
||||
import { RichInput } from "./rich-input";
|
||||
|
||||
beforeAll(() => i18n.loadAndActivate({ locale: "en", messages: {} }));
|
||||
|
||||
const inlineTable = `<table style="width: 300pt; border-collapse: collapse"><tbody><tr><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Alpha</td><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Beta</td><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Gamma</td></tr><tr><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Delta</td><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Epsilon</td><td style="width: 100pt; padding: 4pt; border: 1pt solid #cc00cc">Zeta</td></tr></tbody></table>`;
|
||||
|
||||
const complexTable = `<table style="width: 300pt; border-collapse: collapse"><tbody><tr><th colspan="2" rowspan="2" style="width: 200pt; border: 2pt dashed #123456; padding: 3pt"><p><strong>Lead</strong> cell</p><p><em>Second</em> paragraph</p></th><th style="width: 100pt">Side</th></tr><tr><td style="border-left: 1pt solid rgb(1, 2, 3)"><p>Tail</p></td></tr></tbody></table>`;
|
||||
|
||||
const semanticTable = `<table style="width: 300pt; border-spacing: 0"><tbody><tr style="height: 24pt"><td colspan="2" rowspan="1" colwidth="100,200" align="right" style="padding: 4pt; text-align: right"><p data-indent="2" style="margin-inline-start: 48px; text-align: justify"><a href="https://example.com" target="_blank" rel="noopener noreferrer nofollow" class="link"><span style="color: #123456">Inside</span></a> <mark data-color="#ffff00" style="background-color: #ffff00; color: inherit">Mark</mark> <mark data-color="#000000" style="background-color: #000000; color: inherit; color: #ffffff">Dark</mark></p></td><td><ol start="2"><li style="text-align: center">Two</li></ol></td></tr></tbody></table>`;
|
||||
|
||||
const unsupportedTable = `<table border="1" style="width: 300pt"><tbody><tr><td>Original</td></tr></tbody></table>`;
|
||||
|
||||
const unsupportedTables = [
|
||||
["legacy table attributes", unsupportedTable],
|
||||
["truncated table marker", "<table"],
|
||||
["truncated table opening tag", '<table style="width: 100pt"'],
|
||||
["truncated table closing tag", "</table"],
|
||||
["unclosed table", "<table><tbody><tr><td>Open"],
|
||||
["truncated table after a complete table", "<table><tbody><tr><td>Complete</td></tr></tbody></table><table"],
|
||||
[
|
||||
"unrepresented descendant elements and attributes",
|
||||
`<table><tbody><tr><td><section aria-label="keep">Inside</section></td></tr></tbody></table>`,
|
||||
],
|
||||
[
|
||||
"multiple table bodies",
|
||||
"<table><tbody><tr><td>First</td></tr></tbody><tbody><tr><td>Second</td></tr></tbody></table>",
|
||||
],
|
||||
["empty table body", "<table><tbody></tbody></table>"],
|
||||
["non-row table body child", "<table><tbody><script></script><tr><td>Inside</td></tr></tbody></table>"],
|
||||
["non-cell table row child", "<table><tbody><tr><script></script><td>Inside</td></tr></tbody></table>"],
|
||||
["mismatched row widths", "<table><tbody><tr><td>One</td></tr><tr><td>Two</td><td>Three</td></tr></tbody></table>"],
|
||||
[
|
||||
"non-rectangular column span",
|
||||
'<table><tbody><tr><td colspan="2">Wide</td></tr><tr><td colspan="3">Wider</td></tr></tbody></table>',
|
||||
],
|
||||
["row span beyond table bounds", '<table><tbody><tr><td rowspan="2">Inside</td></tr></tbody></table>'],
|
||||
[
|
||||
"row span collision",
|
||||
'<table><tbody><tr><td rowspan="2">Tall</td><td>Side</td></tr><tr><td colspan="2">Overlap</td></tr></tbody></table>',
|
||||
],
|
||||
["browser-repaired malformed markup", "<table><tbody><tr><td>Broken</tr></tbody></table>"],
|
||||
[
|
||||
"unrepresented attributes on supported descendants",
|
||||
`<table><tbody><tr><td><p data-keep="yes">Inside</p></td></tr></tbody></table>`,
|
||||
],
|
||||
["invalid table styles", '<table style="width: banana"><tbody><tr><td>Inside</td></tr></tbody></table>'],
|
||||
["invalid row styles", '<table><tbody><tr style="height: banana"><td>Inside</td></tr></tbody></table>'],
|
||||
["invalid cell alignment", '<table><tbody><tr><td align="justify">Inside</td></tr></tbody></table>'],
|
||||
[
|
||||
"invalid cell style alignment",
|
||||
'<table><tbody><tr><td style="text-align: justify">Inside</td></tr></tbody></table>',
|
||||
],
|
||||
["invalid column widths", '<table><tbody><tr><td colwidth="100,bogus">Inside</td></tr></tbody></table>'],
|
||||
["mismatched column widths", '<table><tbody><tr><td colspan="2" colwidth="100">Inside</td></tr></tbody></table>'],
|
||||
["invalid column span", '<table><tbody><tr><td colspan="0">Inside</td></tr></tbody></table>'],
|
||||
["invalid row span", '<table><tbody><tr><td rowspan="-1">Inside</td></tr></tbody></table>'],
|
||||
[
|
||||
"invalid paragraph alignment",
|
||||
'<table><tbody><tr><td><p style="text-align: sideways">Inside</p></td></tr></tbody></table>',
|
||||
],
|
||||
["invalid indent level", '<table><tbody><tr><td><p data-indent="9">Inside</p></td></tr></tbody></table>'],
|
||||
[
|
||||
"mismatched indent margin",
|
||||
'<table><tbody><tr><td><p data-indent="2" style="margin-inline-start: 12px">Inside</p></td></tr></tbody></table>',
|
||||
],
|
||||
[
|
||||
"physical indent margin",
|
||||
'<table><tbody><tr><td><p data-indent="2" style="margin-left: 48px">Inside</p></td></tr></tbody></table>',
|
||||
],
|
||||
[
|
||||
"invalid ordered-list start",
|
||||
'<table><tbody><tr><td><ol start="first"><li>Inside</li></ol></td></tr></tbody></table>',
|
||||
],
|
||||
["empty blockquote", "<table><tbody><tr><td><blockquote></blockquote></td></tr></tbody></table>"],
|
||||
["whitespace-only blockquote", "<table><tbody><tr><td><blockquote> </blockquote></td></tr></tbody></table>"],
|
||||
["empty list", "<table><tbody><tr><td><ul></ul></td></tr></tbody></table>"],
|
||||
[
|
||||
"anchor without href",
|
||||
'<table><tbody><tr><td><p><a target="_blank" rel="noopener" class="link">Inside</a></p></td></tr></tbody></table>',
|
||||
],
|
||||
[
|
||||
"unsafe link URI",
|
||||
'<table><tbody><tr><td><p><a href="javascript:alert(1)">Inside</a></p></td></tr></tbody></table>',
|
||||
],
|
||||
[
|
||||
"unsafe link URI with ignored whitespace",
|
||||
'<table><tbody><tr><td><p><a href="java\u200Bscript:alert(1)">Inside</a></p></td></tr></tbody></table>',
|
||||
],
|
||||
[
|
||||
"invalid text color",
|
||||
'<table><tbody><tr><td><p><span style="color: nope">Inside</span></p></td></tr></tbody></table>',
|
||||
],
|
||||
[
|
||||
"invalid highlight color",
|
||||
'<table><tbody><tr><td><p><mark data-color="nope" style="background-color: nope; color: inherit">Inside</mark></p></td></tr></tbody></table>',
|
||||
],
|
||||
[
|
||||
"mismatched highlight color",
|
||||
'<table><tbody><tr><td><p><mark data-color="#ffff00" style="background-color: #000000; color: inherit">Inside</mark></p></td></tr></tbody></table>',
|
||||
],
|
||||
] as const;
|
||||
|
||||
type InputOptions = {
|
||||
className?: string;
|
||||
editable?: boolean;
|
||||
};
|
||||
|
||||
async function input(value: string, options: InputOptions = {}) {
|
||||
let editor: Editor | undefined;
|
||||
const onChange = vi.fn();
|
||||
const renderInput = (nextValue: string, nextOptions: InputOptions = options) => (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<PromptDialogProvider>
|
||||
<RichInput
|
||||
aria-label="Table editor"
|
||||
value={nextValue}
|
||||
onChange={onChange}
|
||||
className={nextOptions.className}
|
||||
editable={nextOptions.editable}
|
||||
onCreate={(event) => {
|
||||
editor = event.editor;
|
||||
}}
|
||||
/>
|
||||
</PromptDialogProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
const result = render(renderInput(value));
|
||||
await waitFor(() => expect(editor).toBeDefined());
|
||||
if (!editor) throw new Error("Editor did not initialize");
|
||||
return {
|
||||
editor,
|
||||
onChange,
|
||||
rerender: (nextValue: string, nextOptions?: InputOptions) => result.rerender(renderInput(nextValue, nextOptions)),
|
||||
};
|
||||
}
|
||||
|
||||
async function controlledInput(initialValue: string) {
|
||||
let editor: Editor | undefined;
|
||||
const onChange = vi.fn();
|
||||
|
||||
function Harness() {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
const [editable, setEditable] = useState(true);
|
||||
const [className, setClassName] = useState("initial");
|
||||
|
||||
return (
|
||||
<I18nProvider i18n={i18n}>
|
||||
<PromptDialogProvider>
|
||||
<button type="button" onClick={() => setEditable((current) => !current)}>
|
||||
Toggle lock
|
||||
</button>
|
||||
<button type="button" onClick={() => setClassName("updated")}>
|
||||
Update prop
|
||||
</button>
|
||||
<output data-testid="stored-value">{value}</output>
|
||||
<RichInput
|
||||
aria-label="Table editor"
|
||||
value={value}
|
||||
onChange={(nextValue) => {
|
||||
onChange(nextValue);
|
||||
setValue(nextValue);
|
||||
}}
|
||||
className={className}
|
||||
editable={editable}
|
||||
onCreate={(event) => {
|
||||
editor = event.editor;
|
||||
}}
|
||||
/>
|
||||
</PromptDialogProvider>
|
||||
</I18nProvider>
|
||||
);
|
||||
}
|
||||
|
||||
render(<Harness />);
|
||||
await waitFor(() => expect(editor).toBeDefined());
|
||||
if (!editor) throw new Error("Editor did not initialize");
|
||||
return { editor, onChange };
|
||||
}
|
||||
|
||||
const jsonText = (node: JSONContent): string => {
|
||||
if ("text" in node) return node.text ?? "";
|
||||
return node.content?.map(jsonText).join("") ?? "";
|
||||
};
|
||||
|
||||
const tableMatrix = (editor: Editor): string[][] => {
|
||||
const table = editor.getJSON().content?.find((node) => node.type === "table");
|
||||
if (!table || !("content" in table)) return [];
|
||||
return table.content?.map((row) => ("content" in row ? (row.content?.map(jsonText) ?? []) : [])) ?? [];
|
||||
};
|
||||
|
||||
const textPosition = (editor: Editor, text: string): number => {
|
||||
let position: number | undefined;
|
||||
editor.state.doc.descendants((node, nodePosition) => {
|
||||
const offset = node.text?.indexOf(text) ?? -1;
|
||||
if (position === undefined && offset >= 0) position = nodePosition + offset;
|
||||
});
|
||||
if (position === undefined) throw new Error(`Missing text: ${text}`);
|
||||
return position;
|
||||
};
|
||||
|
||||
describe("RichInput imported tables (#3196)", () => {
|
||||
it("parses a supported 2x3 table as structured JSON without emitting on mount", async () => {
|
||||
const { editor, onChange } = await input(inlineTable);
|
||||
|
||||
expect(editor.getJSON().content?.[0]?.type).toBe("table");
|
||||
expect(tableMatrix(editor)).toEqual([
|
||||
["Alpha", "Beta", "Gamma"],
|
||||
["Delta", "Epsilon", "Zeta"],
|
||||
]);
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("edits one named cell and retains structure through undo, redo, and remount", async () => {
|
||||
const { editor, onChange } = await input(inlineTable);
|
||||
act(() => {
|
||||
editor.commands.setTextSelection(textPosition(editor, "Beta") + "Beta".length);
|
||||
editor.commands.insertContent("!");
|
||||
});
|
||||
expect(tableMatrix(editor)).toEqual([
|
||||
["Alpha", "Beta!", "Gamma"],
|
||||
["Delta", "Epsilon", "Zeta"],
|
||||
]);
|
||||
const edited = editor.getHTML();
|
||||
expect(onChange).toHaveBeenLastCalledWith(edited);
|
||||
|
||||
act(() => {
|
||||
editor.commands.undo();
|
||||
});
|
||||
expect(tableMatrix(editor)[0]).toEqual(["Alpha", "Beta", "Gamma"]);
|
||||
act(() => {
|
||||
editor.commands.redo();
|
||||
editor.commands.setContent(edited, { emitUpdate: false });
|
||||
});
|
||||
expect(tableMatrix(editor)).toEqual([
|
||||
["Alpha", "Beta!", "Gamma"],
|
||||
["Delta", "Epsilon", "Zeta"],
|
||||
]);
|
||||
expect(editor.getHTML()).toBe(edited);
|
||||
});
|
||||
|
||||
it("retains spans, multiple paragraphs, inline marks, styles, and unrelated prop updates", async () => {
|
||||
const { editor, onChange, rerender } = await input(complexTable);
|
||||
const table = editor.getJSON().content?.[0];
|
||||
const firstRow = table && "content" in table ? table.content?.[0] : undefined;
|
||||
const lead = firstRow && "content" in firstRow ? firstRow.content?.[0] : undefined;
|
||||
expect(lead).toMatchObject({
|
||||
type: "tableHeader",
|
||||
attrs: {
|
||||
colspan: 2,
|
||||
rowspan: 2,
|
||||
style: "width: 200pt; border: 2pt dashed #123456; padding: 3pt",
|
||||
},
|
||||
});
|
||||
expect(lead && "content" in lead ? lead.content : undefined).toHaveLength(2);
|
||||
if (!lead || !("content" in lead)) throw new Error("Missing lead table cell content");
|
||||
const firstParagraph = lead.content?.[0];
|
||||
const secondParagraph = lead.content?.[1];
|
||||
expect(
|
||||
firstParagraph && "content" in firstParagraph ? firstParagraph.content?.[0]?.marks?.[0]?.type : undefined,
|
||||
).toBe("bold");
|
||||
expect(
|
||||
secondParagraph && "content" in secondParagraph ? secondParagraph.content?.[0]?.marks?.[0]?.type : undefined,
|
||||
).toBe("italic");
|
||||
|
||||
rerender(complexTable, { className: "unrelated", editable: true });
|
||||
await waitFor(() => expect(editor.getJSON().content?.[0]?.type).toBe("table"));
|
||||
expect(editor.getHTML()).toContain('colspan="2"');
|
||||
expect(editor.getHTML()).toContain('rowspan="2"');
|
||||
expect(editor.getHTML()).toContain("border: 2pt dashed #123456");
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("parses pasted supported table HTML into editable table nodes", async () => {
|
||||
const { editor } = await input("<p>Before</p>");
|
||||
act(() => {
|
||||
editor.commands.selectAll();
|
||||
editor.view.pasteHTML(inlineTable);
|
||||
});
|
||||
expect(tableMatrix(editor)).toEqual([
|
||||
["Alpha", "Beta", "Gamma"],
|
||||
["Delta", "Epsilon", "Zeta"],
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps every supported table semantic editable", async () => {
|
||||
const { editor, onChange } = await input(semanticTable);
|
||||
expect(editor.isEditable).toBe(true);
|
||||
expect(screen.queryByText(/Original table formatting is preserved/)).not.toBeInTheDocument();
|
||||
act(() => {
|
||||
editor.commands.setTextSelection(textPosition(editor, "Inside") + "Inside".length);
|
||||
editor.commands.insertContent("!");
|
||||
});
|
||||
expect(editor.getHTML()).toContain("Inside!");
|
||||
expect(onChange).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("edits a configured anchor while preserving its title", async () => {
|
||||
const titledLink =
|
||||
'<table><tbody><tr><td><p><a href="https://example.com" title="Profile">Inside</a></p></td></tr></tbody></table>';
|
||||
const { editor, onChange } = await input(titledLink);
|
||||
|
||||
expect(editor.isEditable).toBe(true);
|
||||
act(() => {
|
||||
editor.commands.setTextSelection(textPosition(editor, "Inside") + "Inside".length);
|
||||
editor.commands.insertContent("!");
|
||||
});
|
||||
expect(editor.getHTML()).toContain('href="https://example.com"');
|
||||
expect(editor.getHTML()).toContain('title="Profile"');
|
||||
expect(onChange).toHaveBeenLastCalledWith(editor.getHTML());
|
||||
});
|
||||
|
||||
it.each(unsupportedTables)("preserves exact bytes for %s behind an accessible read-only notice", async (_, value) => {
|
||||
const user = userEvent.setup();
|
||||
const { editor, onChange } = await controlledInput(value);
|
||||
const notice = screen.getByText(/Original table formatting is preserved/).closest('[role="status"]');
|
||||
if (!notice) throw new Error("Missing unsupported-table status notice");
|
||||
expect(notice).toHaveTextContent("Original table formatting is preserved");
|
||||
expect(screen.queryByRole("button", { name: "Convert to editable text" })).not.toBeInTheDocument();
|
||||
expect(editor.isEditable).toBe(false);
|
||||
expect(screen.getByTestId("stored-value")).toHaveTextContent(value, { normalizeWhitespace: false });
|
||||
|
||||
await user.click(editor.view.dom);
|
||||
await user.keyboard("Changed");
|
||||
await user.click(screen.getByRole("button", { name: "Update prop" }));
|
||||
await user.click(screen.getByRole("button", { name: "Toggle lock" }));
|
||||
await user.click(screen.getByRole("button", { name: "Toggle lock" }));
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
expect(screen.getByTestId("stored-value")).toHaveTextContent(value, { normalizeWhitespace: false });
|
||||
});
|
||||
|
||||
it("keeps unsupported content read-only without a conversion path when caller marks editor as locked", async () => {
|
||||
const { editor } = await input(unsupportedTable, { editable: false });
|
||||
expect(editor.isEditable).toBe(false);
|
||||
expect(screen.queryByRole("button", { name: "Convert to editable text" })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { resolveHighlightToolbarState } from "./rich-input.utils";
|
||||
|
||||
describe("Tiptap editing", () => {
|
||||
it("creates a new paragraph when Enter is pressed", () => {
|
||||
const editor = new Editor({ extensions: [StarterKit], content: "<p>First</p>" });
|
||||
|
||||
editor.commands.setTextSelection(editor.state.doc.content.size - 1);
|
||||
editor.commands.enter();
|
||||
|
||||
expect(editor.getHTML()).toBe("<p>First</p><p></p>");
|
||||
editor.destroy();
|
||||
});
|
||||
|
||||
it("creates and splits list items", () => {
|
||||
const editor = new Editor({ extensions: [StarterKit], content: "<p>First</p>" });
|
||||
|
||||
editor.commands.setTextSelection(editor.state.doc.content.size - 1);
|
||||
editor.commands.toggleBulletList();
|
||||
editor.commands.enter();
|
||||
|
||||
expect(editor.getHTML()).toBe("<ul><li><p>First</p></li><li><p></p></li></ul><p></p>");
|
||||
editor.destroy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveHighlightToolbarState", () => {
|
||||
it("shows legacy colorless highlights as default yellow and clearable", () => {
|
||||
expect(resolveHighlightToolbarState(true, null)).toEqual({
|
||||
visibleHighlightColor: "rgba(255, 255, 0, 1)",
|
||||
canClearHighlight: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not show or clear a highlight when no highlight mark is active", () => {
|
||||
expect(resolveHighlightToolbarState(false, null)).toEqual({
|
||||
visibleHighlightColor: undefined,
|
||||
canClearHighlight: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
||||
export const defaultHighlightColor = "rgba(255, 255, 0, 1)";
|
||||
|
||||
export function resolveHighlightToolbarState(isHighlight: boolean, highlightColor: string | null) {
|
||||
const visibleHighlightColor = highlightColor ?? (isHighlight ? defaultHighlightColor : undefined);
|
||||
|
||||
return { visibleHighlightColor, canClearHighlight: isHighlight };
|
||||
}
|
||||
@@ -1,295 +0,0 @@
|
||||
// @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: {} }));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,470 @@
|
||||
import Color from "@tiptap/extension-color";
|
||||
import Highlight from "@tiptap/extension-highlight";
|
||||
import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table";
|
||||
import TextAlign from "@tiptap/extension-text-align";
|
||||
import { TextStyle } from "@tiptap/extension-text-style";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import { isDarkColor } from "@reactive-resume/utils/color";
|
||||
import { ParagraphIndent } from "./paragraph-indent";
|
||||
import {
|
||||
LiteralHeading,
|
||||
LiteralParagraph,
|
||||
LiteralWhitespaceInput,
|
||||
whitespaceAttribute,
|
||||
whitespacePreserveValue,
|
||||
} from "./rich-input-whitespace";
|
||||
|
||||
const borderStyleProperties = [
|
||||
"border",
|
||||
"border-top",
|
||||
"border-right",
|
||||
"border-bottom",
|
||||
"border-left",
|
||||
"border-width",
|
||||
"border-style",
|
||||
"border-color",
|
||||
"border-top-width",
|
||||
"border-top-style",
|
||||
"border-top-color",
|
||||
"border-right-width",
|
||||
"border-right-style",
|
||||
"border-right-color",
|
||||
"border-bottom-width",
|
||||
"border-bottom-style",
|
||||
"border-bottom-color",
|
||||
"border-left-width",
|
||||
"border-left-style",
|
||||
"border-left-color",
|
||||
] as const;
|
||||
|
||||
const textBlockTags = new Set(["p", "h1", "h2", "h3", "h4", "h5", "h6"]);
|
||||
const inlineTags = new Set(["br", "strong", "b", "em", "i", "u", "s", "strike", "code", "a", "span", "mark"]);
|
||||
const cellBlockTags = new Set([...textBlockTags, "blockquote", "ul", "ol", "hr"]);
|
||||
|
||||
type ValueValidator = (value: string, element: Element) => boolean;
|
||||
|
||||
type ElementRule = {
|
||||
attributes?: ReadonlyMap<string, ValueValidator>;
|
||||
styles?: ReadonlyMap<string, ValueValidator>;
|
||||
validate?: (element: Element) => boolean;
|
||||
};
|
||||
|
||||
type StyleDeclaration = {
|
||||
property: string;
|
||||
value: string;
|
||||
};
|
||||
|
||||
const cssWideKeywords = new Set(["inherit", "initial", "revert", "revert-layer", "unset"]);
|
||||
const textAlignments = new Set(["left", "center", "right", "justify"]);
|
||||
const cellAlignments = new Set(["left", "center", "right"]);
|
||||
const linkProtocols = new Set(["http", "https", "ftp", "ftps", "mailto", "tel", "callto", "sms", "cid", "xmpp"]);
|
||||
|
||||
const readStyleDeclarations = (element: Element): StyleDeclaration[] | null => {
|
||||
const style = element.getAttribute("style");
|
||||
if (style === null) return [];
|
||||
const declarations = style
|
||||
.split(";")
|
||||
.map((declaration) => declaration.trim())
|
||||
.filter(Boolean);
|
||||
if (declarations.length === 0) return null;
|
||||
const parsed = declarations.map((declaration) => {
|
||||
const separator = declaration.indexOf(":");
|
||||
if (separator <= 0) return null;
|
||||
const property = declaration.slice(0, separator).trim().toLowerCase();
|
||||
const value = declaration.slice(separator + 1).trim();
|
||||
return property && value ? { property, value } : null;
|
||||
});
|
||||
return parsed.every((declaration) => declaration !== null) ? parsed : null;
|
||||
};
|
||||
|
||||
const supportsCssValue =
|
||||
(property: string): ValueValidator =>
|
||||
(value, element) => {
|
||||
if (/!important\s*$/i.test(value)) return false;
|
||||
const probe = element.ownerDocument.createElement("span").style;
|
||||
probe.setProperty(property, value);
|
||||
return probe.getPropertyValue(property) !== "";
|
||||
};
|
||||
|
||||
const supportsColor: ValueValidator = (value, element) => supportsCssValue("color")(value, element);
|
||||
const supportsTextAlign =
|
||||
(allowed: ReadonlySet<string>): ValueValidator =>
|
||||
(value) =>
|
||||
allowed.has(value);
|
||||
const supportsCanonicalInteger =
|
||||
(minimum: number, maximum = Number.MAX_SAFE_INTEGER): ValueValidator =>
|
||||
(value) => {
|
||||
if (!/^-?\d+$/.test(value)) return false;
|
||||
const parsed = Number(value);
|
||||
return Number.isSafeInteger(parsed) && parsed >= minimum && parsed <= maximum && String(parsed) === value;
|
||||
};
|
||||
const supportsBorderSpacing: ValueValidator = (value, element) => {
|
||||
if (cssWideKeywords.has(value)) return true;
|
||||
const parts = value.split(/\s+/);
|
||||
return parts.length <= 2 && parts.every((part) => supportsCssValue("width")(part, element));
|
||||
};
|
||||
const supportsVerticalAlign: ValueValidator = (value, element) => {
|
||||
const keywords = new Set(["baseline", "sub", "super", "text-top", "text-bottom", "middle", "top", "bottom"]);
|
||||
return cssWideKeywords.has(value) || keywords.has(value) || supportsCssValue("width")(value, element);
|
||||
};
|
||||
const supportsLinkHref: ValueValidator = (value) => {
|
||||
if (
|
||||
!value ||
|
||||
value.trim() !== value ||
|
||||
/[\s\u200B-\u200D\u2060\uFEFF]/u.test(value) ||
|
||||
Array.from(value).some((character) => character.charCodeAt(0) <= 31 || character.charCodeAt(0) === 127)
|
||||
)
|
||||
return false;
|
||||
const scheme = value.match(/^([a-z][a-z0-9+.-]*):/i)?.[1]?.toLowerCase();
|
||||
return !scheme || linkProtocols.has(scheme);
|
||||
};
|
||||
|
||||
const cssRules = (properties: readonly string[]) =>
|
||||
new Map<string, ValueValidator>(properties.map((property) => [property, supportsCssValue(property)]));
|
||||
|
||||
const styleValues = (element: Element, property: string) =>
|
||||
(readStyleDeclarations(element) ?? [])
|
||||
.filter((declaration) => declaration.property === property)
|
||||
.map(({ value }) => value);
|
||||
|
||||
const normalizedColor = (value: string, element: Element) => {
|
||||
const probe = element.ownerDocument.createElement("span").style;
|
||||
probe.color = value;
|
||||
return probe.color;
|
||||
};
|
||||
|
||||
const validateCell = (element: Element) => {
|
||||
const colspan = element.getAttribute("colspan");
|
||||
const colwidth = element.getAttribute("colwidth");
|
||||
if (colwidth) {
|
||||
const widths = colwidth.split(",");
|
||||
const span = colspan ? Number(colspan) : 1;
|
||||
if (widths.length !== span || !widths.every((width) => supportsCanonicalInteger(1)(width, element))) return false;
|
||||
}
|
||||
const align = element.getAttribute("align")?.trim().toLowerCase();
|
||||
const styleAligns = styleValues(element, "text-align");
|
||||
return styleAligns.length <= 1 && (!align || styleAligns.length === 0 || align === styleAligns[0]);
|
||||
};
|
||||
|
||||
const validateTextBlock = (element: Element) => {
|
||||
const indent = element.getAttribute("data-indent");
|
||||
const margins = styleValues(element, "margin-inline-start");
|
||||
const alignments = styleValues(element, "text-align");
|
||||
if (margins.length > 1 || alignments.length > 1) return false;
|
||||
if (!indent) return margins.length === 0;
|
||||
if (element.closest("li") || !supportsCanonicalInteger(1, 8)(indent, element)) return false;
|
||||
return margins.length === 0 || margins[0] === `${Number(indent) * 24}px`;
|
||||
};
|
||||
|
||||
const validateMark = (element: Element) => {
|
||||
const dataColor = element.getAttribute("data-color");
|
||||
const backgrounds = styleValues(element, "background-color");
|
||||
const colors = styleValues(element, "color");
|
||||
if (backgrounds.length > 1 || colors.length > 2) return false;
|
||||
const semanticColor = dataColor ?? backgrounds[0];
|
||||
if (!semanticColor) return colors.length === 0;
|
||||
if (dataColor && backgrounds[0] && normalizedColor(dataColor, element) !== normalizedColor(backgrounds[0], element))
|
||||
return false;
|
||||
if (colors.length === 0) return true;
|
||||
return isDarkColor(semanticColor)
|
||||
? colors.length === 2 && colors[0] === "inherit" && normalizedColor(colors[1] ?? "", element) === "#ffffff"
|
||||
: colors.length === 1 && colors[0] === "inherit";
|
||||
};
|
||||
|
||||
const tableStyles = new Map([
|
||||
...cssRules(["width", "min-width", "max-width", "border-collapse", ...borderStyleProperties]),
|
||||
["border-spacing", supportsBorderSpacing],
|
||||
]);
|
||||
const rowStyles = cssRules(["height", ...borderStyleProperties]);
|
||||
const cellStyles = new Map([
|
||||
...cssRules([
|
||||
"width",
|
||||
"min-width",
|
||||
"max-width",
|
||||
"height",
|
||||
"padding",
|
||||
"padding-top",
|
||||
"padding-right",
|
||||
"padding-bottom",
|
||||
"padding-left",
|
||||
...borderStyleProperties,
|
||||
]),
|
||||
["vertical-align", supportsVerticalAlign],
|
||||
["text-align", supportsTextAlign(cellAlignments)],
|
||||
["background-color", supportsColor],
|
||||
]);
|
||||
const textBlockStyles = new Map([
|
||||
["text-align", supportsTextAlign(textAlignments)],
|
||||
["margin-inline-start", supportsCssValue("margin-inline-start")],
|
||||
]);
|
||||
const listItemStyles = new Map([["text-align", supportsTextAlign(textAlignments)]]);
|
||||
const textStyles = new Map([["color", supportsColor]]);
|
||||
const highlightStyles = new Map([
|
||||
["background-color", supportsColor],
|
||||
["color", supportsColor],
|
||||
]);
|
||||
const cellAttributes = new Map<string, ValueValidator>([
|
||||
["colspan", supportsCanonicalInteger(1, 1000)],
|
||||
["rowspan", supportsCanonicalInteger(1, 65_534)],
|
||||
["colwidth", (value, element) => value.split(",").every((width) => supportsCanonicalInteger(1)(width, element))],
|
||||
["align", (value) => cellAlignments.has(value.trim().toLowerCase())],
|
||||
]);
|
||||
const noValues: ElementRule = {};
|
||||
const textBlockRule: ElementRule = {
|
||||
attributes: new Map([
|
||||
["data-indent", supportsCanonicalInteger(1, 8)],
|
||||
[whitespaceAttribute, (value) => value === whitespacePreserveValue],
|
||||
]),
|
||||
styles: textBlockStyles,
|
||||
validate: validateTextBlock,
|
||||
};
|
||||
const elementRules = new Map<string, ElementRule>([
|
||||
["table", { styles: tableStyles }],
|
||||
["tbody", noValues],
|
||||
["tr", { styles: rowStyles }],
|
||||
["td", { attributes: cellAttributes, styles: cellStyles, validate: validateCell }],
|
||||
["th", { attributes: cellAttributes, styles: cellStyles, validate: validateCell }],
|
||||
...[...textBlockTags].map((tag): [string, ElementRule] => [tag, textBlockRule]),
|
||||
["blockquote", noValues],
|
||||
["ul", noValues],
|
||||
["ol", { attributes: new Map([["start", supportsCanonicalInteger(Number.MIN_SAFE_INTEGER)]]) }],
|
||||
["li", { styles: listItemStyles, validate: (element) => styleValues(element, "text-align").length <= 1 }],
|
||||
["hr", noValues],
|
||||
["br", noValues],
|
||||
["strong", noValues],
|
||||
["b", noValues],
|
||||
["em", noValues],
|
||||
["i", noValues],
|
||||
["u", noValues],
|
||||
["s", noValues],
|
||||
["strike", noValues],
|
||||
["code", noValues],
|
||||
[
|
||||
"a",
|
||||
{
|
||||
attributes: new Map([
|
||||
["href", supportsLinkHref],
|
||||
["target", () => true],
|
||||
["rel", () => true],
|
||||
["class", () => true],
|
||||
["title", () => true],
|
||||
]),
|
||||
validate: (element) => element.hasAttribute("href"),
|
||||
},
|
||||
],
|
||||
["span", { styles: textStyles, validate: (element) => styleValues(element, "color").length === 1 }],
|
||||
["mark", { attributes: new Map([["data-color", supportsColor]]), styles: highlightStyles, validate: validateMark }],
|
||||
]);
|
||||
|
||||
const hasOnlySupportedValues = (element: Element) => {
|
||||
const rule = elementRules.get(element.tagName.toLowerCase());
|
||||
if (!rule) return false;
|
||||
for (const attribute of element.attributes) {
|
||||
const name = attribute.name.toLowerCase();
|
||||
if (name === "style" && rule.styles) continue;
|
||||
const validate = rule.attributes?.get(name);
|
||||
if (!validate?.(attribute.value, element)) return false;
|
||||
}
|
||||
const declarations = readStyleDeclarations(element);
|
||||
if (!declarations) return false;
|
||||
if (declarations.length > 0 && !rule.styles) return false;
|
||||
for (const { property, value } of declarations) {
|
||||
if (!rule.styles?.get(property)?.(value, element)) return false;
|
||||
}
|
||||
return rule.validate?.(element) ?? true;
|
||||
};
|
||||
|
||||
const hasUnsupportedChildNode = (node: Node) =>
|
||||
node.nodeType === Node.TEXT_NODE ? Boolean(node.textContent?.trim()) : node.nodeType !== Node.ELEMENT_NODE;
|
||||
|
||||
const hasOnlyInlineContent = (element: Element): boolean =>
|
||||
Array.from(element.childNodes).every((child) => {
|
||||
if (child.nodeType === Node.TEXT_NODE) return true;
|
||||
if (!(child instanceof Element) || !inlineTags.has(child.tagName.toLowerCase())) return false;
|
||||
return hasOnlyInlineContent(child);
|
||||
});
|
||||
|
||||
const hasOnlyCellBlockContent = (element: Element): boolean =>
|
||||
Array.from(element.childNodes).every((child) => {
|
||||
if (child.nodeType === Node.TEXT_NODE) return true;
|
||||
if (!(child instanceof Element)) return false;
|
||||
const tagName = child.tagName.toLowerCase();
|
||||
if (inlineTags.has(tagName)) return hasOnlyInlineContent(child);
|
||||
if (!cellBlockTags.has(tagName)) return false;
|
||||
if (textBlockTags.has(tagName)) return hasOnlyInlineContent(child);
|
||||
if (tagName === "hr") return child.childNodes.length === 0;
|
||||
if (tagName === "blockquote") {
|
||||
const hasContent = Array.from(child.childNodes).some(
|
||||
(blockChild) => blockChild.nodeType === Node.ELEMENT_NODE || Boolean(blockChild.textContent?.trim()),
|
||||
);
|
||||
return hasContent && hasOnlyCellBlockContent(child);
|
||||
}
|
||||
const listItems = Array.from(child.children);
|
||||
if (listItems.length === 0) return false;
|
||||
return Array.from(child.childNodes).every((listChild) => {
|
||||
if (listChild.nodeType === Node.TEXT_NODE) return !listChild.textContent?.trim();
|
||||
if (!(listChild instanceof Element) || listChild.tagName.toLowerCase() !== "li") return false;
|
||||
return hasOnlyCellBlockContent(listChild);
|
||||
});
|
||||
});
|
||||
|
||||
const hasRectangularTableGrid = (rows: readonly Element[]) => {
|
||||
let expectedWidth: number | undefined;
|
||||
const occupiedUntil: number[] = [];
|
||||
|
||||
for (const [rowIndex, row] of rows.entries()) {
|
||||
const coverage = occupiedUntil.map((endRow) => endRow > rowIndex);
|
||||
let column = 0;
|
||||
|
||||
for (const cell of Array.from(row.children)) {
|
||||
while (coverage[column]) column++;
|
||||
const colspan = Number(cell.getAttribute("colspan") ?? 1);
|
||||
const rowspan = Number(cell.getAttribute("rowspan") ?? 1);
|
||||
if (rowIndex + rowspan > rows.length) return false;
|
||||
for (let offset = 0; offset < colspan; offset++) {
|
||||
if (coverage[column + offset]) return false;
|
||||
coverage[column + offset] = true;
|
||||
occupiedUntil[column + offset] = rowIndex + rowspan;
|
||||
}
|
||||
column += colspan;
|
||||
}
|
||||
|
||||
const rowWidth = coverage.lastIndexOf(true) + 1;
|
||||
if (rowWidth === 0 || coverage.slice(0, rowWidth).some((covered) => !covered)) return false;
|
||||
expectedWidth ??= rowWidth;
|
||||
if (rowWidth !== expectedWidth) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
const hasUnsupportedCellDescendant = (element: Element) => {
|
||||
const tagName = element.tagName.toLowerCase();
|
||||
return !inlineTags.has(tagName) && !cellBlockTags.has(tagName) && tagName !== "li"
|
||||
? true
|
||||
: !hasOnlySupportedValues(element);
|
||||
};
|
||||
|
||||
const sourceTablesFrom = (html: string) =>
|
||||
Array.from(html.matchAll(/<table\b[^>]*>[\s\S]*?<\/table\s*>/gi), (match) => match[0]);
|
||||
|
||||
const tableMarkersFrom = (html: string) => Array.from(html.matchAll(/<\/?table(?=\s|\/?>|$)/gi));
|
||||
|
||||
const openingTableMarkersFrom = (html: string) => Array.from(html.matchAll(/<table(?=\s|\/?>|$)/gi));
|
||||
|
||||
const parsedTablesMatchSource = (sourceTables: readonly string[], tables: readonly HTMLTableElement[]) => {
|
||||
return (
|
||||
sourceTables.length === tables.length && sourceTables.every((source, index) => source === tables[index]?.outerHTML)
|
||||
);
|
||||
};
|
||||
|
||||
export const hasUnsupportedTableMarkup = (html: string) => {
|
||||
if (typeof DOMParser === "undefined") return false;
|
||||
const tableMarkers = tableMarkersFrom(html);
|
||||
if (tableMarkers.length === 0) return false;
|
||||
const document = new DOMParser().parseFromString(html, "text/html");
|
||||
const tables = Array.from(document.querySelectorAll("table"));
|
||||
const sourceTables = sourceTablesFrom(html);
|
||||
const openingTableMarkers = openingTableMarkersFrom(html);
|
||||
if (
|
||||
tables.length === 0 ||
|
||||
sourceTables.length === 0 ||
|
||||
openingTableMarkers.length !== sourceTables.length ||
|
||||
tableMarkers.length !== sourceTables.length * 2 ||
|
||||
!parsedTablesMatchSource(sourceTables, tables)
|
||||
)
|
||||
return true;
|
||||
|
||||
for (const table of tables) {
|
||||
if (!hasOnlySupportedValues(table)) return true;
|
||||
const bodies = Array.from(table.children);
|
||||
if (bodies.length !== 1 || bodies[0]?.tagName.toLowerCase() !== "tbody") return true;
|
||||
if (Array.from(table.childNodes).some(hasUnsupportedChildNode)) return true;
|
||||
|
||||
const body = bodies[0];
|
||||
if (!body || !hasOnlySupportedValues(body)) return true;
|
||||
if (Array.from(body.childNodes).some(hasUnsupportedChildNode)) return true;
|
||||
const rows = Array.from(body.children);
|
||||
if (rows.length === 0 || rows.some((row) => row.tagName.toLowerCase() !== "tr")) return true;
|
||||
|
||||
for (const row of rows) {
|
||||
if (!hasOnlySupportedValues(row)) return true;
|
||||
if (Array.from(row.childNodes).some(hasUnsupportedChildNode)) return true;
|
||||
if (Array.from(row.children).some((cell) => !["td", "th"].includes(cell.tagName.toLowerCase()))) return true;
|
||||
}
|
||||
for (const cell of table.querySelectorAll("td, th")) {
|
||||
if (!hasOnlySupportedValues(cell)) return true;
|
||||
if (!hasOnlyCellBlockContent(cell)) return true;
|
||||
if (Array.from(cell.querySelectorAll("*"), hasUnsupportedCellDescendant).some(Boolean)) return true;
|
||||
}
|
||||
if (!hasRectangularTableGrid(rows)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const preservedStyle = {
|
||||
default: null,
|
||||
parseHTML: (element: HTMLElement) => element.getAttribute("style"),
|
||||
renderHTML: (attributes: { style?: string | null }) => (attributes.style ? { style: attributes.style } : {}),
|
||||
};
|
||||
|
||||
const StyledTable = Table.extend({
|
||||
addAttributes() {
|
||||
return { ...this.parent?.(), style: preservedStyle };
|
||||
},
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
return ["table", HTMLAttributes, ["tbody", 0]];
|
||||
},
|
||||
});
|
||||
|
||||
const StyledTableRow = TableRow.extend({
|
||||
addAttributes() {
|
||||
return { ...this.parent?.(), style: preservedStyle };
|
||||
},
|
||||
});
|
||||
|
||||
const StyledTableHeader = TableHeader.extend({
|
||||
addAttributes() {
|
||||
return { ...this.parent?.(), style: preservedStyle };
|
||||
},
|
||||
});
|
||||
|
||||
const StyledTableCell = TableCell.extend({
|
||||
addAttributes() {
|
||||
return { ...this.parent?.(), style: preservedStyle };
|
||||
},
|
||||
});
|
||||
|
||||
export const richTextExtensions = [
|
||||
StarterKit.configure({
|
||||
heading: false,
|
||||
paragraph: false,
|
||||
codeBlock: false,
|
||||
link: {
|
||||
openOnClick: false,
|
||||
enableClickSelection: true,
|
||||
defaultProtocol: "https",
|
||||
protocols: ["http", "https"],
|
||||
},
|
||||
}),
|
||||
LiteralParagraph,
|
||||
LiteralHeading.configure({ levels: [1, 2, 3, 4, 5, 6] }),
|
||||
LiteralWhitespaceInput.configure({ hasUnsupportedTableMarkup }),
|
||||
TextStyle,
|
||||
Color,
|
||||
Highlight.configure({ multicolor: true }).extend({
|
||||
renderHTML({ HTMLAttributes }) {
|
||||
const color = HTMLAttributes["data-color"] as string | undefined;
|
||||
if (color && isDarkColor(color)) {
|
||||
HTMLAttributes.style = `${HTMLAttributes.style ?? ""}; color: #ffffff`;
|
||||
}
|
||||
return ["mark", HTMLAttributes, 0];
|
||||
},
|
||||
}),
|
||||
TextAlign.configure({ types: ["heading", "paragraph", "listItem"] }),
|
||||
ParagraphIndent,
|
||||
StyledTable,
|
||||
StyledTableRow,
|
||||
StyledTableHeader,
|
||||
StyledTableCell,
|
||||
];
|
||||
@@ -11,7 +11,7 @@ import { Icon } from "@reactive-resume/ui/components/icon";
|
||||
import { useKeyboardInset } from "@reactive-resume/ui/hooks/use-keyboard-inset";
|
||||
import { useIsMobile } from "@reactive-resume/ui/hooks/use-mobile";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
import { hasUnsupportedTableMarkup, richInputExtensions } from "@/components/input/rich-input";
|
||||
import { hasUnsupportedTableMarkup, richTextExtensions } from "@/components/input/rich-text-extensions";
|
||||
import { openAssistantFrom } from "@/features/assistant/open";
|
||||
import { useHasUsableAiProvider } from "@/features/settings/integrations/hooks/use-has-usable-ai-provider";
|
||||
import { usePrompt } from "@/hooks/use-prompt";
|
||||
@@ -118,7 +118,7 @@ export function RichTextEditor({
|
||||
const readOnlyTable = useMemo(() => hasUnsupportedTableMarkup(value), [value]);
|
||||
|
||||
const editor = useEditor({
|
||||
extensions: richInputExtensions,
|
||||
extensions: richTextExtensions,
|
||||
content: value,
|
||||
editable: !disabled && !readOnlyTable,
|
||||
immediatelyRender: false,
|
||||
|
||||
@@ -23,8 +23,8 @@ type RichInputProps = {
|
||||
vi.mock("../shared/section-base", () => ({
|
||||
SectionBase: ({ children }: SectionBaseProps) => <div data-testid="section-base">{children}</div>,
|
||||
}));
|
||||
vi.mock("@/components/input/rich-input", () => ({
|
||||
RichInput: (props: RichInputProps) => {
|
||||
vi.mock("@/features/resume/editor/write/rich-text-editor", () => ({
|
||||
RichTextEditor: (props: RichInputProps) => {
|
||||
richInputProps.value = props.value;
|
||||
richInputProps.onChange = props.onChange;
|
||||
return <textarea data-testid="rich-input" value={props.value} readOnly />;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { t } from "@lingui/core/macro";
|
||||
import { Trans } from "@lingui/react/macro";
|
||||
import { RichInput } from "@/components/input/rich-input";
|
||||
import { useCurrentResume, useUpdateResumeData } from "@/features/resume/builder/draft";
|
||||
import { RichTextEditor } from "@/features/resume/editor/write/rich-text-editor";
|
||||
import { SectionBase } from "../shared/section-base";
|
||||
|
||||
export function NotesSectionBuilder() {
|
||||
@@ -28,7 +29,7 @@ function NotesSectionForm() {
|
||||
<Trans>Keep private notes about this resume here. Nobody else can see them.</Trans>
|
||||
</p>
|
||||
|
||||
<RichInput value={notes} onChange={onChange} />
|
||||
<RichTextEditor label={t`Notes`} value={notes} onChange={onChange} />
|
||||
|
||||
<p className="text-ink-3">
|
||||
<Trans>For example, note which companies you sent this resume to, or links to the job descriptions.</Trans>
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { Toggle } from "./toggle";
|
||||
|
||||
describe("Toggle", () => {
|
||||
it("renders with data-slot='toggle'", () => {
|
||||
render(<Toggle data-testid="t">on</Toggle>);
|
||||
expect(screen.getByTestId("t")).toHaveAttribute("data-slot", "toggle");
|
||||
});
|
||||
|
||||
it("starts unpressed by default", () => {
|
||||
render(<Toggle data-testid="t">on</Toggle>);
|
||||
expect(screen.getByTestId("t").getAttribute("aria-pressed")).toBe("false");
|
||||
});
|
||||
|
||||
it("can be controlled via pressed prop", () => {
|
||||
render(
|
||||
<Toggle data-testid="t" pressed>
|
||||
on
|
||||
</Toggle>,
|
||||
);
|
||||
expect(screen.getByTestId("t").getAttribute("aria-pressed")).toBe("true");
|
||||
});
|
||||
|
||||
it("calls onPressedChange when clicked", async () => {
|
||||
const onChange = vi.fn();
|
||||
render(
|
||||
<Toggle data-testid="t" onPressedChange={onChange}>
|
||||
x
|
||||
</Toggle>,
|
||||
);
|
||||
await userEvent.click(screen.getByTestId("t"));
|
||||
expect(onChange).toHaveBeenCalledWith(true, expect.anything());
|
||||
});
|
||||
|
||||
it.each([["default"], ["outline"]] as const)("renders variant=%s", (variant) => {
|
||||
render(
|
||||
<Toggle data-testid="t" variant={variant}>
|
||||
x
|
||||
</Toggle>,
|
||||
);
|
||||
expect(screen.getByTestId("t")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([["default"], ["sm"], ["lg"]] as const)("renders size=%s", (size) => {
|
||||
render(
|
||||
<Toggle data-testid="t" size={size}>
|
||||
x
|
||||
</Toggle>,
|
||||
);
|
||||
expect(screen.getByTestId("t")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("merges custom className", () => {
|
||||
render(
|
||||
<Toggle data-testid="t" className="my-custom">
|
||||
x
|
||||
</Toggle>,
|
||||
);
|
||||
expect(screen.getByTestId("t")).toHaveClass("my-custom");
|
||||
});
|
||||
});
|
||||
@@ -1,36 +0,0 @@
|
||||
import type { VariantProps } from "class-variance-authority";
|
||||
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle";
|
||||
import { cva } from "class-variance-authority";
|
||||
import { cn } from "@reactive-resume/utils/style";
|
||||
|
||||
const toggleVariants = cva(
|
||||
"group/toggle inline-flex items-center justify-center gap-1.5 whitespace-nowrap rounded-md font-medium text-ink-2 text-sm transition-colors duration-quick hover:bg-hover hover:text-ink disabled:pointer-events-none disabled:text-ink-3 aria-pressed:bg-sunken aria-pressed:text-ink [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-transparent",
|
||||
outline: "border border-line-2 bg-surface hover:bg-sunken",
|
||||
},
|
||||
size: {
|
||||
default: "h-9 min-w-9 px-2.5",
|
||||
sm: "h-7 min-w-7 rounded-sm px-2 text-[13px] [&_svg:not([class*='size-'])]:size-3.5",
|
||||
lg: "h-11 min-w-11 rounded-lg px-3 text-base",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
size: "default",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Toggle({
|
||||
className,
|
||||
variant = "default",
|
||||
size = "default",
|
||||
...props
|
||||
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
|
||||
return <TogglePrimitive data-slot="toggle" className={cn(toggleVariants({ variant, size, className }))} {...props} />;
|
||||
}
|
||||
|
||||
export { Toggle, toggleVariants };
|
||||
Reference in New Issue
Block a user