| Open"],
- ["truncated table after a complete table", "`,
- ],
- [
- "multiple table bodies",
- "",
- ],
- ["empty table body", ""],
- ["non-row table body child", ""],
- ["non-cell table row child", ""],
- ["mismatched row widths", ""],
- [
- "non-rectangular column span",
- '',
- ],
- ["row span beyond table bounds", ''],
- [
- "row span collision",
- '',
- ],
- ["browser-repaired malformed markup", ""],
- [
- "unrepresented attributes on supported descendants",
- ``,
- ],
- ["invalid table styles", ''],
- ["invalid row styles", ''],
- ["invalid cell alignment", ''],
- [
- "invalid cell style alignment",
- '',
- ],
- ["invalid column widths", ''],
- ["mismatched column widths", ''],
- ["invalid column span", ''],
- ["invalid row span", ''],
- [
- "invalid paragraph alignment",
- '',
- ],
- ["invalid indent level", ''],
- [
- "mismatched indent margin",
- '',
- ],
- [
- "physical indent margin",
- '',
- ],
- [
- "invalid ordered-list start",
- '',
- ],
- ["empty blockquote", ""],
- ["whitespace-only blockquote", ""],
- ["empty list", ""],
- [
- "anchor without href",
- '',
- ],
- [
- "unsafe link URI",
- '',
- ],
- [
- "unsafe link URI with ignored whitespace",
- '',
- ],
- [
- "invalid text color",
- '',
- ],
- [
- "invalid highlight color",
- '',
- ],
- [
- "mismatched highlight color",
- '',
- ],
-] 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) => (
-
-
- {
- editor = event.editor;
- }}
- />
-
-
- );
- 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 (
-
-
-
-
-
- {
- onChange(nextValue);
- setValue(nextValue);
- }}
- className={className}
- editable={editable}
- onCreate={(event) => {
- editor = event.editor;
- }}
- />
-
-
- );
- }
-
- render();
- 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(" Before ");
- 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 =
- '';
- 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();
- });
-});
diff --git a/apps/web/src/components/input/rich-input.test.ts b/apps/web/src/components/input/rich-input.test.ts
deleted file mode 100644
index e96f1c7d1..000000000
--- a/apps/web/src/components/input/rich-input.test.ts
+++ /dev/null
@@ -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: "First " });
-
- editor.commands.setTextSelection(editor.state.doc.content.size - 1);
- editor.commands.enter();
-
- expect(editor.getHTML()).toBe("First ");
- editor.destroy();
- });
-
- it("creates and splits list items", () => {
- const editor = new Editor({ extensions: [StarterKit], content: "First " });
-
- editor.commands.setTextSelection(editor.state.doc.content.size - 1);
- editor.commands.toggleBulletList();
- editor.commands.enter();
-
- expect(editor.getHTML()).toBe("");
- 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,
- });
- });
-});
diff --git a/apps/web/src/components/input/rich-input.tsx b/apps/web/src/components/input/rich-input.tsx
deleted file mode 100644
index 8149d5a83..000000000
--- a/apps/web/src/components/input/rich-input.tsx
+++ /dev/null
@@ -1,1231 +0,0 @@
-import type { Editor, UseEditorOptions } from "@tiptap/react";
-import { t } from "@lingui/core/macro";
-import { useLingui } from "@lingui/react";
-import { Trans } from "@lingui/react/macro";
-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 { EditorContent, EditorContext, useEditor, useEditorState } from "@tiptap/react";
-import StarterKit from "@tiptap/starter-kit";
-import { useEffect, useMemo, useState } from "react";
-import { match } from "ts-pattern";
-import z from "zod";
-import { Button } from "@reactive-resume/ui/components/button";
-import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@reactive-resume/ui/components/dialog";
-import {
- DropdownMenu,
- DropdownMenuCheckboxItem,
- DropdownMenuContent,
- DropdownMenuSeparator,
- DropdownMenuTrigger,
-} from "@reactive-resume/ui/components/dropdown-menu";
-import { Icon } from "@reactive-resume/ui/components/icon";
-import { PopoverHeader, PopoverTitle, PopoverTrigger } from "@reactive-resume/ui/components/popover";
-import { toast } from "@reactive-resume/ui/components/toast";
-import { Toggle } from "@reactive-resume/ui/components/toggle";
-import { isDarkColor } from "@reactive-resume/utils/color";
-import { isRTL } from "@reactive-resume/utils/locale";
-import { cn } from "@reactive-resume/utils/style";
-import { usePrompt } from "@/hooks/use-prompt";
-import { ColorPicker } from "./color-picker";
-import { ParagraphIndent } from "./paragraph-indent";
-import { defaultHighlightColor, resolveHighlightToolbarState } from "./rich-input.utils";
-import {
- LiteralHeading,
- LiteralParagraph,
- LiteralWhitespaceInput,
- whitespaceAttribute,
- whitespacePreserveValue,
-} from "./rich-input-whitespace";
-
-const defaultTextColor = "rgba(0, 0, 0, 1)";
-
-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;
- styles?: ReadonlyMap;
- 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): 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(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([
- ["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([
- ["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(/]*>[\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(/|$)/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 richInputExtensions = [
- 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,
-];
-
-type Props = UseEditorOptions & {
- "aria-label"?: string;
- value: string;
- onChange: (value: string) => void;
- style?: React.CSSProperties;
- className?: string;
- editorClassName?: string;
-};
-
-export function RichInput({
- value,
- onChange,
- style,
- className,
- editorClassName,
- "aria-label": ariaLabel,
- ...options
-}: Props) {
- const { i18n } = useLingui();
- const textDirection = isRTL(i18n.locale) ? "rtl" : undefined;
- const [isFullscreen, setIsFullscreen] = useState(false);
- const hasUnsupportedTable = useMemo(() => hasUnsupportedTableMarkup(value), [value]);
- const requestedEditable = options.editable ?? true;
-
- const editor = useEditor({
- ...options,
- extensions: richInputExtensions,
- textDirection,
- content: value,
- editable: requestedEditable && !hasUnsupportedTable,
- immediatelyRender: false,
- shouldRerenderOnTransaction: false,
- editorProps: {
- attributes: {
- ...(ariaLabel ? { "aria-label": ariaLabel } : {}),
- spellcheck: "false",
- "data-editor": "true",
- "data-fullscreen": isFullscreen ? "true" : "false",
- class: cn(
- "wysiwyg group/editor overflow-y-auto p-3 pb-4 [&_[data-resume-whitespace=preserve]]:whitespace-pre-wrap",
- "rounded-md rounded-t-none border outline-none focus-visible:border-accent",
- "[td:has(.selectedCell)]:bg-accent",
- "data-[fullscreen=false]:max-h-[400px] data-[fullscreen=false]:min-h-[100px]",
- "data-[fullscreen=true]:max-h-none data-[fullscreen=true]:min-h-full",
- editorClassName,
- ),
- },
- },
- onUpdate: ({ editor }) => {
- if (hasUnsupportedTable) return;
- onChange(editor.getHTML());
- },
- });
-
- const providerValue = useMemo(() => ({ editor }), [editor]);
-
- useEffect(() => {
- if (!editor || editor.getHTML() === value) return;
- editor.commands.setContent(value, { emitUpdate: false });
- }, [editor, value]);
-
- useEffect(() => {
- if (!editor) return;
- editor.setEditable(requestedEditable && !hasUnsupportedTable, false);
- }, [editor, hasUnsupportedTable, requestedEditable]);
-
- if (!editor) return null;
-
- const editorElement = (
-
- {hasUnsupportedTable ? (
-
-
-
- Original table formatting is preserved. This content is read-only because it cannot be edited safely.
-
-
-
- ) : (
-
- )}
-
-
-
-
-
- );
-
- if (isFullscreen) {
- return (
-
-
- {/* Placeholder to maintain layout */}
-
-
-
-
-
- );
- }
-
- return (
-
-
- {editorElement}
-
-
- );
-}
-
-function useEditorToolbarState(editor: Editor) {
- const prompt = usePrompt();
-
- const state = useEditorState({
- editor,
- selector: (ctx) => {
- return {
- // Bold
- isBold: ctx.editor.isActive("bold") ?? false,
- canBold: ctx.editor.can().chain().toggleBold().run() ?? false,
- toggleBold: () => ctx.editor.chain().focus().toggleBold().run(),
-
- // Italic
- isItalic: ctx.editor.isActive("italic") ?? false,
- canItalic: ctx.editor.can().chain().toggleItalic().run() ?? false,
- toggleItalic: () => ctx.editor.chain().focus().toggleItalic().run(),
-
- // Underline
- isUnderline: ctx.editor.isActive("underline") ?? false,
- canUnderline: ctx.editor.can().chain().toggleUnderline().run() ?? false,
- toggleUnderline: () => ctx.editor.chain().focus().toggleUnderline().run(),
-
- // Strike
- isStrike: ctx.editor.isActive("strike") ?? false,
- canStrike: ctx.editor.can().chain().toggleStrike().run() ?? false,
- toggleStrike: () => ctx.editor.chain().focus().toggleStrike().run(),
-
- // Highlight Color
- isHighlight: ctx.editor.isActive("highlight") ?? false,
- highlightColor: (ctx.editor.getAttributes("highlight").color as string | undefined) ?? null,
- canHighlightColor: ctx.editor.can().chain().toggleHighlight().run() ?? false,
- setHighlightColor: (color: string) => ctx.editor.chain().focus().toggleHighlight({ color }).run(),
- unsetHighlightColor: () => ctx.editor.chain().focus().unsetHighlight().run(),
-
- // Text Color
- textColor: (ctx.editor.getAttributes("textStyle").color as string | undefined) ?? null,
- canTextColor: ctx.editor.can().chain().setColor(defaultTextColor).run() ?? false,
- setTextColor: (color: string) => ctx.editor.chain().focus().setColor(color).run(),
- unsetTextColor: () => ctx.editor.chain().focus().unsetColor().run(),
-
- // Heading 1
- isHeading1: ctx.editor.isActive("heading", { level: 1 }) ?? false,
- canHeading1: ctx.editor.can().chain().toggleHeading({ level: 1 }).run() ?? false,
- toggleHeading1: () => ctx.editor.chain().focus().toggleHeading({ level: 1 }).run(),
-
- // Heading 2
- isHeading2: ctx.editor.isActive("heading", { level: 2 }) ?? false,
- canHeading2: ctx.editor.can().chain().toggleHeading({ level: 2 }).run() ?? false,
- toggleHeading2: () => ctx.editor.chain().focus().toggleHeading({ level: 2 }).run(),
-
- // Heading 3
- isHeading3: ctx.editor.isActive("heading", { level: 3 }) ?? false,
- canHeading3: ctx.editor.can().chain().toggleHeading({ level: 3 }).run() ?? false,
- toggleHeading3: () => ctx.editor.chain().focus().toggleHeading({ level: 3 }).run(),
-
- // Heading 4
- isHeading4: ctx.editor.isActive("heading", { level: 4 }) ?? false,
- canHeading4: ctx.editor.can().chain().toggleHeading({ level: 4 }).run() ?? false,
- toggleHeading4: () => ctx.editor.chain().focus().toggleHeading({ level: 4 }).run(),
-
- // Heading 5
- isHeading5: ctx.editor.isActive("heading", { level: 5 }) ?? false,
- canHeading5: ctx.editor.can().chain().toggleHeading({ level: 5 }).run() ?? false,
- toggleHeading5: () => ctx.editor.chain().focus().toggleHeading({ level: 5 }).run(),
-
- // Heading 6
- isHeading6: ctx.editor.isActive("heading", { level: 6 }) ?? false,
- canHeading6: ctx.editor.can().chain().toggleHeading({ level: 6 }).run() ?? false,
- toggleHeading6: () => ctx.editor.chain().focus().toggleHeading({ level: 6 }).run(),
-
- // Paragraph
- isParagraph: ctx.editor.isActive("paragraph") ?? false,
- canParagraph: ctx.editor.can().chain().setParagraph().run() ?? false,
- setParagraph: () => ctx.editor.chain().focus().setParagraph().run(),
-
- // Left Align
- isLeftAlign: ctx.editor.isActive({ textAlign: "left" }) ?? false,
- canLeftAlign: ctx.editor.can().chain().toggleTextAlign("left").run() ?? false,
- toggleLeftAlign: () => ctx.editor.chain().focus().toggleTextAlign("left").run(),
-
- // Center Align
- isCenterAlign: ctx.editor.isActive({ textAlign: "center" }) ?? false,
- canCenterAlign: ctx.editor.can().chain().toggleTextAlign("center").run() ?? false,
- toggleCenterAlign: () => ctx.editor.chain().focus().toggleTextAlign("center").run(),
-
- // Right Align
- isRightAlign: ctx.editor.isActive({ textAlign: "right" }) ?? false,
- canRightAlign: ctx.editor.can().chain().toggleTextAlign("right").run() ?? false,
- toggleRightAlign: () => ctx.editor.chain().focus().toggleTextAlign("right").run(),
-
- // Justify Align
- isJustifyAlign: ctx.editor.isActive({ textAlign: "justify" }) ?? false,
- canJustifyAlign: ctx.editor.can().chain().toggleTextAlign("justify").run() ?? false,
- toggleJustifyAlign: () => ctx.editor.chain().focus().toggleTextAlign("justify").run(),
-
- // Bullet List
- isBulletList: ctx.editor.isActive("bulletList") ?? false,
- canBulletList: ctx.editor.can().chain().toggleBulletList().run() ?? false,
- toggleBulletList: () => ctx.editor.chain().focus().toggleBulletList().run(),
-
- // Ordered List
- isOrderedList: ctx.editor.isActive("orderedList") ?? false,
- canOrderedList: ctx.editor.can().chain().toggleOrderedList().run() ?? false,
- toggleOrderedList: () => ctx.editor.chain().focus().toggleOrderedList().run(),
-
- // Outdent block or list item
- canDecreaseIndent: ctx.editor.can().chain().decreaseIndent().run() ?? false,
- decreaseIndent: () => ctx.editor.chain().focus().decreaseIndent().run(),
-
- // Indent block or list item
- canIncreaseIndent: ctx.editor.can().chain().increaseIndent().run() ?? false,
- increaseIndent: () => ctx.editor.chain().focus().increaseIndent().run(),
-
- // Link
- isLink: ctx.editor.isActive("link") ?? false,
- setLink: async () => {
- const url = await prompt(t`Enter the URL you want to link to:`, {
- defaultValue: "https://",
- });
-
- if (!url || url.trim() === "") {
- ctx.editor.chain().focus().unsetLink().run();
- return;
- }
-
- if (!z.url({ protocol: /^https?$/ }).safeParse(url).success) {
- toast.add({
- type: "error",
- title: t`The URL you entered is not valid.`,
- description: t`Valid URLs must start with http:// or https://.`,
- });
- return;
- }
-
- ctx.editor.chain().focus().setLink({ href: url, target: "_blank", rel: "noopener nofollow" }).run();
- },
- unsetLink: () => ctx.editor.chain().focus().unsetLink().run(),
-
- // Inline Code
- isInlineCode: ctx.editor.isActive("code") ?? false,
- canInlineCode: ctx.editor.can().chain().toggleCode().run() ?? false,
- toggleInlineCode: () => ctx.editor.chain().focus().toggleCode().run(),
-
- // Character Count
- characterCount: ctx.editor.getText().length,
-
- // Hard Break
- setHardBreak: () => ctx.editor.chain().focus().setHardBreak().run(),
-
- // Horizontal Rule
- setHorizontalRule: () => ctx.editor.chain().focus().setHorizontalRule().run(),
- };
- },
- });
-
- return state;
-}
-
-type EditorToolbarState = ReturnType;
-
-type EditorToolbarProps = {
- editor: Editor;
- isFullscreen: boolean;
-};
-
-function EditorToolbar({ editor, isFullscreen }: EditorToolbarProps) {
- const state = useEditorToolbarState(editor);
-
- return renderEditorToolbar(state, isFullscreen);
-}
-
-function renderEditorToolbar(state: EditorToolbarState, isFullscreen: boolean) {
- const { visibleHighlightColor, canClearHighlight } = resolveHighlightToolbarState(
- state.isHighlight,
- state.highlightColor,
- );
-
- return (
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- }
- />
- }
- >
-
-
-
-
-
-
-
-
- Highlight Color
-
-
-
- Choose a preset or custom shade.
-
-
-
-
-
-
-
-
-
-
-
- A
-
-
-
- }
- />
- }
- >
-
-
-
- A
-
-
-
-
- Text Color
-
-
-
- Choose a preset or custom shade.
-
-
-
-
-
-
-
-
-
-
-
-
-
- {match(state)
- .with({ isParagraph: true }, () => )
- .with({ isHeading1: true }, () => )
- .with({ isHeading2: true }, () => )
- .with({ isHeading3: true }, () => )
- .with({ isHeading4: true }, () => )
- .with({ isHeading5: true }, () => )
- .with({ isHeading6: true }, () => )
- .otherwise(() => (
-
- ))}
-
- }
- />
-
-
-
- Paragraph
-
-
-
- Heading 1
-
-
- Heading 2
-
-
- Heading 3
-
-
- Heading 4
-
-
- Heading 5
-
-
- Heading 6
-
-
-
-
-
-
- {match(state)
- .with({ isLeftAlign: true }, () => )
- .with({ isCenterAlign: true }, () => )
- .with({ isRightAlign: true }, () => )
- .with({ isJustifyAlign: true }, () => )
- .otherwise(() => (
-
- ))}
-
- }
- />
-
-
-
- Left Align
-
-
- Center Align
-
-
- Right Align
-
-
- Justify Align
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
- {state.isLink ? (
-
- ) : (
-
- )}
-
-
-
-
-
-
-
-
-
-
- {state.characterCount} characters
-
-
- );
-}
diff --git a/apps/web/src/components/input/rich-input.utils.ts b/apps/web/src/components/input/rich-input.utils.ts
deleted file mode 100644
index 6973e3db5..000000000
--- a/apps/web/src/components/input/rich-input.utils.ts
+++ /dev/null
@@ -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 };
-}
diff --git a/apps/web/src/components/input/rich-input.whitespace.test.tsx b/apps/web/src/components/input/rich-input.whitespace.test.tsx
deleted file mode 100644
index 4b434f9bd..000000000
--- a/apps/web/src/components/input/rich-input.whitespace.test.tsx
+++ /dev/null
@@ -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 (
-
-
- onCreate(editor)} />
-
-
- );
-}
-
-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}`);
- expect(onChange).not.toHaveBeenCalled();
- });
-
- it("keeps marked whitespace through marks, line breaks, undo, redo, and remount", async () => {
- const original = ` Bold tail Second\t `;
- 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("First ");
-
- act(() => editor.view.dispatch(editor.state.tr.insertText(" \t", 1)));
- const saved = ` \tFirst `;
- expect(editor.getHTML()).toBe(saved);
- expect(onChange).toHaveBeenLastCalledWith(saved);
-
- act(() => editor.commands.undo());
- expect(editor.getHTML()).toBe("First ");
- 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("Start ");
- act(() => {
- editor.commands.selectAll();
- editor.view.pasteHTML(" Pasted\ttext next ");
- });
-
- expect(editor.getHTML()).toBe(` Pasted\ttext next `);
- 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("Start ");
- act(() => {
- editor.commands.selectAll();
- editor.view.pasteText(" Plain\ttext \n\tSecond ");
- });
-
- expect(editor.getHTML()).toBe(` Plain\ttext \tSecond `);
- 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("First ");
- act(() => {
- editor.commands.setTextSelection(3);
- editor.commands.enter();
- });
- expect(editor.getHTML()).toBe(`Fi rst `);
-
- act(() => {
- editor.commands.setTextSelection(3);
- editor.commands.setHardBreak();
- });
- expect(editor.getHTML()).toBe(`Fi
rst `);
- });
-
- it("preserves marked whitespace through paragraph, heading, and list transitions", async () => {
- const html = ` First\t `;
- const { editor } = await input(html);
-
- act(() => editor.commands.toggleHeading({ level: 2 }));
- expect(editor.getHTML()).toBe(` First\t `);
- act(() => editor.commands.setParagraph());
- expect(editor.getHTML()).toBe(`${html}`);
- act(() => editor.commands.toggleBulletList());
- expect(editor.getHTML()).toBe(``);
- act(() => editor.commands.toggleBulletList());
- expect(editor.getHTML()).toBe(`${html}`);
- });
-
- it.each(["bullet", "ordered"])("keeps heading whitespace through %s list conversion and reload", async (list) => {
- const { editor } = await input(` Literal\t `);
- const toggle = () => (list === "bullet" ? editor.commands.toggleBulletList() : editor.commands.toggleOrderedList());
- act(toggle);
- const saved = editor.getHTML();
- expect(saved).toContain(` Literal\t `);
- 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(` Literal\t `);
- });
-
- it("keeps structural markers local to previously marked blocks, including undo and redo", async () => {
- const html = ` First\t Legacy `;
- const { editor } = await input(html);
- act(() => {
- editor.commands.selectAll();
- editor.commands.toggleHeading({ level: 2 });
- });
- const saved = ` First\t Legacy`;
- 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(` First\t Second\t `);
- act(() => {
- editor.commands.selectAll();
- editor.commands.toggleHeading({ level: 2 });
- });
- const headings = ` First\t Second\t `;
- 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(` First\t Second\t `);
- act(() => editor.commands.setContent(saved, { emitUpdate: false }));
- expect(editor.getHTML()).toBe(saved);
- });
-
- it.each([
- [' Text\tX ', ` Text\tX `],
- [" Text\tX ", ` Text\tX `],
- [" Text\tX ", ` Text\tX `],
- [' Text\tX ', ` Text\tX `],
- [" Text\tX ", ` Text\tX `],
- [
- "",
- ` First\t Second\t `,
- ],
- ["", ``],
- ["- Text\tX
", ` Text\tX `],
- [" Text\tX ", ` Text\tX `],
- [" First Second\t ", ` First Second\t `],
- ])("preserves supported clipboard text and baseline blocks: %s", async (html, expected) => {
- const { editor } = await input("Start ");
- 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("Start ");
- act(() => {
- editor.commands.selectAll();
- editor.view.pasteHTML("");
- });
- expect(editor.getHTML()).toContain(` Cell\ttext `);
- });
-
- it.each(["td", "th"])("preserves whitespace in pasted bare %s table cells", async (cellTag) => {
- const { editor } = await input("Start ");
- act(() => {
- editor.commands.selectAll();
- editor.view.pasteHTML(`<${cellTag}> Cell\ttext ${cellTag}> `);
- });
-
- expect(editor.getHTML()).toContain(` Cell\ttext `);
- expect(editor.getText()).toContain(" Cell\ttext ");
- });
-
- it.each([
- "",
- '',
- ])("rejects unsafe table paste before DOM normalization: %s", async (html) => {
- const { editor, onChange } = await input("Start ");
- act(() => {
- editor.commands.selectAll();
- editor.view.pasteHTML(html);
- });
- expect(editor.getHTML()).toBe("Start ");
- expect(onChange).not.toHaveBeenCalled();
- });
-
- it("keeps marked paragraphs editable inside supported table cells", async () => {
- const html = ``;
- 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(" Legacy\ttext ", onChange, (created) => (editor = created)));
- await waitFor(() => expect(editor).toBeDefined());
- if (!editor) throw new Error("Editor did not initialize");
- expect(editor.getHTML()).toBe("Legacy text ");
- expect(onChange).not.toHaveBeenCalled();
-
- rendered.rerender(richInput("\n Updated\tlegacy \n", onChange, () => {}));
- await waitFor(() => expect(editor?.getHTML()).toBe("Updated legacy "));
- 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 = `\u3000中\u00a0文 \t `;
- const { editor } = await input(html);
- expect(editor.getHTML()).toBe(
- locale === "en"
- ? `\u3000中 文 \t `
- : `\u3000中 文 \t `,
- );
- expect(editor.getText()).toBe("\u3000中\u00a0文 \t ");
- } finally {
- act(() => i18n.loadAndActivate({ locale: "en", messages: {} }));
- }
- });
-});
diff --git a/apps/web/src/components/input/rich-text-extensions.ts b/apps/web/src/components/input/rich-text-extensions.ts
new file mode 100644
index 000000000..d85265f34
--- /dev/null
+++ b/apps/web/src/components/input/rich-text-extensions.ts
@@ -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;
+ styles?: ReadonlyMap;
+ 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): 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(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([
+ ["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([
+ ["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(/]*>[\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(/|$)/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,
+];
diff --git a/apps/web/src/features/resume/editor/write/rich-text-editor.tsx b/apps/web/src/features/resume/editor/write/rich-text-editor.tsx
index 9ce607f91..3bfbd9aac 100644
--- a/apps/web/src/features/resume/editor/write/rich-text-editor.tsx
+++ b/apps/web/src/features/resume/editor/write/rich-text-editor.tsx
@@ -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,
diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/notes.test.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/notes.test.tsx
index ebbd1c4af..71bc85d17 100644
--- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/notes.test.tsx
+++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/notes.test.tsx
@@ -23,8 +23,8 @@ type RichInputProps = {
vi.mock("../shared/section-base", () => ({
SectionBase: ({ children }: SectionBaseProps) => {children} ,
}));
-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 ;
diff --git a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/notes.tsx b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/notes.tsx
index 9b27f77a9..47cd54a3e 100644
--- a/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/notes.tsx
+++ b/apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/notes.tsx
@@ -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() {
Keep private notes about this resume here. Nobody else can see them.
-
+
For example, note which companies you sent this resume to, or links to the job descriptions.
diff --git a/packages/ui/src/components/toggle.test.tsx b/packages/ui/src/components/toggle.test.tsx
deleted file mode 100644
index ebd756e6e..000000000
--- a/packages/ui/src/components/toggle.test.tsx
+++ /dev/null
@@ -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(on);
- expect(screen.getByTestId("t")).toHaveAttribute("data-slot", "toggle");
- });
-
- it("starts unpressed by default", () => {
- render(on);
- expect(screen.getByTestId("t").getAttribute("aria-pressed")).toBe("false");
- });
-
- it("can be controlled via pressed prop", () => {
- render(
-
- on
- ,
- );
- expect(screen.getByTestId("t").getAttribute("aria-pressed")).toBe("true");
- });
-
- it("calls onPressedChange when clicked", async () => {
- const onChange = vi.fn();
- render(
-
- x
- ,
- );
- await userEvent.click(screen.getByTestId("t"));
- expect(onChange).toHaveBeenCalledWith(true, expect.anything());
- });
-
- it.each([["default"], ["outline"]] as const)("renders variant=%s", (variant) => {
- render(
-
- x
- ,
- );
- expect(screen.getByTestId("t")).toBeInTheDocument();
- });
-
- it.each([["default"], ["sm"], ["lg"]] as const)("renders size=%s", (size) => {
- render(
-
- x
- ,
- );
- expect(screen.getByTestId("t")).toBeInTheDocument();
- });
-
- it("merges custom className", () => {
- render(
-
- x
- ,
- );
- expect(screen.getByTestId("t")).toHaveClass("my-custom");
- });
-});
diff --git a/packages/ui/src/components/toggle.tsx b/packages/ui/src/components/toggle.tsx
deleted file mode 100644
index 94b750dbf..000000000
--- a/packages/ui/src/components/toggle.tsx
+++ /dev/null
@@ -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) {
- return ;
-}
-
-export { Toggle, toggleVariants };
|