Skip to content

Commit 55fce05

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) with a renderMarkdown that wraps in ==; comparison operators (x == y, a == b == c) 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 (bold/italic/strike/code/highlight nested across paragraphs, headings, lists, quotes, links, and table cells) as regression tests.
1 parent 2cc6351 commit 55fce05

5 files changed

Lines changed: 133 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: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
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+
9+
/** `==text==` with non-space edges and no interior `=` — the Pandoc/Obsidian highlight syntax. */
10+
const HIGHLIGHT_TOKEN = /^==(?!\s)([^=]+?)(?<!\s)==/
11+
/** Input/paste rule form (anchored on a preceding boundary) so typing `==x==` toggles the mark. */
12+
const HIGHLIGHT_INPUT = /(?:^|\s)(==(?!\s)([^=]+?)(?<!\s)==)$/
13+
const HIGHLIGHT_PASTE = /(?:^|\s)(==(?!\s)([^=]+?)(?<!\s)==)/g
14+
15+
/**
16+
* Highlight mark (`<mark>`), serialized to and parsed from `==text==`. CommonMark/`marked` has no
17+
* highlight token, so this registers a custom inline tokenizer (parsing the inner text as inline
18+
* markdown so nested marks like `==**bold**==` survive) and a `renderMarkdown` that wraps the content
19+
* in `==`. Mirrors the verbatim-node registration pattern in `./raw-markdown-snippet`.
20+
*
21+
* The tokenizer's `start` returns the index of the next `==` (a plain string search, not the
22+
* `createLexer()`-calling form the `RawHtmlBlock` caveat warns against) so `marked` breaks its inline
23+
* text run there and gives this tokenizer a chance mid-line — `=` is not a default break char like `[`.
24+
*/
25+
export const Highlight = Mark.create({
26+
name: 'highlight',
27+
28+
parseHTML() {
29+
return [{ tag: 'mark' }]
30+
},
31+
32+
renderHTML({ HTMLAttributes }) {
33+
return ['mark', mergeAttributes(HTMLAttributes), 0]
34+
},
35+
36+
addInputRules() {
37+
return [markInputRule({ find: HIGHLIGHT_INPUT, type: this.type })]
38+
},
39+
40+
addPasteRules() {
41+
return [markPasteRule({ find: HIGHLIGHT_PASTE, type: this.type })]
42+
},
43+
44+
addKeyboardShortcuts() {
45+
return { 'Mod-Shift-h': () => this.editor.commands.toggleMark(this.name) }
46+
},
47+
48+
markdownTokenName: 'highlight',
49+
markdownTokenizer: {
50+
name: 'highlight',
51+
level: 'inline' as const,
52+
start: (src: string) => src.indexOf('=='),
53+
tokenize(src: string): MarkdownToken | undefined {
54+
const match = HIGHLIGHT_TOKEN.exec(src)
55+
if (!match) return undefined
56+
return { type: 'highlight', raw: match[0], text: match[1] }
57+
},
58+
},
59+
60+
parseMarkdown(token: MarkdownToken, helpers: MarkdownParseHelpers) {
61+
const inner = token.text ?? ''
62+
const tokens = helpers.tokenizeInline?.(inner)
63+
const content = tokens ? helpers.parseInline(tokens) : [{ type: 'text', text: inner }]
64+
return { mark: 'highlight', content }
65+
},
66+
67+
renderMarkdown(node: JSONContent, h: MarkdownRendererHelpers) {
68+
return `==${h.renderChildren(node.content ?? [])}==`
69+
},
70+
})

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: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,22 @@ 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',
161177
}
162178

163179
for (const [name, input] of Object.entries(cases)) {
@@ -434,3 +450,25 @@ describe('consecutive empty paragraphs', () => {
434450
}
435451
)
436452
})
453+
454+
describe('highlight ==mark==', () => {
455+
function markPresent(src: string): boolean {
456+
editor = new Editor({ extensions: createMarkdownContentExtensions() })
457+
editor.commands.setContent(src, { contentType: 'markdown' })
458+
const has = JSON.stringify(editor.getJSON()).includes('"type":"highlight"')
459+
editor.destroy()
460+
editor = null
461+
return has
462+
}
463+
464+
it('parses ==text== into a highlight mark, including mid-line and in headings', () => {
465+
expect(markPresent('a ==marked== word')).toBe(true)
466+
expect(markPresent('# a ==mark== b')).toBe(true)
467+
expect(markPresent('**bold ==mark== here**')).toBe(true)
468+
})
469+
470+
it('leaves comparison / spaced == operators as literal text', () => {
471+
expect(markPresent('if x == y then z')).toBe(false)
472+
expect(markPresent('a == b == c')).toBe(false)
473+
})
474+
})

0 commit comments

Comments
 (0)