From 22dbbdc547f2ccb3dee2a6c25b6852230d3e2a5f Mon Sep 17 00:00:00 2001 From: Amruth Pillai Date: Tue, 29 Sep 2026 10:41:32 +0200 Subject: [PATCH] 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. --- .../input/rich-input.indent.test.tsx | 237 ---- .../input/rich-input.table.test.tsx | 346 ----- .../src/components/input/rich-input.test.ts | 45 - apps/web/src/components/input/rich-input.tsx | 1231 ----------------- .../src/components/input/rich-input.utils.ts | 7 - .../input/rich-input.whitespace.test.tsx | 295 ---- .../components/input/rich-text-extensions.ts | 470 +++++++ .../resume/editor/write/rich-text-editor.tsx | 4 +- .../-sidebar/right/sections/notes.test.tsx | 4 +- .../-sidebar/right/sections/notes.tsx | 5 +- packages/ui/src/components/toggle.test.tsx | 63 - packages/ui/src/components/toggle.tsx | 36 - 12 files changed, 477 insertions(+), 2266 deletions(-) delete mode 100644 apps/web/src/components/input/rich-input.indent.test.tsx delete mode 100644 apps/web/src/components/input/rich-input.table.test.tsx delete mode 100644 apps/web/src/components/input/rich-input.test.ts delete mode 100644 apps/web/src/components/input/rich-input.tsx delete mode 100644 apps/web/src/components/input/rich-input.utils.ts delete mode 100644 apps/web/src/components/input/rich-input.whitespace.test.tsx create mode 100644 apps/web/src/components/input/rich-text-extensions.ts delete mode 100644 packages/ui/src/components/toggle.test.tsx delete mode 100644 packages/ui/src/components/toggle.tsx diff --git a/apps/web/src/components/input/rich-input.indent.test.tsx b/apps/web/src/components/input/rich-input.indent.test.tsx deleted file mode 100644 index 46268108e..000000000 --- a/apps/web/src/components/input/rich-input.indent.test.tsx +++ /dev/null @@ -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( - - - { - editor = event.editor; - }} - /> - - , - ); - 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("

First

"); - expect(screen.getByTitle("Decrease indent")).toBeDisabled(); - expect(screen.getByTitle("Increase indent")).toBeEnabled(); - increase(); - const html = '

First

'; - 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("

First

"); - }); - - 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

Last

`; - 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('

First

Second

Third

'); - 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('

First

Second

'); - 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("

First

Second

"); - act(() => { - editor.commands.setTextSelection(2); - }); - increase(); - expect(editor.getHTML()).toContain( - '

First

Second

', - ); - }); - - it("disables indent at eight without an update", async () => { - const { editor, onChange } = await input('

First

'); - 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}>
  • First

  • Second

  • Last

    `); - act(() => { - editor.commands.setTextSelection(12); - }); - increase(); - expect(editor.getHTML()).toContain(`<${tag}>
  • Second

  • `); - expect(editor.getHTML()).not.toContain("data-indent"); - decrease(); - expect(editor.getHTML()).toBe(`<${tag}>
  • First

  • Second

  • Last

    `); - }); - - it.each(["toggleBulletList", "toggleOrderedList"] as const)( - "normalizes indentation when converting to a list with %s", - async (command) => { - const { editor } = await input('

    First

    '); - 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("
  • "); - }, - ); - - it("clears paragraph offsets when typing the bullet-list shortcut", async () => { - const { editor } = await input('

    First

    '); - act(() => { - editor.commands.setTextSelection(1); - editor.view.someProp("handleTextInput", (handle) => - handle(editor.view, 1, 1, "- ", () => editor.state.tr.insertText("- ", 1)), - ); - }); - expect(editor.getHTML()).toContain(""); - expect(editor.getHTML()).not.toContain("data-indent"); - }); - - it("clears imported paragraph offsets inside lists", async () => { - const { editor } = await input(''); - 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('

    First

    '); - act(() => { - editor.commands.setNode("heading", { level: 2, indent: 0 }); - }); - expect(editor.getHTML()).toContain("

    First

    "); - }); - - it("characterizes literal leading spaces and tabs in saved HTML", async () => { - const { editor } = await input("

    First

    \tSecond

    "); - expect(editor.getHTML()).toBe("

    First

    Second

    "); - }); - - it("does not mutate during capability checks or beyond either bound", async () => { - const { editor, onChange } = await input("

    First

    "); - 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("

    First

    Third

    "); - act(() => { - editor.commands.selectAll(); - }); - increase(); - expect(editor.getHTML()).toBe( - '

    First

    Third

    ', - ); - }); - - it("renders logical margins in RTL without physical left/right margins", async () => { - i18n.loadAndActivate({ locale: "he", messages: {} }); - try { - const { editor } = await input('

    First

    '); - 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("

    First

    "); - act(() => { - editor.view.dispatch(editor.state.tr.insertText(" \t", 1)); - }); - const saved = editor.getHTML(); - expect(saved).toBe('

    \tFirst

    '); - expect(onChange).toHaveBeenLastCalledWith(saved); - act(() => { - editor.commands.setContent(saved, { emitUpdate: false }); - }); - expect(editor.getHTML()).toBe(saved); - }); -}); diff --git a/apps/web/src/components/input/rich-input.table.test.tsx b/apps/web/src/components/input/rich-input.table.test.tsx deleted file mode 100644 index 15c13c336..000000000 --- a/apps/web/src/components/input/rich-input.table.test.tsx +++ /dev/null @@ -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 = `
    AlphaBetaGamma
    DeltaEpsilonZeta
    `; - -const complexTable = `

    Lead cell

    Second paragraph

    Side

    Tail

    `; - -const semanticTable = `

    Inside Mark Dark

    1. Two
    `; - -const unsupportedTable = `
    Original
    `; - -const unsupportedTables = [ - ["legacy table attributes", unsupportedTable], - ["truncated table marker", "Open"], - ["truncated table after a complete table", "
    Complete
    Inside
    `, - ], - [ - "multiple table bodies", - "
    First
    Second
    ", - ], - ["empty table body", "
    "], - ["non-row table body child", "
    Inside
    "], - ["non-cell table row child", "
    Inside
    "], - ["mismatched row widths", "
    One
    TwoThree
    "], - [ - "non-rectangular column span", - '
    Wide
    Wider
    ', - ], - ["row span beyond table bounds", '
    Inside
    '], - [ - "row span collision", - '
    TallSide
    Overlap
    ', - ], - ["browser-repaired malformed markup", "
    Broken
    "], - [ - "unrepresented attributes on supported descendants", - `

    Inside

    `, - ], - ["invalid table styles", '
    Inside
    '], - ["invalid row styles", '
    Inside
    '], - ["invalid cell alignment", '
    Inside
    '], - [ - "invalid cell style alignment", - '
    Inside
    ', - ], - ["invalid column widths", '
    Inside
    '], - ["mismatched column widths", '
    Inside
    '], - ["invalid column span", '
    Inside
    '], - ["invalid row span", '
    Inside
    '], - [ - "invalid paragraph alignment", - '

    Inside

    ', - ], - ["invalid indent level", '

    Inside

    '], - [ - "mismatched indent margin", - '

    Inside

    ', - ], - [ - "physical indent margin", - '

    Inside

    ', - ], - [ - "invalid ordered-list start", - '
    1. Inside
    ', - ], - ["empty blockquote", "
    "], - ["whitespace-only blockquote", "
    "], - ["empty list", "
      "], - [ - "anchor without href", - '

      Inside

      ', - ], - [ - "unsafe link URI", - '

      Inside

      ', - ], - [ - "unsafe link URI with ignored whitespace", - '

      Inside

      ', - ], - [ - "invalid text color", - '

      Inside

      ', - ], - [ - "invalid highlight color", - '

      Inside

      ', - ], - [ - "mismatched highlight color", - '

      Inside

      ', - ], -] 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 ( - - - - - {value} - { - 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 = - '

      Inside

      '; - 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("
      • First

      "); - 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 */} -
      -
      - - - -
      - - - Fullscreen Editor - - - - - Edit content in fullscreen mode - - -
      - {editorElement} -
      -
      - - ); - } - - 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 `; - 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(`
      • First\t

      `); - 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
      ", - `

      First\t

      Second\t

      `, - ], - ["
      • Text\tX
      ", `
      • Text\tX

      `], - ["
      1. Text\tX
      ", `
      1. 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("

      Cell\ttext

      "); - }); - 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
      `); - }); - - expect(editor.getHTML()).toContain(`

      Cell\ttext

      `); - expect(editor.getText()).toContain(" Cell\ttext "); - }); - - it.each([ - "

      Cell\t

      ", - '

      Cell\t

      ', - ])("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 = `

      Cell\ttext

      `; - 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