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:
Amruth Pillai
2026-09-29 10:41:32 +02:00
parent 538fd316a1
commit 22dbbdc547
12 changed files with 477 additions and 2266 deletions
@@ -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中&nbsp;文 \t </p>`
: `<p dir="rtl" ${preserve}>\u3000中&nbsp;文 \t </p>`,
);
expect(editor.getText()).toBe("\u3000中\u00a0文 \t ");
} finally {
act(() => i18n.loadAndActivate({ locale: "en", messages: {} }));
}
});
});
@@ -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");
});
});
-36
View File
@@ -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 };