From d1dbada9e421f5fa22cdf3eff78c23ff8303b92c Mon Sep 17 00:00:00 2001 From: lucaronin Date: Thu, 16 Apr 2026 11:24:37 +0200 Subject: [PATCH] fix: restore markdown-safe editor formatting controls --- src/components/Editor.test.tsx | 5 + src/components/SingleEditorView.tsx | 24 +- .../tolariaEditorFormatting.test.tsx | 45 +- src/components/tolariaEditorFormatting.tsx | 439 +++++++++++++++++- .../tolariaEditorFormattingConfig.ts | 67 ++- .../editor-formatting-persistence.spec.ts | 106 ++++- 6 files changed, 654 insertions(+), 32 deletions(-) diff --git a/src/components/Editor.test.tsx b/src/components/Editor.test.tsx index 9035615b..9bca14b2 100644 --- a/src/components/Editor.test.tsx +++ b/src/components/Editor.test.tsx @@ -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' diff --git a/src/components/SingleEditorView.tsx b/src/components/SingleEditorView.tsx index 306f922b..17c15614 100644 --- a/src/components/SingleEditorView.tsx +++ b/src/components/SingleEditorView.tsx @@ -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) { const editorRef = useRef(editor) @@ -146,7 +156,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange const handleContainerClick = useCallback((e: React.MouseEvent) => { 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} > - { 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() diff --git a/src/components/tolariaEditorFormatting.test.tsx b/src/components/tolariaEditorFormatting.test.tsx index f4462b50..ae10c884 100644 --- a/src/components/tolariaEditorFormatting.test.tsx +++ b/src/components/tolariaEditorFormatting.test.tsx @@ -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', + ) }) }) diff --git a/src/components/tolariaEditorFormatting.tsx b/src/components/tolariaEditorFormatting.tsx index 1ae57339..d9afb583 100644 --- a/src/components/tolariaEditorFormatting.tsx +++ b/src/components/tolariaEditorFormatting.tsx @@ -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 + +type TolariaSelectedBlock = ReturnType< + BlockNoteEditor['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, +) { + return ( + style in editor.schema.styleSchema && + editor.schema.styleSchema[style].type === style && + editor.schema.styleSchema[style].propSchema === 'boolean' + ) +} + +function selectionSupportsInlineFormatting( + editor: BlockNoteEditor, +) { + return ( + editor.getSelection()?.blocks || [editor.getTextCursorPosition().block] + ).some((block) => block.content !== undefined) +} + +function getBasicTextStyleButtonState( + basicTextStyle: TolariaBasicTextStyle, + editor: BlockNoteEditor, +) { + 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[number], +) { + const Icon = item.icon + return +} + +function isSelectedBlockTypeItem( + item: ReturnType[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, + firstSelectedBlock: TolariaSelectedBlock, +) { + return getTolariaBlockTypeSelectItems() + .filter((item) => + editorHasBlockWithType( + editor, + item.type, + Object.fromEntries( + Object.entries(item.props || {}).map(([propName, propValue]) => [ + propName, + typeof propValue, + ]), + ) as Record, + ), + ) + .map((item) => ({ + ...item, + iconElement: getBlockTypeItemIconElement(item), + isSelected: isSelectedBlockTypeItem(item, firstSelectedBlock), + })) +} + +function updateSelectedBlocksToType( + editor: BlockNoteEditor, + selectedBlocks: TolariaSelectedBlock[], + item: ReturnType[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 ( + } + /> + ) +} + +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 {items} + if (!selectedItem || !editor.isEditable) return null + + return ( + + + { + event.preventDefault() + event.currentTarget.focus() + }} + leftSection={selectedItem.iconElement} + rightSection={} + size="xs" + variant="subtle" + > + {selectedItem.name} + + + + {selectItems.map((item) => ( + { + updateSelectedBlocksToType(editor, selectedBlocks, item) + }} + leftSection={item.iconElement} + rightSection={item.isSelected + ? + :
} + > + {item.name} + + ))} + + + ) +} + +function replaceToolbarControls(items: ReactElement[]) { + return items.flatMap((item) => { + switch (String(item.key)) { + case 'blockTypeSelect': + return [] + case 'boldStyleButton': + return [] + case 'italicStyleButton': + return [] + case 'strikeStyleButton': + return [] + 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), + , + ...items.slice(strikeButtonIndex + 1), + ] +} + +function getTolariaFormattingToolbarItems() { + return insertInlineCodeButton( + replaceToolbarControls( + filterTolariaFormattingToolbarItems( + getFormattingToolbarItems(), + ), + ), + ) +} + +export function TolariaFormattingToolbar() { + return {getTolariaFormattingToolbarItems()} +} + +export function TolariaFormattingToolbarController(props: { + formattingToolbar?: FC; + 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( + () => ({ + ...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 ( + + {isOpen && ( +
{ + setToolbarHasFocus(true) + }} + onBlurCapture={(event) => { + const nextTarget = event.relatedTarget + if (nextTarget instanceof Node && event.currentTarget.contains(nextTarget)) { + return + } + + setToolbarHasFocus(false) + formattingToolbar.store.setState(false) + }} + > + +
+ )} +
+ ) } diff --git a/src/components/tolariaEditorFormattingConfig.ts b/src/components/tolariaEditorFormattingConfig.ts index ed070a36..41ee4485 100644 --- a/src/components/tolariaEditorFormattingConfig.ts +++ b/src/components/tolariaEditorFormattingConfig.ts @@ -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 + 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> = { + 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( items: T[], ): T[] { @@ -34,7 +88,16 @@ export function filterTolariaFormattingToolbarItems( export function filterTolariaSlashMenuItems( 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( diff --git a/tests/smoke/editor-formatting-persistence.spec.ts b/tests/smoke/editor-formatting-persistence.spec.ts index 029b65c3..4a7d9372 100644 --- a/tests/smoke/editor-formatting-persistence.spec.ts +++ b/tests/smoke/editor-formatting-persistence.spec.ts @@ -53,6 +53,11 @@ async function getRawEditorContent(page: Page): Promise { }) } +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) + }) +}