From 008e8f3471ceba02780a0a4e2fae9cb26b7bf7a0 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 6 Jun 2026 10:15:44 +0200 Subject: [PATCH] fix: support rich-editor markdown highlights --- src/components/Editor.tsx | 2 + .../markdownHighlightInputExtension.test.ts | 211 ++++++++++++++++++ .../markdownHighlightInputExtension.ts | 208 +++++++++++++++++ .../tolariaEditorFormatting.behavior.test.tsx | 4 + src/components/tolariaEditorFormatting.tsx | 23 +- .../editor-formatting-persistence.spec.ts | 41 ++++ 6 files changed, 486 insertions(+), 3 deletions(-) create mode 100644 src/components/markdownHighlightInputExtension.test.ts create mode 100644 src/components/markdownHighlightInputExtension.ts diff --git a/src/components/Editor.tsx b/src/components/Editor.tsx index 53648790..5ed34b40 100644 --- a/src/components/Editor.tsx +++ b/src/components/Editor.tsx @@ -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(), ], }) diff --git a/src/components/markdownHighlightInputExtension.test.ts b/src/components/markdownHighlightInputExtension.test.ts new file mode 100644 index 00000000..8833de5d --- /dev/null +++ b/src/components/markdownHighlightInputExtension.test.ts @@ -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 = {}) { + 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, event: Partial = {}) { + 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) + }) +}) diff --git a/src/components/markdownHighlightInputExtension.ts b/src/components/markdownHighlightInputExtension.ts new file mode 100644 index 00000000..8a9df90b --- /dev/null +++ b/src/components/markdownHighlightInputExtension.ts @@ -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['prosemirrorView']> +type MarkLike = { type: { name: string } } +type EditorMark = Parameters[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 +}) diff --git a/src/components/tolariaEditorFormatting.behavior.test.tsx b/src/components/tolariaEditorFormatting.behavior.test.tsx index 4b007c9d..f368374a 100644 --- a/src/components/tolariaEditorFormatting.behavior.test.tsx +++ b/src/components/tolariaEditorFormatting.behavior.test.tsx @@ -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 = 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', diff --git a/src/components/tolariaEditorFormatting.tsx b/src/components/tolariaEditorFormatting.tsx index 8c0ace45..f1bfd647 100644 --- a/src/components/tolariaEditorFormatting.tsx +++ b/src/components/tolariaEditorFormatting.tsx @@ -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 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), , + , ...items.slice(strikeButtonIndex + 1), ] } function getTolariaFormattingToolbarItems(vaultPath?: string) { - return insertInlineCodeButton( + return insertExtraTextStyleButtons( replaceToolbarControls( filterTolariaFormattingToolbarItems( getFormattingToolbarItems(), diff --git a/tests/smoke/editor-formatting-persistence.spec.ts b/tests/smoke/editor-formatting-persistence.spec.ts index db98196c..a9c12b45 100644 --- a/tests/smoke/editor-formatting-persistence.spec.ts +++ b/tests/smoke/editor-formatting-persistence.spec.ts @@ -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')