fix: support rich-editor markdown highlights
This commit is contained in:
@@ -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(),
|
||||
],
|
||||
})
|
||||
|
||||
211
src/components/markdownHighlightInputExtension.test.ts
Normal file
211
src/components/markdownHighlightInputExtension.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
208
src/components/markdownHighlightInputExtension.ts
Normal file
208
src/components/markdownHighlightInputExtension.ts
Normal 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
|
||||
})
|
||||
@@ -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',
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user