From 048d27243d2c5282bb08e74d7b35c3cf4a023dcf Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sat, 2 May 2026 16:39:43 +0200 Subject: [PATCH] fix: refine slash command menu --- src/components/Editor.css | 88 +++++++++++- .../tolariaEditorFormatting.test.tsx | 57 ++++++-- .../tolariaEditorFormattingConfig.ts | 122 ++++++++++------ src/hooks/useBuildNumber.test.ts | 25 ++++ src/hooks/useBuildNumber.ts | 16 ++- .../blocknote-slash-menu-styling.spec.ts | 135 +++++++++++++++--- 6 files changed, 358 insertions(+), 85 deletions(-) diff --git a/src/components/Editor.css b/src/components/Editor.css index 0bb0ad48..770758be 100644 --- a/src/components/Editor.css +++ b/src/components/Editor.css @@ -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 ============================================= */ diff --git a/src/components/tolariaEditorFormatting.test.tsx b/src/components/tolariaEditorFormatting.test.tsx index ae10c884..a5871e89 100644 --- a/src/components/tolariaEditorFormatting.test.tsx +++ b/src/components/tolariaEditorFormatting.test.tsx @@ -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', + ]) }) }) diff --git a/src/components/tolariaEditorFormattingConfig.ts b/src/components/tolariaEditorFormattingConfig.ts index 41ee4485..9c4851eb 100644 --- a/src/components/tolariaEditorFormattingConfig.ts +++ b/src/components/tolariaEditorFormattingConfig.ts @@ -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 - 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> = { - 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> = { + 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( 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[] } diff --git a/src/hooks/useBuildNumber.test.ts b/src/hooks/useBuildNumber.test.ts index 12fc20f5..52cd0d0c 100644 --- a/src/hooks/useBuildNumber.test.ts +++ b/src/hooks/useBuildNumber.test.ts @@ -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() + }) }) diff --git a/src/hooks/useBuildNumber.ts b/src/hooks/useBuildNumber.ts index 5bc6fc12..0c41f2b2 100644 --- a/src/hooks/useBuildNumber.ts +++ b/src/hooks/useBuildNumber.ts @@ -10,9 +10,19 @@ export function useBuildNumber(): string | undefined { const [buildNumber, setBuildNumber] = useState() useEffect(() => { - tauriCall('get_build_number').then(setBuildNumber).catch(() => { - setBuildNumber('b?') - }) + let mounted = true + + tauriCall('get_build_number') + .then((value) => { + if (mounted) setBuildNumber(value) + }) + .catch(() => { + if (mounted) setBuildNumber('b?') + }) + + return () => { + mounted = false + } }, []) return buildNumber diff --git a/tests/smoke/blocknote-slash-menu-styling.spec.ts b/tests/smoke/blocknote-slash-menu-styling.spec.ts index c2aabacb..c7ae9994 100644 --- a/tests/smoke/blocknote-slash-menu-styling.spec.ts +++ b/tests/smoke/blocknote-slash-menu-styling.spec.ts @@ -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>) { + 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( + '.bn-mt-suggestion-menu-item-section[data-position="left"]', + )! + const shortcut = item.querySelector('.mantine-Badge-root')! + const shortcutLabel = item.querySelector('.mantine-Badge-label')! + const subtitle = item.querySelector('.bn-mt-suggestion-menu-item-subtitle')! + const regularIcon = item.querySelector('.tolaria-slash-menu-icon__regular')! + const fillIcon = item.querySelector('.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>, +) { + 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('.tolaria-slash-menu-icon__regular')! + const fillIcon = item.querySelector('.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('') }) })