fix: restore markdown-safe editor formatting controls
This commit is contained in:
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user