mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-25 12:05:06 +02:00
* 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>
46 lines
1.8 KiB
TypeScript
46 lines
1.8 KiB
TypeScript
/**
|
|
* highlight-markdown — read-only `==text==` → `<mark>text</mark>` transform.
|
|
*
|
|
* The editor (Tiptap) parses `==text==` natively via the HighlightExtension's
|
|
* markdownTokenizer. The read-only surface uses react-markdown, which has no
|
|
* notion of `==` highlight syntax, so we lower it to a raw `<mark>` element here.
|
|
* readonly-content.tsx already runs `rehype-raw` (so the raw `<mark>` becomes a
|
|
* real element) and whitelists `mark` in its sanitize schema. Because the inner
|
|
* text is left untouched, nested Markdown inside a highlight (e.g. `==**bold**==`
|
|
* or an inline `` `code` ``) is still parsed by remark — matching the editor.
|
|
*
|
|
* The matching rules live in highlight-match.ts and are shared with the editor
|
|
* tokenizer, so the storage ↔ editor ↔ read-only boundary can never drift:
|
|
* fences may not sit inside code/math, and a highlight may not cross a blank
|
|
* line / block boundary.
|
|
*/
|
|
|
|
import { findLiteralRanges, matchHighlightAt } from "./highlight-match";
|
|
|
|
/**
|
|
* Lower `==text==` highlight syntax to raw `<mark>` for the react-markdown
|
|
* read-only pipeline. No-op when the text contains no `==`.
|
|
*/
|
|
export function highlightToHtml(markdown: string): string {
|
|
if (!markdown.includes("==")) return markdown;
|
|
const ranges = findLiteralRanges(markdown);
|
|
|
|
let result = "";
|
|
let cursor = 0;
|
|
let i = markdown.indexOf("==");
|
|
while (i !== -1) {
|
|
const match = matchHighlightAt(markdown, i, ranges);
|
|
if (match) {
|
|
result += markdown.slice(cursor, i);
|
|
result += `<mark>${match.inner}</mark>`;
|
|
cursor = match.end;
|
|
i = markdown.indexOf("==", match.end);
|
|
} else {
|
|
// Not a highlight here — advance past this `==` and keep looking.
|
|
i = markdown.indexOf("==", i + 2);
|
|
}
|
|
}
|
|
result += markdown.slice(cursor);
|
|
return result;
|
|
}
|