fix: support rich-editor markdown highlights

This commit is contained in:
lucaronin
2026-06-06 10:15:44 +02:00
parent 6aaaca8e24
commit 008e8f3471
6 changed files with 486 additions and 3 deletions

View File

@@ -33,6 +33,7 @@ import { useRegisterEditorContentFlushes } from './editorContentFlushRegistratio
import { useRawModeWithFlush } from './useRawModeWithFlush'
import { createArrowLigaturesExtension } from './arrowLigaturesExtension'
import { createImeCompositionKeyGuardExtension } from './imeCompositionKeyGuardExtension'
import { createMarkdownHighlightInputExtension } from './markdownHighlightInputExtension'
import { createMathInputExtension } from './mathInputExtension'
import { createRichEditorTransformErrorRecoveryExtension } from './richEditorTransformErrorRecoveryExtension'
import { useFilenameAutolinkGuard } from './useFilenameAutolinkGuard'
@@ -226,6 +227,7 @@ function useEditorSetup({
createRichEditorTransformErrorRecoveryExtension(),
createImeCompositionKeyGuardExtension(),
createArrowLigaturesExtension(),
createMarkdownHighlightInputExtension(),
createMathInputExtension(),
],
})

View File

@@ -0,0 +1,211 @@
import { describe, expect, it, vi } from 'vitest'
import { MARKDOWN_HIGHLIGHT_STYLE } from '../utils/markdownHighlightMarkdown'
import {
createMarkdownHighlightInputExtension,
readMarkdownHighlightInputReplacement,
} from './markdownHighlightInputExtension'
function createTransaction() {
const transaction = {
addMark: vi.fn(() => transaction),
delete: vi.fn(() => transaction),
scrollIntoView: vi.fn(() => transaction),
}
return transaction
}
function createView(beforeText: string, parentStart = 0) {
const cursor = parentStart + beforeText.length
const transaction = createTransaction()
const highlightMark = { type: { name: MARKDOWN_HIGHLIGHT_STYLE } }
const highlightMarkType = { create: vi.fn(() => highlightMark) }
const docNodes: Array<{
node: {
isText?: boolean
marks?: Array<{ type: { name: string } }>
nodeSize?: number
}
pos: number
}> = []
const view = {
composing: false,
dispatch: vi.fn(),
state: {
doc: {
nodesBetween: vi.fn((
from: number,
to: number,
visit: (
node: { isText?: boolean; marks?: Array<{ type: { name: string } }>; nodeSize?: number },
pos: number,
) => boolean | void,
) => {
for (const item of docNodes) {
const nodeEnd = item.pos + (item.node.nodeSize ?? 1)
if (nodeEnd < from || item.pos > to) continue
if (visit(item.node, item.pos) === false) return
}
}),
},
schema: {
marks: {
[MARKDOWN_HIGHLIGHT_STYLE]: highlightMarkType,
},
},
selection: {
from: cursor,
to: cursor,
$from: {
parent: {
isTextblock: true,
textBetween: vi.fn(() => beforeText),
},
parentOffset: beforeText.length,
marks: vi.fn(() => []),
},
},
storedMarks: null as Array<{ type: { name: string } }> | null,
tr: transaction,
},
}
return {
cursor,
docNodes,
highlightMark,
highlightMarkType,
transaction,
view,
}
}
function createFixture(beforeText = 'Plain ==marked=', parentStart = 0) {
let beforeInputListener: EventListener | null = null
const { docNodes, highlightMark, highlightMarkType, transaction, view } = createView(beforeText, parentStart)
const dom = {
addEventListener: vi.fn((type: string, listener: EventListener) => {
if (type === 'beforeinput') {
beforeInputListener = listener
}
}),
}
const editor = {
_tiptapEditor: { view },
prosemirrorView: view,
}
const extension = createMarkdownHighlightInputExtension()({ editor: editor as never })
return {
docNodes,
dom,
extension,
fireInput(event: Partial<InputEvent> = {}) {
if (!beforeInputListener) {
throw new Error('Markdown highlight input extension did not register beforeinput')
}
const inputEvent = {
data: '=',
inputType: 'insertText',
isComposing: false,
preventDefault: vi.fn(),
...event,
}
beforeInputListener(inputEvent as InputEvent)
return inputEvent
},
highlightMark,
highlightMarkType,
mount() {
const controller = new AbortController()
extension.mount?.({
dom: dom as never,
root: document,
signal: controller.signal,
})
return controller
},
transaction,
view,
}
}
function expectNoHighlightTransform(fixture: ReturnType<typeof createFixture>, event: Partial<InputEvent> = {}) {
const inputEvent = fixture.fireInput(event)
expect(fixture.transaction.delete).not.toHaveBeenCalled()
expect(fixture.transaction.addMark).not.toHaveBeenCalled()
expect(fixture.view.dispatch).not.toHaveBeenCalled()
expect(inputEvent.preventDefault).not.toHaveBeenCalled()
}
describe('createMarkdownHighlightInputExtension', () => {
it('reads a completed highlight pair before the final equals is inserted', () => {
expect(readMarkdownHighlightInputReplacement({
beforeText: 'Plain ==marked=',
cursor: 15,
parentStart: 0,
})).toEqual({
closingFrom: 14,
closingTo: 15,
contentFrom: 8,
contentTo: 14,
openingFrom: 6,
openingTo: 8,
})
})
it('registers a beforeinput listener when the editor mounts', () => {
const fixture = createFixture()
fixture.mount()
expect(fixture.dom.addEventListener).toHaveBeenCalledWith(
'beforeinput',
expect.any(Function),
expect.objectContaining({
capture: true,
signal: expect.any(AbortSignal),
}),
)
})
it('turns typed ==highlight== syntax into the durable highlight mark', () => {
const fixture = createFixture('Plain ==marked=', 20)
fixture.mount()
const event = fixture.fireInput()
expect(fixture.transaction.delete).toHaveBeenNthCalledWith(1, 34, 35)
expect(fixture.transaction.delete).toHaveBeenNthCalledWith(2, 26, 28)
expect(fixture.highlightMarkType.create).toHaveBeenCalledWith()
expect(fixture.transaction.addMark).toHaveBeenCalledWith(26, 32, fixture.highlightMark)
expect(fixture.transaction.scrollIntoView).toHaveBeenCalled()
expect(fixture.view.dispatch).toHaveBeenCalledWith(fixture.transaction)
expect(event.preventDefault).toHaveBeenCalledTimes(1)
})
it('leaves highlight-looking syntax literal inside inline code', () => {
const fixture = createFixture()
fixture.view.state.storedMarks = [{ type: { name: 'code' } }]
fixture.mount()
expectNoHighlightTransform(fixture)
})
it('leaves highlight-looking syntax literal when existing content has code marks', () => {
const fixture = createFixture()
fixture.docNodes.push({
node: {
isText: true,
marks: [{ type: { name: 'code' } }],
nodeSize: 6,
},
pos: 8,
})
fixture.mount()
expectNoHighlightTransform(fixture)
})
})

View File

@@ -0,0 +1,208 @@
import { createExtension } from '@blocknote/core'
import type { useCreateBlockNote } from '@blocknote/react'
import { MARKDOWN_HIGHLIGHT_STYLE } from '../utils/markdownHighlightMarkdown'
import {
isRecoverableEditorTransformError,
reportRecoveredEditorTransformError,
} from './richEditorTransformErrorRecoveryExtension'
const MARKDOWN_HIGHLIGHT_DELIMITER = '=='
const MARKDOWN_HIGHLIGHT_DELIMITER_LENGTH = MARKDOWN_HIGHLIGHT_DELIMITER.length
const FINAL_MARKDOWN_HIGHLIGHT_INPUT = '='
const CODE_MARK_TYPE = 'code'
type EditorViewLike = NonNullable<ReturnType<typeof useCreateBlockNote>['prosemirrorView']>
type MarkLike = { type: { name: string } }
type EditorMark = Parameters<EditorViewLike['state']['tr']['addMark']>[2]
type MarkTypeLike = { create: () => EditorMark }
type ReadEditorView = () => EditorViewLike | undefined
interface MarkdownHighlightCursorText {
beforeText: string
cursor: number
parentStart: number
}
export interface MarkdownHighlightInputReplacement {
closingFrom: number
closingTo: number
contentFrom: number
contentTo: number
openingFrom: number
openingTo: number
}
function isInsertedFinalEquals(event: InputEvent): event is InputEvent & { data: string } {
return event.inputType === 'insertText'
&& event.data === FINAL_MARKDOWN_HIGHLIGHT_INPUT
}
function hasCodeMark(marks: readonly MarkLike[] | null | undefined): boolean {
return Boolean(marks?.some((mark) => mark.type.name === CODE_MARK_TYPE))
}
function selectionHasCodeMark(view: EditorViewLike): boolean {
const marks = view.state.storedMarks ?? view.state.selection.$from.marks()
return hasCodeMark(marks)
}
function rangeHasCodeMark(
view: EditorViewLike,
from: number,
to: number,
): boolean {
let containsCode = false
view.state.doc.nodesBetween(from, to, (node: {
isText?: boolean
marks?: readonly MarkLike[]
}) => {
if (!node.isText) return true
containsCode = hasCodeMark(node.marks)
return containsCode ? false : true
})
return containsCode
}
function readCursorText(view: EditorViewLike): MarkdownHighlightCursorText | null {
const { from, to, $from } = view.state.selection
if (from !== to) return null
if (!$from.parent.isTextblock) return null
return {
beforeText: $from.parent.textBetween(0, $from.parentOffset, '', ''),
cursor: from,
parentStart: from - $from.parentOffset,
}
}
function hasValidHighlightContent(content: string): boolean {
if (content.trim().length === 0) return false
if (/^\s|\s$/.test(content)) return false
return !/[\r\n]/.test(content)
}
export function readMarkdownHighlightInputReplacement({
beforeText,
cursor,
parentStart,
}: MarkdownHighlightCursorText): MarkdownHighlightInputReplacement | null {
const candidateText = `${beforeText}${FINAL_MARKDOWN_HIGHLIGHT_INPUT}`
if (!candidateText.endsWith(MARKDOWN_HIGHLIGHT_DELIMITER)) return null
const closingStart = candidateText.length - MARKDOWN_HIGHLIGHT_DELIMITER_LENGTH
const openingStart = candidateText.lastIndexOf(
MARKDOWN_HIGHLIGHT_DELIMITER,
closingStart - 1,
)
if (openingStart === -1) return null
const contentStart = openingStart + MARKDOWN_HIGHLIGHT_DELIMITER_LENGTH
const content = candidateText.slice(contentStart, closingStart)
if (!hasValidHighlightContent(content)) return null
const closingFrom = parentStart + closingStart
if (cursor !== closingFrom + 1) return null
return {
closingFrom,
closingTo: cursor,
contentFrom: parentStart + contentStart,
contentTo: parentStart + closingStart,
openingFrom: parentStart + openingStart,
openingTo: parentStart + contentStart,
}
}
function recoverTransformError(error: unknown): boolean {
if (!isRecoverableEditorTransformError(error)) return false
reportRecoveredEditorTransformError('transform_error', error)
return true
}
function readHighlightMarkType(view: EditorViewLike): MarkTypeLike | null {
const markType = Reflect.get(view.state.schema.marks, MARKDOWN_HIGHLIGHT_STYLE) as MarkTypeLike | undefined
return markType ?? null
}
function replaceCompletedMarkdownHighlight(
view: EditorViewLike,
): EditorViewLike['state']['tr'] | null {
if (selectionHasCodeMark(view)) return null
const cursorText = readCursorText(view)
if (!cursorText) return null
const replacement = readMarkdownHighlightInputReplacement(cursorText)
const highlightMarkType = readHighlightMarkType(view)
if (!replacement || !highlightMarkType) return null
if (rangeHasCodeMark(view, replacement.contentFrom, replacement.contentTo)) return null
const highlightedFrom = replacement.contentFrom - MARKDOWN_HIGHLIGHT_DELIMITER_LENGTH
const highlightedTo = replacement.contentTo - MARKDOWN_HIGHLIGHT_DELIMITER_LENGTH
return view.state.tr
.delete(replacement.closingFrom, replacement.closingTo)
.delete(replacement.openingFrom, replacement.openingTo)
.addMark(highlightedFrom, highlightedTo, highlightMarkType.create())
.scrollIntoView()
}
function readMarkdownHighlightInputTransaction(
view: EditorViewLike,
): EditorViewLike['state']['tr'] | null {
try {
return replaceCompletedMarkdownHighlight(view)
} catch (error) {
if (!recoverTransformError(error)) throw error
return null
}
}
function shouldSkipInputEvent(event: InputEvent, view: EditorViewLike): boolean {
return !isInsertedFinalEquals(event) || event.isComposing || Boolean(view.composing)
}
function dispatchMarkdownHighlightTransaction(
view: EditorViewLike,
transaction: EditorViewLike['state']['tr'],
): boolean {
try {
view.dispatch(transaction)
return true
} catch (error) {
if (!recoverTransformError(error)) throw error
return false
}
}
function handleMarkdownHighlightBeforeInput(event: InputEvent, readView: ReadEditorView) {
const view = readView()
if (!view || shouldSkipInputEvent(event, view)) return
const transaction = readMarkdownHighlightInputTransaction(view)
if (!transaction) return
if (!dispatchMarkdownHighlightTransaction(view, transaction)) return
event.preventDefault()
}
export const createMarkdownHighlightInputExtension = createExtension(({ editor }) => {
const readView = () => editor._tiptapEditor?.view ?? editor.prosemirrorView
return {
key: 'markdownHighlightInput',
mount: ({ dom, signal }) => {
dom.addEventListener('beforeinput', ((event: InputEvent) => {
handleMarkdownHighlightBeforeInput(event, readView)
}) as EventListener, {
capture: true,
signal,
})
},
} as const
})

View File

@@ -105,6 +105,7 @@ vi.mock('@phosphor-icons/react', () => ({
ArrowSquareOut: MockIcon,
CaretDown: MockIcon,
Code: MockIcon,
Highlighter: MockIcon,
TextB: MockIcon,
TextItalic: MockIcon,
TextStrikethrough: MockIcon,
@@ -155,6 +156,7 @@ function createMockEditor(blockType = 'image', props: Record<string, unknown> =
italic: { type: 'italic', propSchema: 'boolean' },
strike: { type: 'strike', propSchema: 'boolean' },
code: { type: 'code', propSchema: 'boolean' },
highlight: { type: 'highlight', propSchema: 'boolean' },
},
},
prosemirrorState: { selection: { from: 1, to: 5 } },
@@ -187,11 +189,13 @@ describe('tolariaEditorFormatting behavior', () => {
fireEvent.click(screen.getByRole('button', { name: /bold/i }))
fireEvent.click(screen.getByRole('button', { name: /inline code/i }))
fireEvent.click(screen.getByRole('button', { name: /highlight/i }))
fireEvent.click(screen.getByRole('button', { name: 'Heading 1' }))
expect(editor.focus).toHaveBeenCalled()
expect(editor.toggleStyles).toHaveBeenCalledWith({ bold: true })
expect(editor.toggleStyles).toHaveBeenCalledWith({ code: true })
expect(editor.toggleStyles).toHaveBeenCalledWith({ highlight: true })
expect(editor.transact).toHaveBeenCalledTimes(1)
expect(editor.updateBlock).toHaveBeenCalledWith(
'file-block',

View File

@@ -48,11 +48,13 @@ import {
ArrowSquareOut as ExternalLink,
CaretDown as ChevronDown,
Code as Code2,
Highlighter,
TextB as Bold,
TextItalic as Italic,
TextStrikethrough as Strikethrough,
type Icon as PhosphorIcon,
} from '@phosphor-icons/react'
import { MARKDOWN_HIGHLIGHT_STYLE } from '../utils/markdownHighlightMarkdown'
import {
filterTolariaFormattingToolbarItems,
getTolariaBlockTypeSelectItems,
@@ -64,7 +66,12 @@ import {
reportRecoveredEditorTransformError,
} from './richEditorTransformErrorRecoveryExtension'
type TolariaBasicTextStyle = 'bold' | 'italic' | 'strike' | 'code'
type TolariaBasicTextStyle =
| 'bold'
| 'italic'
| 'strike'
| 'code'
| typeof MARKDOWN_HIGHLIGHT_STYLE
const FORMATTER_CLOSE_GRACE_MS = 160
const FORMATTER_VIEWPORT_PADDING_PX = 8
@@ -183,6 +190,11 @@ const TOLARIA_BASIC_TEXT_STYLE_TOOLTIPS = {
mainTooltip: 'Inline code (persists in markdown)',
secondaryTooltip: '`code`',
},
[MARKDOWN_HIGHLIGHT_STYLE]: {
label: 'Highlight',
mainTooltip: 'Highlight (persists in markdown)',
secondaryTooltip: '==highlight==',
},
} satisfies Record<
TolariaBasicTextStyle,
{ label: string; mainTooltip: string; secondaryTooltip: string }
@@ -193,6 +205,7 @@ const TOLARIA_BASIC_TEXT_STYLE_ICONS = {
italic: Italic,
strike: Strikethrough,
code: Code2,
[MARKDOWN_HIGHLIGHT_STYLE]: Highlighter,
} satisfies Record<TolariaBasicTextStyle, PhosphorIcon>
type TolariaSelectedBlock = ReturnType<
@@ -649,7 +662,7 @@ function replaceToolbarControls(items: ReactElement[], vaultPath?: string) {
})
}
function insertInlineCodeButton(items: ReactElement[]) {
function insertExtraTextStyleButtons(items: ReactElement[]) {
const strikeButtonIndex = items.findIndex(
(item) => String(item.key) === 'strikeStyleButton',
)
@@ -658,12 +671,16 @@ function insertInlineCodeButton(items: ReactElement[]) {
return [
...items.slice(0, strikeButtonIndex + 1),
<TolariaBasicTextStyleButton basicTextStyle="code" key="codeStyleButton" />,
<TolariaBasicTextStyleButton
basicTextStyle={MARKDOWN_HIGHLIGHT_STYLE}
key="highlightStyleButton"
/>,
...items.slice(strikeButtonIndex + 1),
]
}
function getTolariaFormattingToolbarItems(vaultPath?: string) {
return insertInlineCodeButton(
return insertExtraTextStyleButtons(
replaceToolbarControls(
filterTolariaFormattingToolbarItems(
getFormattingToolbarItems(),

View File

@@ -207,6 +207,7 @@ test('toolbar only exposes audited markdown-safe formatting controls', async ({
await expect(page.locator('.bn-formatting-toolbar [data-test="bold"]')).toBeVisible()
await expect(page.locator('.bn-formatting-toolbar [data-test="italic"]')).toBeVisible()
await expect(page.locator('.bn-formatting-toolbar [data-test="code"]')).toBeVisible()
await expect(page.locator('.bn-formatting-toolbar [data-test="highlight"]')).toBeVisible()
await expect(page.locator('.bn-formatting-toolbar [data-test="strike"]')).toBeVisible()
await expect(page.locator('.bn-formatting-toolbar [data-test="createLink"]')).toBeVisible()
@@ -258,6 +259,46 @@ test('Obsidian-style highlight markdown renders and persists', async ({ page })
expect(await getRawEditorContent(page)).toContain(highlightedLine)
})
test('Obsidian-style highlight markdown typed in rich mode renders and persists', async ({ page }) => {
await openNote(page, 'Note B')
const block = page.locator('.bn-block-content').nth(1)
await block.click()
await page.keyboard.press('End')
await page.keyboard.type(' Plain ==rich-marked==')
await expect(page.locator('.bn-editor mark.markdown-highlight', { hasText: 'rich-marked' })).toBeVisible()
await expect(block).not.toContainText('==rich-marked==')
await roundTripThroughAnotherNote(page)
await openRawMode(page)
expect(await getRawEditorContent(page)).toContain('Plain ==rich-marked==')
})
test('toolbar highlight button toggles selected text and persists removal', async ({ page }) => {
await openNote(page, 'Note B')
await selectWord(page, 1, 'referenced')
await page.locator('.bn-formatting-toolbar [data-test="highlight"]').click()
await expect(page.locator('.bn-editor mark.markdown-highlight', { hasText: 'referenced' })).toBeVisible()
await roundTripThroughAnotherNote(page)
await openRawMode(page)
expect(await getRawEditorContent(page)).toContain('This is Note B, ==referenced== by Alpha Project.')
await openBlockNoteMode(page)
await selectWord(page, 1, 'referenced')
await page.locator('.bn-formatting-toolbar [data-test="highlight"]').click()
await expect(page.locator('.bn-editor mark.markdown-highlight', { hasText: 'referenced' })).toHaveCount(0)
await roundTripThroughAnotherNote(page)
await openRawMode(page)
const raw = await getRawEditorContent(page)
expect(raw).toContain('This is Note B, referenced by Alpha Project.')
expect(raw).not.toContain('==referenced==')
})
test('toolbar block-type commands persist numbered lists', async ({ page }) => {
await openNote(page, 'Note B')
await selectWord(page, 1, 'This')