fix: refine slash command menu

This commit is contained in:
lucaronin
2026-05-02 16:39:43 +02:00
parent 58e129cdbc
commit 048d27243d
6 changed files with 358 additions and 85 deletions

View File

@@ -122,16 +122,16 @@
matches our vault theme instead of BlockNote's hardcoded light/dark defaults. */
--bn-colors-editor-background: var(--bg-primary);
--bn-colors-editor-text: var(--text-primary);
--bn-colors-menu-background: var(--bg-card);
--bn-colors-menu-background: var(--surface-popover);
--bn-colors-menu-text: var(--text-primary);
--bn-colors-tooltip-background: var(--bg-hover);
--bn-colors-tooltip-background: var(--state-hover-subtle);
--bn-colors-tooltip-text: var(--text-primary);
--bn-colors-hovered-background: var(--bg-hover);
--bn-colors-hovered-background: var(--state-hover);
--bn-colors-hovered-text: var(--text-primary);
--bn-colors-selected-background: var(--bg-selected);
--bn-colors-selected-text: var(--text-primary);
--bn-colors-border: var(--border-primary);
--bn-colors-shadow: var(--border-primary);
--bn-colors-border: var(--border-dialog);
--bn-colors-shadow: var(--shadow-dialog);
--bn-colors-side-menu: var(--text-muted);
}
@@ -140,6 +140,84 @@
padding: 20px 0;
}
.editor__blocknote-container .bn-suggestion-menu {
border: 1px solid var(--border-dialog) !important;
background: var(--surface-popover) !important;
box-shadow: 0 8px 32px var(--shadow-dialog) !important;
}
.editor__blocknote-container .bn-suggestion-menu-label {
color: var(--text-secondary) !important;
}
.editor__blocknote-container .bn-suggestion-menu-item {
color: var(--text-primary) !important;
height: 34px !important;
min-height: 34px !important;
padding: 3px 8px !important;
}
.editor__blocknote-container .bn-mt-suggestion-menu-item-section[data-position="left"] {
width: 28px;
min-width: 28px;
margin-inline-end: 8px !important;
padding: 0 !important;
background: transparent !important;
border-radius: 0 !important;
color: var(--text-primary) !important;
}
.editor__blocknote-container .bn-mt-suggestion-menu-item-section[data-position="right"] {
margin-inline-start: 12px !important;
color: var(--text-muted) !important;
}
.editor__blocknote-container .bn-mt-suggestion-menu-item-section[data-position="right"] .mantine-Badge-root {
min-width: 0 !important;
height: auto !important;
padding: 0 !important;
border: 0 !important;
border-radius: 0 !important;
background: transparent !important;
color: var(--text-muted) !important;
font-size: 10px !important;
font-weight: 500 !important;
line-height: 1.2 !important;
text-transform: none !important;
}
.editor__blocknote-container .bn-mt-suggestion-menu-item-body {
justify-content: center !important;
padding-right: 0 !important;
}
.editor__blocknote-container .bn-mt-suggestion-menu-item-subtitle {
display: none !important;
}
.editor__blocknote-container .tolaria-slash-menu-icon {
position: relative;
display: inline-flex;
width: 20px;
height: 20px;
align-items: center;
justify-content: center;
}
.editor__blocknote-container .tolaria-slash-menu-icon__fill {
position: absolute;
inset: 0;
opacity: 0;
}
.editor__blocknote-container .bn-suggestion-menu-item:is(:hover, [aria-selected="true"]) .tolaria-slash-menu-icon__regular {
opacity: 0;
}
.editor__blocknote-container .bn-suggestion-menu-item:is(:hover, [aria-selected="true"]) .tolaria-slash-menu-icon__fill {
opacity: 1;
}
/* =============================================
Bear-style live preview: zero horizontal shift
============================================= */

View File

@@ -1,3 +1,4 @@
import { Children, isValidElement, type ReactElement } from 'react'
import { describe, expect, it } from 'vitest'
import { getFormattingToolbarItems } from '@blocknote/react'
import {
@@ -44,20 +45,24 @@ describe('tolariaEditorFormatting', () => {
])
})
it('filters unsupported toggle slash-menu variants and annotates supported markdown commands', () => {
it('filters unsupported toggle slash-menu variants and removes command descriptions', () => {
type TolariaSlashMenuTestItem = {
key: string
title: string
onItemClick: () => void
subtext?: string
icon?: ReactElement
}
const items = filterTolariaSlashMenuItems([
{ key: 'toggle_heading', title: 'Toggle heading', onItemClick: () => {} },
{ 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: () => {} },
{ key: 'heading', title: 'Heading', subtext: 'Default heading copy', onItemClick: () => {} },
{ key: 'bullet_list', title: 'Bullet List', subtext: 'Default list copy', onItemClick: () => {} },
{ key: 'code_block', title: 'Code Block', subtext: 'Default code copy', onItemClick: () => {} },
{ key: 'heading_4', title: 'Heading 4', onItemClick: () => {} },
{ key: 'heading_5', title: 'Heading 5', onItemClick: () => {} },
{ key: 'heading_6', title: 'Heading 6', onItemClick: () => {} },
] satisfies TolariaSlashMenuTestItem[])
expect(items.map((item) => item.key)).toEqual([
@@ -65,14 +70,40 @@ describe('tolariaEditorFormatting', () => {
'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',
)
expect(items.map((item) => item.subtext)).toEqual([
undefined,
undefined,
undefined,
])
})
it('wraps slash-menu icons so hover can swap Phosphor weights', () => {
type TolariaSlashMenuTestItem = {
key: string
title: string
onItemClick: () => void
icon?: ReactElement
}
const items = filterTolariaSlashMenuItems([
{ key: 'heading', title: 'Heading', onItemClick: () => {} },
] satisfies TolariaSlashMenuTestItem[])
const icon = items[0]?.icon
expect(isValidElement(icon)).toBe(true)
if (!isValidElement<{ className?: string; children?: ReactElement[] }>(icon)) return
const iconChildren = Children.toArray(icon.props.children) as Array<
ReactElement<{ className?: string; weight?: string }>
>
expect(icon.props.className).toBe('tolaria-slash-menu-icon')
expect(iconChildren.map((child) => child.props.className)).toEqual([
'tolaria-slash-menu-icon__regular',
'tolaria-slash-menu-icon__fill',
])
expect(iconChildren.map((child) => child.props.weight)).toEqual([
'regular',
'fill',
])
})
})

View File

@@ -3,29 +3,36 @@ import {
getDefaultReactSlashMenuItems,
type DefaultReactSuggestionItem,
} from '@blocknote/react'
import type { ReactElement } from 'react'
import { createElement, type ReactElement } from 'react'
import {
Code2,
Heading1,
Heading2,
Heading3,
Heading4,
Heading5,
Heading6,
List,
CodeBlock,
File,
ImageSquare,
ListBullets,
ListChecks,
ListOrdered,
Pilcrow,
Quote,
type LucideIcon,
} from 'lucide-react'
ListNumbers,
Minus,
Paragraph,
Quotes,
Smiley,
SpeakerHigh,
Table,
TextHOne,
TextHTwo,
TextHThree,
TextHFour,
TextHFive,
TextHSix,
Video,
type Icon as PhosphorIcon,
} from '@phosphor-icons/react'
type TolariaSlashMenuItem = DefaultReactSuggestionItem & { key: string }
type TolariaBlockTypeSelectItem = {
name: string
type: string
props?: Record<string, boolean | number | string>
icon: LucideIcon
icon: PhosphorIcon
}
const UNSUPPORTED_FORMATTING_TOOLBAR_KEYS = new Set([
@@ -37,6 +44,9 @@ const UNSUPPORTED_FORMATTING_TOOLBAR_KEYS = new Set([
])
const UNSUPPORTED_SLASH_MENU_KEYS = new Set([
'heading_4',
'heading_5',
'heading_6',
'toggle_heading',
'toggle_heading_2',
'toggle_heading_3',
@@ -44,33 +54,60 @@ const UNSUPPORTED_SLASH_MENU_KEYS = new Set([
])
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: 'Paragraph', type: 'paragraph', icon: Paragraph },
{ name: 'Heading 1', type: 'heading', props: { level: 1 }, icon: TextHOne },
{ name: 'Heading 2', type: 'heading', props: { level: 2 }, icon: TextHTwo },
{ name: 'Heading 3', type: 'heading', props: { level: 3 }, icon: TextHThree },
{ name: 'Heading 4', type: 'heading', props: { level: 4 }, icon: TextHFour },
{ name: 'Heading 5', type: 'heading', props: { level: 5 }, icon: TextHFive },
{ name: 'Heading 6', type: 'heading', props: { level: 6 }, icon: TextHSix },
{ name: 'Quote', type: 'quote', icon: Quotes },
{ name: 'Bullet List', type: 'bulletListItem', icon: ListBullets },
{ name: 'Numbered List', type: 'numberedListItem', icon: ListNumbers },
{ name: 'Checklist', type: 'checkListItem', icon: ListChecks },
{ name: 'Code Block', type: 'codeBlock', icon: Code2 },
{ name: 'Code Block', type: 'codeBlock', icon: CodeBlock },
]
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.',
const TOLARIA_SLASH_MENU_ICONS: Partial<Record<string, PhosphorIcon>> = {
audio: SpeakerHigh,
bullet_list: ListBullets,
check_list: ListChecks,
code_block: CodeBlock,
divider: Minus,
emoji: Smiley,
file: File,
heading: TextHOne,
heading_2: TextHTwo,
heading_3: TextHThree,
image: ImageSquare,
numbered_list: ListNumbers,
paragraph: Paragraph,
quote: Quotes,
table: Table,
toggle_heading: TextHOne,
toggle_heading_2: TextHTwo,
toggle_heading_3: TextHThree,
toggle_list: ListBullets,
video: Video,
}
function createTolariaSlashMenuIcon(Icon: PhosphorIcon) {
return createElement(
'span',
{ className: 'tolaria-slash-menu-icon' },
createElement(Icon, {
'aria-hidden': true,
className: 'tolaria-slash-menu-icon__regular',
size: 18,
weight: 'regular',
}),
createElement(Icon, {
'aria-hidden': true,
className: 'tolaria-slash-menu-icon__fill',
size: 18,
weight: 'fill',
}),
)
}
export function getTolariaBlockTypeSelectItems() {
@@ -91,11 +128,12 @@ export function filterTolariaSlashMenuItems<T extends TolariaSlashMenuItem>(
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
const TolariaIcon = TOLARIA_SLASH_MENU_ICONS[item.key]
return {
...item,
subtext: tolariaSubtext,
icon: TolariaIcon ? createTolariaSlashMenuIcon(TolariaIcon) : item.icon,
subtext: undefined,
}
}) as T[]
}

View File

@@ -22,4 +22,29 @@ describe('useBuildNumber', () => {
const { result } = renderHook(() => useBuildNumber())
await waitFor(() => expect(result.current).toBe('b?'))
})
it('ignores build number requests that settle after unmount', async () => {
const { mockInvoke } = await import('../mock-tauri')
let rejectBuildNumber!: (reason?: unknown) => void
vi.mocked(mockInvoke).mockReturnValueOnce(
new Promise((_resolve, reject) => {
rejectBuildNumber = reject
}),
)
const { result, unmount } = renderHook(() => useBuildNumber())
unmount()
const originalWindow = globalThis.window
try {
vi.stubGlobal('window', undefined)
rejectBuildNumber(new Error('late failure'))
await Promise.resolve()
await Promise.resolve()
} finally {
vi.stubGlobal('window', originalWindow)
}
expect(result.current).toBeUndefined()
})
})

View File

@@ -10,9 +10,19 @@ export function useBuildNumber(): string | undefined {
const [buildNumber, setBuildNumber] = useState<string>()
useEffect(() => {
tauriCall<string>('get_build_number').then(setBuildNumber).catch(() => {
setBuildNumber('b?')
})
let mounted = true
tauriCall<string>('get_build_number')
.then((value) => {
if (mounted) setBuildNumber(value)
})
.catch(() => {
if (mounted) setBuildNumber('b?')
})
return () => {
mounted = false
}
}, [])
return buildNumber

View File

@@ -1,4 +1,4 @@
import { test, expect, type Page } from '@playwright/test'
import { test, expect, type Locator, type Page } from '@playwright/test'
import { createFixtureVaultCopy, openFixtureVault, removeFixtureVaultCopy } from '../helpers/fixtureVault'
let tempVaultDir: string
@@ -9,6 +9,104 @@ async function openAlphaProject(page: Page) {
await expect(page.locator('.bn-editor')).toBeVisible({ timeout: 10_000 })
}
async function openSlashMenu(page: Page) {
await page.locator('.bn-editor').click()
await page.keyboard.type('/')
const menu = page.locator('.bn-suggestion-menu')
await expect(menu).toBeVisible({ timeout: 5_000 })
return menu
}
async function readMenuStyles(menu: Locator) {
return menu.evaluate((node) => {
const style = getComputedStyle(node)
return {
backgroundColor: style.backgroundColor,
borderRadius: style.borderRadius,
boxShadow: style.boxShadow,
spacing: style.getPropertyValue('--mantine-spacing-sm').trim(),
radius: style.getPropertyValue('--mantine-radius-default').trim(),
shadow: style.getPropertyValue('--mantine-shadow-md').trim(),
}
})
}
function expectMenuChrome(styles: Awaited<ReturnType<typeof readMenuStyles>>) {
expect(styles.backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
expect(styles.borderRadius).not.toBe('0px')
expect(styles.boxShadow).not.toBe('none')
expect(styles.spacing).not.toBe('')
expect(styles.radius).not.toBe('')
expect(styles.shadow).not.toBe('')
}
async function readSlashMenuItemStyles(item: Locator) {
return item.evaluate((node) => {
const item = node as HTMLElement
const leftSection = item.querySelector<HTMLElement>(
'.bn-mt-suggestion-menu-item-section[data-position="left"]',
)!
const shortcut = item.querySelector<HTMLElement>('.mantine-Badge-root')!
const shortcutLabel = item.querySelector<HTMLElement>('.mantine-Badge-label')!
const subtitle = item.querySelector<HTMLElement>('.bn-mt-suggestion-menu-item-subtitle')!
const regularIcon = item.querySelector<HTMLElement>('.tolaria-slash-menu-icon__regular')!
const fillIcon = item.querySelector<HTMLElement>('.tolaria-slash-menu-icon__fill')!
const probe = document.createElement('span')
probe.style.color = getComputedStyle(document.documentElement)
.getPropertyValue('--text-muted')
.trim()
document.body.appendChild(probe)
const textMuted = getComputedStyle(probe).color
probe.remove()
return {
fillOpacity: getComputedStyle(fillIcon).opacity,
itemHeight: getComputedStyle(item).height,
leftBackgroundColor: getComputedStyle(leftSection).backgroundColor,
leftBorderRadius: getComputedStyle(leftSection).borderRadius,
leftPadding: getComputedStyle(leftSection).padding,
regularOpacity: getComputedStyle(regularIcon).opacity,
shortcutBackgroundColor: getComputedStyle(shortcut).backgroundColor,
shortcutBorderRadius: getComputedStyle(shortcut).borderRadius,
shortcutColor: getComputedStyle(shortcutLabel).color,
shortcutFontSize: getComputedStyle(shortcutLabel).fontSize,
shortcutPadding: getComputedStyle(shortcut).padding,
subtitleDisplay: getComputedStyle(subtitle).display,
subtitleText: subtitle.textContent ?? '',
textMuted,
}
})
}
function expectSimplifiedItemStyles(
styles: Awaited<ReturnType<typeof readSlashMenuItemStyles>>,
) {
expect(styles.itemHeight).toBe('34px')
expect(styles.leftBackgroundColor).toBe('rgba(0, 0, 0, 0)')
expect(styles.leftBorderRadius).toBe('0px')
expect(styles.leftPadding).toBe('0px')
expect(styles.shortcutBackgroundColor).toBe('rgba(0, 0, 0, 0)')
expect(styles.shortcutBorderRadius).toBe('0px')
expect(styles.shortcutColor).toBe(styles.textMuted)
expect(styles.shortcutFontSize).toBe('10px')
expect(styles.shortcutPadding).toBe('0px')
expect(styles.subtitleDisplay).toBe('none')
expect(styles.subtitleText).toBe('')
}
async function readSlashMenuIconOpacities(item: Locator) {
return item.evaluate((node) => {
const item = node as HTMLElement
const regularIcon = item.querySelector<HTMLElement>('.tolaria-slash-menu-icon__regular')!
const fillIcon = item.querySelector<HTMLElement>('.tolaria-slash-menu-icon__fill')!
return {
fillOpacity: getComputedStyle(fillIcon).opacity,
regularOpacity: getComputedStyle(regularIcon).opacity,
}
})
}
test.describe('BlockNote slash menu styling', () => {
test.beforeEach(() => {
tempVaultDir = createFixtureVaultCopy()
@@ -21,29 +119,22 @@ test.describe('BlockNote slash menu styling', () => {
test('slash menu keeps Mantine styling tokens in the editor', async ({ page }) => {
await openAlphaProject(page)
await page.locator('.bn-editor').click()
await page.keyboard.type('/')
const menu = await openSlashMenu(page)
expectMenuChrome(await readMenuStyles(menu))
await expect(menu.getByText('Subheadings', { exact: true })).toHaveCount(0)
await expect(menu.getByText(/^Heading [4-6]$/)).toHaveCount(0)
const secondItem = menu.locator('.bn-suggestion-menu-item').nth(1)
await expect(secondItem.locator('.tolaria-slash-menu-icon')).toBeVisible()
const menu = page.locator('.bn-suggestion-menu')
await expect(menu).toBeVisible({ timeout: 5_000 })
const itemStyles = await readSlashMenuItemStyles(secondItem)
expectSimplifiedItemStyles(itemStyles)
expect(itemStyles.regularOpacity).toBe('1')
expect(itemStyles.fillOpacity).toBe('0')
const menuStyles = await menu.evaluate((node) => {
const style = getComputedStyle(node)
return {
backgroundColor: style.backgroundColor,
borderRadius: style.borderRadius,
boxShadow: style.boxShadow,
spacing: style.getPropertyValue('--mantine-spacing-sm').trim(),
radius: style.getPropertyValue('--mantine-radius-default').trim(),
shadow: style.getPropertyValue('--mantine-shadow-md').trim(),
}
await secondItem.hover()
expect(await readSlashMenuIconOpacities(secondItem)).toEqual({
fillOpacity: '1',
regularOpacity: '0',
})
expect(menuStyles.backgroundColor).not.toBe('rgba(0, 0, 0, 0)')
expect(menuStyles.borderRadius).not.toBe('0px')
expect(menuStyles.boxShadow).not.toBe('none')
expect(menuStyles.spacing).not.toBe('')
expect(menuStyles.radius).not.toBe('')
expect(menuStyles.shadow).not.toBe('')
})
})