|
| 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 | +}) |
0 commit comments