Skip to content

Commit 657235a

Browse files
committed
feat(rich-markdown-editor): add highlight (==mark==) support
Adds a highlight mark rendered as <mark> and serialized to/from ==text== (Pandoc/Obsidian syntax). A custom inline tokenizer parses ==text== (inner text parsed as inline markdown so nested marks like ==**bold**== survive; the body allows a lone `=` so ==a=b== round-trips). `==` cannot be encoded in the delimiter, so an appendTransaction guard strips the mark from any text that ends up containing `==` (e.g. a toolbar highlight over a==b), keeping the text and never emitting the corrupting ==a==b==. Comparison operators (x == y) stay literal. Wired with an input rule, paste rule, Mod-Shift-H, a bubble-menu button, and themed <mark> styling. Also locks in mark-stacking round-trips across contexts.
1 parent 3c5024f commit 657235a

5 files changed

Lines changed: 197 additions & 0 deletions

File tree

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/extensions.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
import { Markdown } from '@tiptap/markdown'
1313
import StarterKit from '@tiptap/starter-kit'
1414
import { MarkdownCodeBlock } from './code-block'
15+
import { Highlight } from './highlight'
1516
import { MarkdownImage } from './image'
1617
import { MarkdownLinkInputRule } from './link-input-rule'
1718
import { MarkdownMention } from './mention/mention-node'
@@ -130,6 +131,7 @@ export function createMarkdownContentExtensions(nodeViews: ContentNodeViews = {}
130131
}),
131132
BlockSafeParagraph,
132133
InlineCode,
134+
Highlight,
133135
codeBlock,
134136
(nodeViews.image ?? MarkdownImage).configure({ allowBase64: true }),
135137
nodeViews.mention ?? MarkdownMention,
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import type {
2+
JSONContent,
3+
MarkdownParseHelpers,
4+
MarkdownRendererHelpers,
5+
MarkdownToken,
6+
} from '@tiptap/core'
7+
import { Mark, markInputRule, markPasteRule, mergeAttributes } from '@tiptap/core'
8+
import type { Transaction } from '@tiptap/pm/state'
9+
import { Plugin } from '@tiptap/pm/state'
10+
11+
/**
12+
* `==text==` with non-space edges — the Pandoc/Obsidian highlight syntax. The body allows a lone `=`
13+
* (`=(?!=)`) but never `==`, so a highlight over text containing `=` (e.g. `==a=b==`) round-trips while
14+
* the closing `==` still terminates the run.
15+
*/
16+
const HIGHLIGHT_BODY = String.raw`(?:[^=]|=(?!=))+?`
17+
const HIGHLIGHT_TOKEN = new RegExp(String.raw`^==(?!\s)(${HIGHLIGHT_BODY})(?<!\s)==`)
18+
/** Input/paste rule form (anchored on a preceding boundary) so typing `==x==` toggles the mark. */
19+
const HIGHLIGHT_INPUT = new RegExp(String.raw`(?:^|\s)(==(?!\s)(${HIGHLIGHT_BODY})(?<!\s)==)$`)
20+
const HIGHLIGHT_PASTE = new RegExp(String.raw`(?:^|\s)(==(?!\s)(${HIGHLIGHT_BODY})(?<!\s)==)`, 'g')
21+
22+
/**
23+
* Highlight mark (`<mark>`), serialized to and parsed from `==text==`. CommonMark/`marked` has no
24+
* highlight token, so this registers a custom inline tokenizer (parsing the inner text as inline
25+
* markdown so nested marks like `==**bold**==` survive) and a `renderMarkdown` that wraps the content
26+
* in `==`. Mirrors the verbatim-node registration pattern in `./raw-markdown-snippet`.
27+
*
28+
* The tokenizer's `start` returns the index of the next `==` (a plain string search, not the
29+
* `createLexer()`-calling form the `RawHtmlBlock` caveat warns against) so `marked` breaks its inline
30+
* text run there and gives this tokenizer a chance mid-line — `=` is not a default break char like `[`.
31+
*
32+
* A lone `=` is allowed inside a highlight (so `==a=b==` round-trips), but `==` cannot be encoded in the
33+
* `==…==` delimiter (emitting `==a==b==` would split the highlight and corrupt the text on reload). The
34+
* tokenizer/input rules already exclude `==`; an `appendTransaction` guard removes the mark from any
35+
* text that ends up containing `==` (e.g. a toolbar highlight over `a==b`), so the doc never holds an
36+
* unrepresentable highlight and serialization stays lossless.
37+
*/
38+
export const Highlight = Mark.create({
39+
name: 'highlight',
40+
41+
parseHTML() {
42+
return [{ tag: 'mark' }]
43+
},
44+
45+
renderHTML({ HTMLAttributes }) {
46+
return ['mark', mergeAttributes(HTMLAttributes), 0]
47+
},
48+
49+
addInputRules() {
50+
return [markInputRule({ find: HIGHLIGHT_INPUT, type: this.type })]
51+
},
52+
53+
addPasteRules() {
54+
return [markPasteRule({ find: HIGHLIGHT_PASTE, type: this.type })]
55+
},
56+
57+
addKeyboardShortcuts() {
58+
return { 'Mod-Shift-h': () => this.editor.commands.toggleMark(this.name) }
59+
},
60+
61+
markdownTokenName: 'highlight',
62+
markdownTokenizer: {
63+
name: 'highlight',
64+
level: 'inline' as const,
65+
start: (src: string) => src.indexOf('=='),
66+
tokenize(src: string): MarkdownToken | undefined {
67+
const match = HIGHLIGHT_TOKEN.exec(src)
68+
if (!match) return undefined
69+
return { type: 'highlight', raw: match[0], text: match[1] }
70+
},
71+
},
72+
73+
parseMarkdown(token: MarkdownToken, helpers: MarkdownParseHelpers) {
74+
const inner = token.text ?? ''
75+
const tokens = helpers.tokenizeInline?.(inner)
76+
const content = tokens ? helpers.parseInline(tokens) : [{ type: 'text', text: inner }]
77+
return { mark: 'highlight', content }
78+
},
79+
80+
renderMarkdown(node: JSONContent, h: MarkdownRendererHelpers) {
81+
return `==${h.renderChildren(node.content ?? [])}==`
82+
},
83+
84+
addProseMirrorPlugins() {
85+
const markType = this.type
86+
return [
87+
new Plugin({
88+
appendTransaction(transactions, _oldState, newState) {
89+
if (!transactions.some((transaction) => transaction.docChanged)) return null
90+
let tr: Transaction | null = null
91+
newState.doc.descendants((node, pos) => {
92+
if (
93+
node.isText &&
94+
node.text?.includes('==') &&
95+
node.marks.some((mark) => mark.type === markType)
96+
) {
97+
tr = (tr ?? newState.tr).removeMark(pos, pos + node.nodeSize, markType)
98+
}
99+
})
100+
return tr
101+
},
102+
}),
103+
]
104+
},
105+
})

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/menus/bubble-menu.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
Code,
1111
Heading1,
1212
Heading2,
13+
Highlighter,
1314
Italic,
1415
Link as LinkIcon,
1516
List,
@@ -76,6 +77,7 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen
7677
bold: e.isActive('bold'),
7778
italic: e.isActive('italic'),
7879
strike: e.isActive('strike'),
80+
highlight: e.isActive('highlight'),
7981
code: e.isActive('code'),
8082
link: e.isActive('link'),
8183
heading1: e.isActive('heading', { level: 1 }),
@@ -262,6 +264,13 @@ export function EditorBubbleMenu({ editor, scrollContainerRef }: EditorBubbleMen
262264
isActive={active.strike}
263265
onClick={() => editor.chain().focus().toggleStrike().run()}
264266
/>
267+
<ToolbarButton
268+
icon={Highlighter}
269+
label='Highlight'
270+
shortcut='⌘⇧H'
271+
isActive={active.highlight}
272+
onClick={() => editor.chain().focus().toggleMark('highlight').run()}
273+
/>
265274
<ToolbarButton
266275
icon={Code}
267276
label='Code'

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.css

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -397,6 +397,20 @@
397397
pointer-events: none;
398398
}
399399

400+
/*
401+
* Highlight mark (`==text==`). An opacity-based amber tint so it reads on both light and dark
402+
* surfaces without a theme override; `color: inherit` keeps the text at the surrounding body color
403+
* and `box-decoration-break: clone` keeps the tint clean where a highlight wraps across lines.
404+
*/
405+
.rich-markdown-prose mark {
406+
background-color: rgba(255, 212, 0, 0.4);
407+
color: inherit;
408+
border-radius: 2px;
409+
padding: 0 0.1em;
410+
box-decoration-break: clone;
411+
-webkit-box-decoration-break: clone;
412+
}
413+
400414
/*
401415
* Field variant (modal embed): match the surrounding chip fields' typography exactly —
402416
* body at the chip `text-sm` (14px) scale and the placeholder at `--text-muted` (not the

apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/round-trip.test.ts

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,23 @@ describe('editor markdown round-trip', () => {
158158
'bold code': '**`x`**',
159159
'heading strike code': '# ~~`x`~~',
160160
'table with pipe': '| x \\| y | 2 |\n| --- | --- |\n| a | b |',
161+
'bold italic nested': '**bold _italic_ word**',
162+
'strike bold nested': '~~**struck bold**~~',
163+
'bold code inline': '**bold `code` here**',
164+
'triple nested marks': '*i **b ~~s~~** i*',
165+
'all marks in heading': '# **b** ~~s~~ *i* `c`',
166+
'marks in bullet': '- **a** ~~b~~ `c`',
167+
'marks in quote': '> **a** ~~b~~ *c*',
168+
'nested list marks': '- **a**\n - ~~b~~\n - *c*',
169+
'bold link': '[**bold link**](https://x.com)',
170+
'link inside bold': '**see [x](https://x.com)**',
171+
'table with marks': '| **b** | ~~s~~ | `c` |\n| --- | --- | --- |\n| *i* | a | b |',
172+
'bold across code boundary': '**a** `b` **c**',
173+
highlight: 'a ==marked== word',
174+
'highlight in heading': '# a ==mark== b',
175+
'highlight nested in bold': '**bold ==mark== here**',
176+
'highlight in list': '- ==a== item',
177+
'highlight with interior equals': 'x ==a=b== y',
161178
}
162179

163180
for (const [name, input] of Object.entries(cases)) {
@@ -434,3 +451,53 @@ describe('consecutive empty paragraphs', () => {
434451
}
435452
)
436453
})
454+
455+
describe('highlight ==mark==', () => {
456+
function markPresent(src: string): boolean {
457+
editor = new Editor({ extensions: createMarkdownContentExtensions() })
458+
editor.commands.setContent(src, { contentType: 'markdown' })
459+
const has = JSON.stringify(editor.getJSON()).includes('"type":"highlight"')
460+
editor.destroy()
461+
editor = null
462+
return has
463+
}
464+
465+
it('parses ==text== into a highlight mark, including mid-line and in headings', () => {
466+
expect(markPresent('a ==marked== word')).toBe(true)
467+
expect(markPresent('# a ==mark== b')).toBe(true)
468+
expect(markPresent('**bold ==mark== here**')).toBe(true)
469+
})
470+
471+
it('parses a highlight body containing a lone `=` (so ==a=b== round-trips)', () => {
472+
expect(markPresent('x ==a=b== y')).toBe(true)
473+
})
474+
475+
it('strips a highlight whose text contains `==` (unrepresentable), keeping the text', () => {
476+
editor = new Editor({ extensions: createMarkdownContentExtensions() })
477+
editor.commands.setContent('x a==b y', { contentType: 'markdown' })
478+
let from = -1
479+
let to = -1
480+
editor.state.doc.descendants((node, pos) => {
481+
if (node.isText) {
482+
const i = node.text?.indexOf('a==b') ?? -1
483+
if (i >= 0) {
484+
from = pos + i
485+
to = from + 4
486+
}
487+
}
488+
})
489+
editor.commands.setTextSelection({ from, to })
490+
editor.commands.toggleMark('highlight')
491+
const md = postProcessSerializedMarkdown(editor.getMarkdown())
492+
expect(JSON.stringify(editor.getJSON())).not.toContain('"type":"highlight"')
493+
expect(md).not.toContain('==a==b==')
494+
expect(editor.getText().trim()).toBe('x a==b y')
495+
editor.destroy()
496+
editor = null
497+
})
498+
499+
it('leaves comparison / spaced == operators as literal text', () => {
500+
expect(markPresent('if x == y then z')).toBe(false)
501+
expect(markPresent('a == b == c')).toBe(false)
502+
})
503+
})

0 commit comments

Comments
 (0)