Files
multica/packages/views/editor/extensions/highlight.test.ts
Jiayuan Zhang 0d51614c9c feat(editor): text highlight (==text==) in description & comments [MUL-2934] (#3661)
* feat(editor): support text highlight (==text==) in description & comments

Adds a single-color (yellow) text highlight mark to the shared rich-text
editor, round-tripped through stored Markdown as ==text==.

- HighlightExtension: @tiptap/extension-highlight + @tiptap/markdown hooks
  (markdownTokenizer/parseMarkdown/renderMarkdown) so ==text== <-> <mark>
  round-trips; inner inline formatting preserved via inlineTokens.
- Bubble menu: highlight toggle button (Mod-Shift-H), i18n in 4 locales.
- Read-only renderer: highlightToHtml lowers ==text== -> <mark> (skips code
  and math); rehype-sanitize schema whitelists <mark>. Nested Markdown inside
  a highlight still parses via the existing rehype-raw step.
- prose.css: single yellow <mark> style, legible in light/dark.

Pinned @tiptap/extension-highlight to exact 3.22.1 to match @tiptap/core
(>=3.23 expects a getStyleProperty export core 3.22.1 doesn't have).

Web/desktop only. Mobile (native md4c, no == syntax, no custom renderers)
is tracked as a follow-up. MUL-2934.

Tests: editor round-trip (cross-process serialization protocol), readonly
<mark> rendering + sanitize, and the ==->mark transform incl. code-skip.

Co-authored-by: multica-agent <github@multica.ai>

* fix(editor): align highlight boundary rules across editor & readonly

Addresses two boundary bugs from review (PR #3661):

1. A == inside inline code/math could close a highlight when the opening
   == was outside the literal span (e.g. ==a `b==c` d== wrongly became
   <mark>a `b</mark>c` d==). Both the editor tokenizer's lazy regex and the
   readonly transform only guarded the opening fence, not the closing one.
2. The readonly transform matched across blank lines (==a\n\nb==) while the
   editor lexes those as two literal paragraphs — a storage↔editor↔readonly
   mismatch.

Fix: extract one shared matcher (utils/highlight-match.ts) used by BOTH the
editor tokenizer and the readonly lowering, so the rules can't drift. It skips
fences that fall inside code/math literal ranges (open or close) and caps the
inner span at the first blank line.

Tests: shared-matcher unit tests + both repros covered on the editor
(round-trip/HTML) and readonly (transform + rendered DOM) sides.

Co-authored-by: multica-agent <github@multica.ai>

* fix(editor): handle CRLF in highlight blank-line boundary

BLANK_LINE_RE only matched LF, so a CRLF blank line (==a\r\n\r\nb==) was not
recognized as a block boundary and got highlighted. Widen to \r?\n[ \t]*\r?\n.

Tests: CRLF blank-line (no highlight) + CRLF soft-break (still highlights) on
the matcher, readonly transform, and editor sides.

Co-authored-by: multica-agent <github@multica.ai>

---------

Co-authored-by: Lambda <lambda@multica.ai>
Co-authored-by: multica-agent <github@multica.ai>
2026-06-02 17:24:55 +02:00

95 lines
3.1 KiB
TypeScript

import { describe, it, expect, afterEach } from "vitest";
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "@tiptap/markdown";
import { HighlightExtension } from "./highlight";
let editor: Editor | null = null;
function makeEditor(markdown: string): Editor {
const element = document.createElement("div");
document.body.appendChild(element);
editor = new Editor({
element,
extensions: [StarterKit, Markdown, HighlightExtension],
});
editor.commands.setContent(markdown, { contentType: "markdown" });
return editor;
}
/** Round-trip: load markdown → serialize back to markdown. */
function roundTrip(markdown: string): string {
return makeEditor(markdown).getMarkdown().trim();
}
afterEach(() => {
editor?.destroy();
editor = null;
});
describe("HighlightExtension — markdown serialization (cross-process protocol)", () => {
it("round-trips a basic highlight as ==text==", () => {
expect(roundTrip("==hi==")).toBe("==hi==");
});
it("round-trips a highlight embedded in a sentence", () => {
expect(roundTrip("before ==mid== after")).toBe("before ==mid== after");
});
it("parses ==text== into a highlight mark (<mark> in HTML)", () => {
const html = makeEditor("==hi==").getHTML();
expect(html).toContain("<mark");
expect(html).toContain("hi");
});
it("preserves inner formatting inside a highlight", () => {
// bold nested inside highlight must survive the round-trip
expect(roundTrip("==**bold**==")).toBe("==**bold**==");
});
it("serializes a highlight applied via the toggleHighlight command", () => {
const e = makeEditor("hello");
e.commands.selectAll();
e.commands.toggleHighlight();
expect(e.getMarkdown().trim()).toBe("==hello==");
});
it("leaves a lone == (comparison) untouched", () => {
expect(roundTrip("if a == b")).toBe("if a == b");
});
it("does not treat == inside inline code as a highlight", () => {
expect(roundTrip("`a ==b== c`")).toBe("`a ==b== c`");
});
// Boundary regressions (Emacs review, PR #3661).
it("does not let a == inside inline code close the highlight", () => {
const e = makeEditor("==a `b==c` d==");
const html = e.getHTML();
// whole span highlighted; inner `==` stays inside an inline <code>
expect(html).toContain("<mark");
expect(html).toContain("<code");
expect(html).toContain("b==c");
// must NOT have stopped at the code's `==`
expect(html).not.toMatch(/<mark[^>]*>a\s*$/);
expect(e.getMarkdown().trim()).toBe("==a `b==c` d==");
});
it("does not highlight across a blank line (two literal paragraphs)", () => {
const e = makeEditor("==a\n\nb==");
expect(e.getHTML()).not.toContain("<mark");
expect(e.getMarkdown().trim()).toBe("==a\n\nb==");
});
it("does not highlight across a CRLF blank line", () => {
const e = makeEditor("==a\r\n\r\nb==");
expect(e.getHTML()).not.toContain("<mark");
});
it("still highlights across a CRLF soft line break", () => {
const e = makeEditor("==a\r\nb==");
expect(e.getHTML()).toContain("<mark");
});
});