fix: restore markdown-safe editor formatting controls

This commit is contained in:
lucaronin
2026-04-16 11:24:37 +02:00
parent 4e66f09eef
commit d1dbada9e4
6 changed files with 654 additions and 32 deletions

View File

@@ -84,6 +84,11 @@ vi.mock('@blocknote/mantine', () => ({
vi.mock('@blocknote/mantine/style.css', () => ({}))
vi.mock('./tolariaEditorFormatting', () => ({
TolariaFormattingToolbar: ({ children }: PropsWithChildren) => <>{children}</>,
TolariaFormattingToolbarController: () => null,
}))
import { Editor } from './Editor'
import { applyPendingRawExitContent } from './editorRawModeSync'
import type { VaultEntry } from '../types'

View File

@@ -5,7 +5,6 @@ import {
SuggestionMenuController,
BlockNoteViewRaw,
ComponentsContext,
FormattingToolbarController,
} from '@blocknote/react'
import { components } from '@blocknote/mantine'
import { MantineContext, MantineProvider } from '@mantine/core'
@@ -20,7 +19,10 @@ import type { VaultEntry } from '../types'
import { _wikilinkEntriesRef } from './editorSchema'
import { useBlockNoteSideMenuHoverGuard } from './blockNoteSideMenuHoverGuard'
import { getTolariaSlashMenuItems } from './tolariaEditorFormattingConfig'
import { TolariaFormattingToolbar } from './tolariaEditorFormatting'
import {
TolariaFormattingToolbar,
TolariaFormattingToolbarController,
} from './tolariaEditorFormatting'
import { useEditorLinkActivation } from './useEditorLinkActivation'
const TEST_TABLE_MARKDOWN = `| Head 1 | Head 2 | Head 3 |
@@ -116,6 +118,14 @@ function useSeedBlockNoteTableBridge(editor: ReturnType<typeof useCreateBlockNot
}, [editor])
}
function shouldIgnoreContainerClick(target: HTMLElement) {
return Boolean(
target.closest(
'[contenteditable="true"], .bn-formatting-toolbar, [role="menu"], [role="dialog"]',
),
)
}
/** Insert an image block after the current cursor position. */
function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
const editorRef = useRef(editor)
@@ -146,7 +156,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
const handleContainerClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
if (!editable) return
const target = e.target as HTMLElement
if (target.closest('[contenteditable="true"]')) return
if (shouldIgnoreContainerClick(target)) return
const blocks = editor.document
if (blocks.length > 0) {
editor.setTextCursorPosition(blocks[blocks.length - 1].id, 'end')
@@ -214,13 +224,17 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
formattingToolbar={false}
slashMenu={false}
>
<FormattingToolbarController
<TolariaFormattingToolbarController
formattingToolbar={TolariaFormattingToolbar}
floatingUIOptions={{
elementProps: {
onMouseDownCapture: (event) => {
const target = event.target as HTMLElement
if (target.closest('button[aria-haspopup], [role="menu"], [role="dialog"]')) {
if (
target.closest(
'[role="menu"], [role="dialog"], button[aria-haspopup]',
)
) {
return
}
event.preventDefault()

View File

@@ -3,20 +3,23 @@ import { getFormattingToolbarItems } from '@blocknote/react'
import {
filterTolariaFormattingToolbarItems,
filterTolariaSlashMenuItems,
getTolariaBlockTypeSelectItems,
} from './tolariaEditorFormattingConfig'
describe('tolariaEditorFormatting', () => {
it('removes formatting toolbar controls that do not round-trip through markdown', () => {
it('keeps the markdown-safe toolbar controls and block type select', () => {
const itemKeys = filterTolariaFormattingToolbarItems(
getFormattingToolbarItems(),
getFormattingToolbarItems(getTolariaBlockTypeSelectItems()),
).map((item) => String(item.key))
expect(itemKeys).toContain('blockTypeSelect')
expect(itemKeys).toContain('boldStyleButton')
expect(itemKeys).toContain('italicStyleButton')
expect(itemKeys).toContain('strikeStyleButton')
expect(itemKeys).toContain('createLinkButton')
expect(itemKeys).toContain('nestBlockButton')
expect(itemKeys).toContain('unnestBlockButton')
expect(itemKeys).not.toContain('blockTypeSelect')
expect(itemKeys).not.toContain('underlineStyleButton')
expect(itemKeys).not.toContain('colorStyleButton')
expect(itemKeys).not.toContain('textAlignLeftButton')
@@ -24,11 +27,29 @@ describe('tolariaEditorFormatting', () => {
expect(itemKeys).not.toContain('textAlignRightButton')
})
it('filters unsupported toggle slash-menu variants while keeping markdown-safe options', () => {
it('returns the audited markdown-safe block types for the toolbar select', () => {
expect(getTolariaBlockTypeSelectItems()).toEqual([
expect.objectContaining({ name: 'Paragraph', type: 'paragraph' }),
expect.objectContaining({ name: 'Heading 1', type: 'heading', props: { level: 1 } }),
expect.objectContaining({ name: 'Heading 2', type: 'heading', props: { level: 2 } }),
expect.objectContaining({ name: 'Heading 3', type: 'heading', props: { level: 3 } }),
expect.objectContaining({ name: 'Heading 4', type: 'heading', props: { level: 4 } }),
expect.objectContaining({ name: 'Heading 5', type: 'heading', props: { level: 5 } }),
expect.objectContaining({ name: 'Heading 6', type: 'heading', props: { level: 6 } }),
expect.objectContaining({ name: 'Quote', type: 'quote' }),
expect.objectContaining({ name: 'Bullet List', type: 'bulletListItem' }),
expect.objectContaining({ name: 'Numbered List', type: 'numberedListItem' }),
expect.objectContaining({ name: 'Checklist', type: 'checkListItem' }),
expect.objectContaining({ name: 'Code Block', type: 'codeBlock' }),
])
})
it('filters unsupported toggle slash-menu variants and annotates supported markdown commands', () => {
type TolariaSlashMenuTestItem = {
key: string
title: string
onItemClick: () => void
subtext?: string
}
const items = filterTolariaSlashMenuItems([
@@ -36,8 +57,22 @@ describe('tolariaEditorFormatting', () => {
{ key: 'toggle_list', title: 'Toggle list', onItemClick: () => {} },
{ key: 'heading', title: 'Heading', onItemClick: () => {} },
{ key: 'bullet_list', title: 'Bullet List', onItemClick: () => {} },
{ key: 'code_block', title: 'Code Block', onItemClick: () => {} },
] satisfies TolariaSlashMenuTestItem[])
expect(items.map((item) => item.key)).toEqual(['heading', 'bullet_list'])
expect(items.map((item) => item.key)).toEqual([
'heading',
'bullet_list',
'code_block',
])
expect(items.find((item) => item.key === 'heading')?.subtext).toContain(
'Markdown-safe heading',
)
expect(items.find((item) => item.key === 'bullet_list')?.subtext).toContain(
'Markdown-safe bullet list',
)
expect(items.find((item) => item.key === 'code_block')?.subtext).toContain(
'Markdown-safe fenced code block',
)
})
})

View File

@@ -1,13 +1,442 @@
import {
FormattingToolbar,
getFormattingToolbarItems,
PositionPopover,
useBlockNoteEditor,
useComponentsContext,
useEditorState,
useExtension,
useExtensionState,
} from '@blocknote/react'
import { filterTolariaFormattingToolbarItems } from './tolariaEditorFormattingConfig'
import type {
FloatingUIOptions,
FormattingToolbarProps,
} from '@blocknote/react'
import {
blockHasType,
defaultProps,
editorHasBlockWithType,
type DefaultProps,
} from '@blocknote/core'
import type {
BlockNoteEditor,
BlockSchema,
InlineContentSchema,
StyleSchema,
} from '@blocknote/core'
import { FormattingToolbarExtension } from '@blocknote/core/extensions'
import { useCallback, useMemo, useState, type FC, type ReactElement } from 'react'
import {
Button as MantineButton,
CheckIcon as MantineCheckIcon,
Menu as MantineMenu,
} from '@mantine/core'
import {
Bold,
ChevronDown,
Code2,
Italic,
Strikethrough,
type LucideIcon,
} from 'lucide-react'
import {
filterTolariaFormattingToolbarItems,
getTolariaBlockTypeSelectItems,
} from './tolariaEditorFormattingConfig'
export function TolariaFormattingToolbar() {
const items = filterTolariaFormattingToolbarItems(
getFormattingToolbarItems(),
type TolariaBasicTextStyle = 'bold' | 'italic' | 'strike' | 'code'
const TOLARIA_BASIC_TEXT_STYLE_TOOLTIPS = {
bold: {
label: 'Bold',
mainTooltip: 'Bold (persists in markdown)',
secondaryTooltip: '**strong**',
},
italic: {
label: 'Italic',
mainTooltip: 'Italic (persists in markdown)',
secondaryTooltip: '*emphasis*',
},
strike: {
label: 'Strikethrough',
mainTooltip: 'Strikethrough (persists in markdown)',
secondaryTooltip: '~~strike~~',
},
code: {
label: 'Inline code',
mainTooltip: 'Inline code (persists in markdown)',
secondaryTooltip: '`code`',
},
} satisfies Record<
TolariaBasicTextStyle,
{ label: string; mainTooltip: string; secondaryTooltip: string }
>
const TOLARIA_BASIC_TEXT_STYLE_ICONS = {
bold: Bold,
italic: Italic,
strike: Strikethrough,
code: Code2,
} satisfies Record<TolariaBasicTextStyle, LucideIcon>
type TolariaSelectedBlock = ReturnType<
BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>['getTextCursorPosition']
>['block']
type TolariaBlockTypeSelectOption = ReturnType<
typeof getTolariaBlockTypeSelectItems
>[number] & {
iconElement: ReactElement
isSelected: boolean
}
function textAlignmentToPlacement(
textAlignment: DefaultProps['textAlignment'],
) {
switch (textAlignment) {
case 'left':
return 'top-start'
case 'center':
return 'top'
case 'right':
return 'top-end'
default:
return 'top-start'
}
}
function editorSupportsTextStyle(
style: TolariaBasicTextStyle,
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
) {
return (
style in editor.schema.styleSchema &&
editor.schema.styleSchema[style].type === style &&
editor.schema.styleSchema[style].propSchema === 'boolean'
)
}
function selectionSupportsInlineFormatting(
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
) {
return (
editor.getSelection()?.blocks || [editor.getTextCursorPosition().block]
).some((block) => block.content !== undefined)
}
function getBasicTextStyleButtonState(
basicTextStyle: TolariaBasicTextStyle,
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
) {
if (!editor.isEditable) return undefined
if (!editorSupportsTextStyle(basicTextStyle, editor)) return undefined
if (!selectionSupportsInlineFormatting(editor)) return undefined
return {
active: basicTextStyle in editor.getActiveStyles(),
}
}
function getBlockTypeItemIconElement(
item: ReturnType<typeof getTolariaBlockTypeSelectItems>[number],
) {
const Icon = item.icon
return <Icon size={16} />
}
function isSelectedBlockTypeItem(
item: ReturnType<typeof getTolariaBlockTypeSelectItems>[number],
firstSelectedBlock: TolariaSelectedBlock,
) {
if (item.type !== firstSelectedBlock.type) return false
return Object.entries(item.props || {}).every(
([propName, propValue]) =>
propValue === firstSelectedBlock.props[propName],
)
}
function getTolariaBlockTypeSelectOptions(
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
firstSelectedBlock: TolariaSelectedBlock,
) {
return getTolariaBlockTypeSelectItems()
.filter((item) =>
editorHasBlockWithType(
editor,
item.type,
Object.fromEntries(
Object.entries(item.props || {}).map(([propName, propValue]) => [
propName,
typeof propValue,
]),
) as Record<string, 'string' | 'number' | 'boolean'>,
),
)
.map((item) => ({
...item,
iconElement: getBlockTypeItemIconElement(item),
isSelected: isSelectedBlockTypeItem(item, firstSelectedBlock),
}))
}
function updateSelectedBlocksToType(
editor: BlockNoteEditor<BlockSchema, InlineContentSchema, StyleSchema>,
selectedBlocks: TolariaSelectedBlock[],
item: ReturnType<typeof getTolariaBlockTypeSelectItems>[number],
) {
editor.focus()
editor.transact(() => {
for (const block of selectedBlocks) {
editor.updateBlock(block, {
type: item.type as never,
props: item.props as never,
})
}
})
}
function TolariaBasicTextStyleButton({
basicTextStyle,
}: {
basicTextStyle: TolariaBasicTextStyle
}) {
const Components = useComponentsContext()!
const editor = useBlockNoteEditor<
BlockSchema,
InlineContentSchema,
StyleSchema
>()
const buttonState = useEditorState({
editor,
selector: ({ editor }) => getBasicTextStyleButtonState(basicTextStyle, editor),
})
const toggleStyle = useCallback(() => {
editor.focus()
editor.toggleStyles({ [basicTextStyle]: true } as never)
}, [basicTextStyle, editor])
if (buttonState === undefined) return null
const Icon = TOLARIA_BASIC_TEXT_STYLE_ICONS[basicTextStyle]
const copy = TOLARIA_BASIC_TEXT_STYLE_TOOLTIPS[basicTextStyle]
return (
<Components.FormattingToolbar.Button
className="bn-button"
data-test={basicTextStyle}
onClick={toggleStyle}
isSelected={buttonState.active}
label={copy.label}
mainTooltip={copy.mainTooltip}
secondaryTooltip={copy.secondaryTooltip}
icon={<Icon />}
/>
)
}
function TolariaBlockTypeSelect() {
const editor = useBlockNoteEditor<
BlockSchema,
InlineContentSchema,
StyleSchema
>()
const selectedBlocks = useEditorState({
editor,
selector: ({ editor }) => (
editor.getSelection()?.blocks || [editor.getTextCursorPosition().block]
),
})
const firstSelectedBlock = selectedBlocks[0]
const selectItems = useMemo(
() => getTolariaBlockTypeSelectOptions(editor, firstSelectedBlock),
[editor, firstSelectedBlock],
)
const selectedItem = selectItems.find(
(item): item is TolariaBlockTypeSelectOption => item.isSelected,
)
return <FormattingToolbar>{items}</FormattingToolbar>
if (!selectedItem || !editor.isEditable) return null
return (
<MantineMenu
withinPortal={false}
transitionProps={{ exitDuration: 0 }}
middlewares={{ flip: true, shift: true, inline: false, size: true }}
>
<MantineMenu.Target>
<MantineButton
onMouseDown={(event) => {
event.preventDefault()
event.currentTarget.focus()
}}
leftSection={selectedItem.iconElement}
rightSection={<ChevronDown size={16} />}
size="xs"
variant="subtle"
>
{selectedItem.name}
</MantineButton>
</MantineMenu.Target>
<MantineMenu.Dropdown className="bn-select">
{selectItems.map((item) => (
<MantineMenu.Item
key={item.name}
onClick={() => {
updateSelectedBlocksToType(editor, selectedBlocks, item)
}}
leftSection={item.iconElement}
rightSection={item.isSelected
? <MantineCheckIcon size={10} className="bn-tick-icon" />
: <div className="bn-tick-space" />}
>
{item.name}
</MantineMenu.Item>
))}
</MantineMenu.Dropdown>
</MantineMenu>
)
}
function replaceToolbarControls(items: ReactElement[]) {
return items.flatMap((item) => {
switch (String(item.key)) {
case 'blockTypeSelect':
return [<TolariaBlockTypeSelect key={item.key} />]
case 'boldStyleButton':
return [<TolariaBasicTextStyleButton basicTextStyle="bold" key={item.key} />]
case 'italicStyleButton':
return [<TolariaBasicTextStyleButton basicTextStyle="italic" key={item.key} />]
case 'strikeStyleButton':
return [<TolariaBasicTextStyleButton basicTextStyle="strike" key={item.key} />]
default:
return [item]
}
})
}
function insertInlineCodeButton(items: ReactElement[]) {
const strikeButtonIndex = items.findIndex(
(item) => String(item.key) === 'strikeStyleButton',
)
if (strikeButtonIndex === -1) return items
return [
...items.slice(0, strikeButtonIndex + 1),
<TolariaBasicTextStyleButton basicTextStyle="code" key="codeStyleButton" />,
...items.slice(strikeButtonIndex + 1),
]
}
function getTolariaFormattingToolbarItems() {
return insertInlineCodeButton(
replaceToolbarControls(
filterTolariaFormattingToolbarItems(
getFormattingToolbarItems(),
),
),
)
}
export function TolariaFormattingToolbar() {
return <FormattingToolbar>{getTolariaFormattingToolbarItems()}</FormattingToolbar>
}
export function TolariaFormattingToolbarController(props: {
formattingToolbar?: FC<FormattingToolbarProps>;
floatingUIOptions?: FloatingUIOptions;
}) {
const editor = useBlockNoteEditor<
BlockSchema,
InlineContentSchema,
StyleSchema
>()
const formattingToolbar = useExtension(FormattingToolbarExtension, {
editor,
})
const show = useExtensionState(FormattingToolbarExtension, {
editor,
})
const [toolbarHasFocus, setToolbarHasFocus] = useState(false)
const isOpen = show || toolbarHasFocus
const position = useEditorState({
editor,
selector: ({ editor }) => (
isOpen
? {
from: editor.prosemirrorState.selection.from,
to: editor.prosemirrorState.selection.to,
}
: undefined
),
})
const placement = useEditorState({
editor,
selector: ({ editor }) => {
const block = editor.getTextCursorPosition().block
if (!blockHasType(block, editor, block.type, {
textAlignment: defaultProps.textAlignment,
})) {
return 'top-start'
}
return textAlignmentToPlacement(block.props.textAlignment)
},
})
const floatingUIOptions = useMemo<FloatingUIOptions>(
() => ({
...props.floatingUIOptions,
useFloatingOptions: {
open: isOpen,
onOpenChange: (open, _event, reason) => {
formattingToolbar.store.setState(open)
if (!open) {
setToolbarHasFocus(false)
}
if (reason === 'escape-key') {
editor.focus()
}
},
placement,
...props.floatingUIOptions?.useFloatingOptions,
},
elementProps: {
style: {
zIndex: 40,
},
...props.floatingUIOptions?.elementProps,
},
}),
[editor, formattingToolbar.store, isOpen, placement, props.floatingUIOptions],
)
const Component = props.formattingToolbar || TolariaFormattingToolbar
return (
<PositionPopover position={position} {...floatingUIOptions}>
{isOpen && (
<div
onFocusCapture={() => {
setToolbarHasFocus(true)
}}
onBlurCapture={(event) => {
const nextTarget = event.relatedTarget
if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) {
return
}
setToolbarHasFocus(false)
formattingToolbar.store.setState(false)
}}
>
<Component />
</div>
)}
</PositionPopover>
)
}

View File

@@ -4,11 +4,31 @@ import {
type DefaultReactSuggestionItem,
} from '@blocknote/react'
import type { ReactElement } from 'react'
import {
Code2,
Heading1,
Heading2,
Heading3,
Heading4,
Heading5,
Heading6,
List,
ListChecks,
ListOrdered,
Pilcrow,
Quote,
type LucideIcon,
} from 'lucide-react'
type TolariaSlashMenuItem = DefaultReactSuggestionItem & { key: string }
type TolariaBlockTypeSelectItem = {
name: string
type: string
props?: Record<string, boolean | number | string>
icon: LucideIcon
}
const UNSUPPORTED_FORMATTING_TOOLBAR_KEYS = new Set([
'blockTypeSelect',
'underlineStyleButton',
'textAlignLeftButton',
'textAlignCenterButton',
@@ -23,6 +43,40 @@ const UNSUPPORTED_SLASH_MENU_KEYS = new Set([
'toggle_list',
])
const TOLARIA_BLOCK_TYPE_SELECT_ITEMS: TolariaBlockTypeSelectItem[] = [
{ name: 'Paragraph', type: 'paragraph', icon: Pilcrow },
{ name: 'Heading 1', type: 'heading', props: { level: 1 }, icon: Heading1 },
{ name: 'Heading 2', type: 'heading', props: { level: 2 }, icon: Heading2 },
{ name: 'Heading 3', type: 'heading', props: { level: 3 }, icon: Heading3 },
{ name: 'Heading 4', type: 'heading', props: { level: 4 }, icon: Heading4 },
{ name: 'Heading 5', type: 'heading', props: { level: 5 }, icon: Heading5 },
{ name: 'Heading 6', type: 'heading', props: { level: 6 }, icon: Heading6 },
{ name: 'Quote', type: 'quote', icon: Quote },
{ name: 'Bullet List', type: 'bulletListItem', icon: List },
{ name: 'Numbered List', type: 'numberedListItem', icon: ListOrdered },
{ name: 'Checklist', type: 'checkListItem', icon: ListChecks },
{ name: 'Code Block', type: 'codeBlock', icon: Code2 },
]
const TOLARIA_SLASH_MENU_SUPPORT_SUBTEXT: Partial<Record<string, string>> = {
heading: 'Markdown-safe heading (`#`). Persists after save and note switches.',
heading_2: 'Markdown-safe heading (`##`). Persists after save and note switches.',
heading_3: 'Markdown-safe heading (`###`). Persists after save and note switches.',
heading_4: 'Markdown-safe heading (`####`). Persists after save and note switches.',
heading_5: 'Markdown-safe heading (`#####`). Persists after save and note switches.',
heading_6: 'Markdown-safe heading (`######`). Persists after save and note switches.',
quote: 'Markdown-safe block quote (`>`). Persists after save and note switches.',
bullet_list: 'Markdown-safe bullet list (`-`). Persists after save and note switches.',
numbered_list: 'Markdown-safe numbered list (`1.`). Persists after save and note switches.',
check_list: 'Markdown-safe checklist (`- [ ]`). Persists after save and note switches.',
paragraph: 'Plain markdown paragraph text. Persists after save and note switches.',
code_block: 'Markdown-safe fenced code block (```...```). Persists after save and note switches.',
}
export function getTolariaBlockTypeSelectItems() {
return TOLARIA_BLOCK_TYPE_SELECT_ITEMS
}
export function filterTolariaFormattingToolbarItems<T extends ReactElement>(
items: T[],
): T[] {
@@ -34,7 +88,16 @@ export function filterTolariaFormattingToolbarItems<T extends ReactElement>(
export function filterTolariaSlashMenuItems<T extends TolariaSlashMenuItem>(
items: T[],
): T[] {
return items.filter((item) => !UNSUPPORTED_SLASH_MENU_KEYS.has(item.key))
return items
.filter((item) => !UNSUPPORTED_SLASH_MENU_KEYS.has(item.key))
.map((item) => {
const tolariaSubtext = TOLARIA_SLASH_MENU_SUPPORT_SUBTEXT[item.key]
if (!tolariaSubtext) return item
return {
...item,
subtext: tolariaSubtext,
}
}) as T[]
}
export function getTolariaSlashMenuItems(

View File

@@ -53,6 +53,11 @@ async function getRawEditorContent(page: Page): Promise<string> {
})
}
async function roundTripThroughAnotherNote(page: Page) {
await openNote(page, 'Note C')
await openNote(page, 'Note B')
}
async function selectWord(page: Page, blockIndex: number, word: string) {
const block = page.locator('.bn-block-content').nth(blockIndex)
await expect(block).toBeVisible({ timeout: 5_000 })
@@ -87,16 +92,85 @@ async function selectWord(page: Page, blockIndex: number, word: string) {
await expect(page.locator('.bn-formatting-toolbar')).toBeVisible({ timeout: 5_000 })
}
test('toolbar only exposes markdown-safe formatting controls', async ({ page }) => {
async function openBlockTypeMenu(page: Page) {
const blockTypeButton = page.getByRole('button', { name: 'Paragraph' })
await blockTypeButton.focus()
await page.keyboard.press('Enter')
}
async function assertSlashMenuBlockCommandPersists(page: Page, options: {
query: string
optionName: RegExp
insertedText: string
rawAssertion: (raw: string) => void
blockContentType: string
}) {
await openNote(page, 'Note B')
await page.locator('.bn-block-content').nth(1).click()
await page.keyboard.type(options.query)
await expect(page.getByRole('option', { name: options.optionName })).toBeVisible()
await page.keyboard.press('Enter')
await page.keyboard.type(options.insertedText)
await page.waitForTimeout(700)
await roundTripThroughAnotherNote(page)
await openRawMode(page)
const raw = await getRawEditorContent(page)
options.rawAssertion(raw)
await openBlockNoteMode(page)
await expect(
page.locator(
`.bn-block-content[data-content-type="${options.blockContentType}"]`,
).first(),
).toContainText(options.insertedText)
}
const slashMenuPersistenceScenarios = [
{
name: 'slash menu block commands persist bullet lists',
query: '/bul',
optionName: /Bullet List/i,
insertedText: 'Persisted bullet',
rawAssertion: (raw: string) => {
expect(raw).toContain('- Persisted bullet')
},
blockContentType: 'bulletListItem',
},
{
name: 'slash menu block commands persist code blocks',
query: '/code',
optionName: /Code Block/i,
insertedText: 'const persistedAnswer = 42',
rawAssertion: (raw: string) => {
expect(raw).toMatch(/```[\w-]*\nconst persistedAnswer = 42/m)
},
blockContentType: 'codeBlock',
},
] as const
test('toolbar only exposes audited markdown-safe formatting controls', async ({ page }) => {
await openNote(page, 'Note B')
await selectWord(page, 1, 'referenced')
await expect(page.getByRole('button', { name: 'Paragraph' })).toHaveCount(0)
await expect(page.getByRole('button', { name: 'Paragraph' })).toBeVisible()
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="strike"]')).toBeVisible()
await expect(page.locator('.bn-formatting-toolbar [data-test="createLink"]')).toBeVisible()
await openBlockTypeMenu(page)
await expect(page.getByRole('menuitem', { name: 'Heading 1' })).toBeVisible()
await expect(page.getByRole('menuitem', { name: 'Heading 6' })).toBeVisible()
await expect(page.getByRole('menuitem', { name: 'Quote' })).toBeVisible()
await expect(page.getByRole('menuitem', { name: 'Bullet List' })).toBeVisible()
await expect(page.getByRole('menuitem', { name: 'Numbered List' })).toBeVisible()
await expect(page.getByRole('menuitem', { name: 'Checklist' })).toBeVisible()
await expect(page.getByRole('menuitem', { name: 'Code Block' })).toBeVisible()
await expect(page.getByRole('menuitem', { name: 'Toggle List' })).toHaveCount(0)
await expect(page.locator('.bn-formatting-toolbar [data-test="underline"]')).toHaveCount(0)
await expect(page.locator('.bn-formatting-toolbar [data-test="colors"]')).toHaveCount(0)
await expect(page.locator('.bn-formatting-toolbar [data-test="alignTextLeft"]')).toHaveCount(0)
@@ -110,32 +184,34 @@ test('supported inline formatting persists after note switches when applied from
await page.keyboard.press('Meta+b')
await page.waitForTimeout(700)
await openNote(page, 'Note C')
await openNote(page, 'Note B')
await roundTripThroughAnotherNote(page)
await openRawMode(page)
const raw = await getRawEditorContent(page)
expect(raw).toContain('This is Note B, **referenced** by Alpha Project.')
})
test('slash menu block commands persist bullet lists', async ({ page }) => {
test('toolbar block-type commands persist numbered lists', async ({ page }) => {
await openNote(page, 'Note B')
await page.locator('.bn-block-content').nth(1).click()
await page.keyboard.type('/bul')
await expect(page.getByRole('option', { name: /Bullet List/i })).toBeVisible()
await page.keyboard.press('Enter')
await page.keyboard.type('Persisted bullet')
await selectWord(page, 1, 'This')
await openBlockTypeMenu(page)
await page.getByRole('menuitem', { name: 'Numbered List' }).click()
await page.waitForTimeout(700)
await openNote(page, 'Note C')
await openNote(page, 'Note B')
await roundTripThroughAnotherNote(page)
await openRawMode(page)
const raw = await getRawEditorContent(page)
expect(raw).toContain('- Persisted bullet')
expect(raw).toContain('1. This is Note B, referenced by Alpha Project.')
await openBlockNoteMode(page)
await expect(page.locator('.bn-block-content[data-content-type="bulletListItem"]').first()).toContainText(
'Persisted bullet',
await expect(page.locator('.bn-block-content[data-content-type="numberedListItem"]').first()).toContainText(
'This is Note B, referenced by Alpha Project.',
)
})
for (const scenario of slashMenuPersistenceScenarios) {
test(scenario.name, async ({ page }) => {
await assertSlashMenuBlockCommandPersists(page, scenario)
})
}