fix: restore editor code block highlighting

This commit is contained in:
lucaronin
2026-04-13 22:00:50 +02:00
parent 2a353d5749
commit f50d9127d1
11 changed files with 196 additions and 14 deletions

View File

@@ -415,6 +415,15 @@ const WikiLink = createReactInlineContentSpec(
)
```
### Code Block Highlighting
Defined in `src/components/editorSchema.tsx` and styled in `src/components/EditorTheme.css`:
- The schema overrides BlockNote's default `codeBlock` spec with `createCodeBlockSpec({ ...codeBlockOptions, defaultLanguage: "text" })` from `@blocknote/code-block`.
- Fenced code blocks now use BlockNote's supported Shiki-backed highlighter path, which renders `.shiki` token spans directly inside the editor DOM.
- Tolaria keeps `defaultLanguage: "text"` so unlabeled code blocks do not silently become JavaScript while still supporting the packaged language aliases such as `ts``typescript`.
- Inline-code chip styling remains scoped to `.bn-inline-content code`, so fenced `pre > code` nodes keep BlockNote's dark shell instead of inheriting the muted inline surface.
### Markdown-to-BlockNote Pipeline
```mermaid

View File

@@ -89,6 +89,7 @@ flowchart LR
| Desktop shell | Tauri v2 | 2.10.0 |
| Frontend | React + TypeScript | React 19, TS 5.9 |
| Editor | BlockNote | 0.46.2 |
| Code block highlighting | @blocknote/code-block | 0.46.2 |
| Raw editor | CodeMirror 6 | - |
| Styling | Tailwind CSS v4 + CSS variables | 4.1.18 |
| UI primitives | Radix UI + shadcn/ui | - |
@@ -177,7 +178,7 @@ flowchart TD
- **Sidebar** (150-400px, resizable): Top-level filters (All Notes, Changes, Pulse) and collapsible type-based section groups. Each type can have a custom icon, color, sort, and visibility set via its type document in `type/`.
- **Note List / Pulse View** (200-500px, resizable): When a section group or filter is selected, shows filtered notes with snippets, modified dates, and status indicators. When Pulse filter is active, shows `PulseView` — a chronological git activity feed grouped by day.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, BlockNote rich text editor with wikilink support. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, `useEditorSave`, `useRawMode`. Navigation history (Cmd+[/]) replaces tabs.
- **Editor** (flex, fills remaining space): Single note open at a time (no tabs — see ADR-0003). Breadcrumb bar with word count, BlockNote rich text editor with wikilink support, and schema-backed fenced code block highlighting via `@blocknote/code-block`. Can toggle to diff view (modified files) or raw CodeMirror view. Decomposed into `Editor` (orchestrator), `EditorContent`, `EditorRightPanel`, `SingleEditorView`, with hooks `useDiffMode`, `useEditorFocus`, `useEditorSave`, and `useRawMode`. Navigation history (Cmd+[/]) replaces tabs.
- **Inspector / AI Agent** (200-500px or 40px collapsed): Toggles between Inspector (frontmatter, relationships, instances, backlinks, git history) and AI Agent panel (the selected CLI agent with tool execution). The Sparkle icon in the breadcrumb bar toggles between them. Per-note `icon` is a suggested Inspector property and the command palette's "Set Note Icon" action opens that field directly. When viewing a Type note, the Inspector shows an **Instances** section listing all notes of that type (sorted by modified_at desc, capped at 50).
Panels are separated by `ResizeHandle` components that support drag-to-resize.

View File

@@ -0,0 +1,29 @@
---
type: ADR
id: "0063"
title: "BlockNote code-block package for editor syntax highlighting"
status: active
date: 2026-04-13
---
## Context
Tolaria uses BlockNote for rich-text editing. Fenced code blocks already render on BlockNote's dark `pre > code` surface, but they were missing syntax highlighting and inherited the muted inline-code chip background from the global `code` selector in `EditorTheme.css`. The QA expectation is a dark code block with highlighted tokens and light code text, without regressing inline-code styling elsewhere in the editor.
BlockNote documents syntax highlighting as a schema concern: replace the default `codeBlock` spec with `createCodeBlockSpec(...)` and provide a Shiki highlighter. Tolaria also needs to preserve the existing default behavior for unlabeled code blocks, which should stay plain text instead of defaulting to JavaScript.
## Decision
**Tolaria overrides the default BlockNote `codeBlock` spec with `@blocknote/code-block`, keeps `defaultLanguage: "text"`, and scopes the muted inline-code chip styling away from fenced code blocks.**
## Options considered
- **Use `@blocknote/code-block`** (chosen): first-party BlockNote path, ships supported language aliases and a bundled Shiki highlighter, renders `.shiki` token spans in-editor, and avoids maintaining a parallel ProseMirror plugin integration.
- **Use a custom `createCodeBlockSpec({ createHighlighter })` bundle**: also valid, but Tolaria does not need a custom language/theme bundle beyond BlockNote's packaged setup right now.
- **Keep BlockNote defaults and only fix CSS**: removes the nested gray chip bug, but leaves fenced code blocks unhighlighted and fails the product requirement.
## Consequences
Tolaria's highlighting now lives in the editor schema instead of an editor-side plugin hook. `src/components/editorSchema.tsx` swaps in `createCodeBlockSpec({ ...codeBlockOptions, defaultLanguage: "text" })`, which adds BlockNote's language selector plus Shiki token spans for supported fenced blocks. `EditorTheme.css` continues to keep the `pre > code` background transparent so BlockNote's dark code-block shell remains intact.
The tradeoff is one new first-party dependency and BlockNote's bundled language menu inside code blocks. If Tolaria later needs a narrower bundle, custom themes, or export-time highlighting parity, this ADR should be superseded with a custom Shiki bundle decision.

View File

@@ -118,3 +118,4 @@ proposed → active → superseded
| [0060](0060-network-aware-ui-gating-for-remote-features.md) | Network-aware UI gating for remote-dependent features | active |
| [0061](0061-ai-prompt-bridge-event-bus.md) | AI prompt bridge — module-level event bus for cross-component prompt routing | active |
| [0062](0062-selectable-cli-ai-agents.md) | Selectable CLI AI agents with a shared panel architecture | active |
| [0063](0063-blocknote-code-block-package-for-editor-highlighting.md) | BlockNote code-block package for editor syntax highlighting | active |

View File

@@ -21,6 +21,7 @@
},
"dependencies": {
"@anthropic-ai/sdk": "^0.78.0",
"@blocknote/code-block": "^0.46.2",
"@blocknote/core": "^0.46.2",
"@blocknote/mantine": "^0.46.2",
"@blocknote/react": "^0.46.2",
@@ -54,7 +55,6 @@
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"katex": "^0.16.28",
"lowlight": "^3.3.0",
"lucide-react": "^0.564.0",
"posthog-js": "^1.363.5",
"radix-ui": "^1.4.3",

104
pnpm-lock.yaml generated
View File

@@ -22,6 +22,9 @@ importers:
'@anthropic-ai/sdk':
specifier: ^0.78.0
version: 0.78.0(zod@4.3.6)
'@blocknote/code-block':
specifier: ^0.46.2
version: 0.46.2(@blocknote/core@0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))
'@blocknote/core':
specifier: ^0.46.2
version: 0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
@@ -121,9 +124,6 @@ importers:
katex:
specifier: ^0.16.28
version: 0.16.28
lowlight:
specifier: ^3.3.0
version: 3.3.0
lucide-react:
specifier: ^0.564.0
version: 0.564.0(react@19.2.4)
@@ -369,6 +369,11 @@ packages:
resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
engines: {node: '>=18'}
'@blocknote/code-block@0.46.2':
resolution: {integrity: sha512-/jLxECkbJjIx4K0zR0uVVz5AKt/08YGEseYuDun6ACHvW7027YRQIqHThXfMwZuRujijraoPHI3gbLXCaaRRRQ==}
peerDependencies:
'@blocknote/core': ^0.46.2
'@blocknote/core@0.46.2':
resolution: {integrity: sha512-p+QFwrTQfQC08f7JoFg9uuwbAUkGhRakCXhHhY63Wd8ARS0ktHfc9eUnJderXQTEtWfHjACjZOyt2FifX6Lalw==}
peerDependencies:
@@ -1826,9 +1831,28 @@ packages:
peerDependencies:
react: ^16.14.0 || 17.x || 18.x || 19.x
'@shikijs/core@3.23.0':
resolution: {integrity: sha512-NSWQz0riNb67xthdm5br6lAkvpDJRTgB36fxlo37ZzM2yq0PQFFzbd8psqC2XMPgCzo1fW6cVi18+ArJ44wqgA==}
'@shikijs/engine-javascript@3.23.0':
resolution: {integrity: sha512-aHt9eiGFobmWR5uqJUViySI1bHMqrAgamWE1TYSUoftkAeCCAiGawPMwM+VCadylQtF4V3VNOZ5LmfItH5f3yA==}
'@shikijs/langs-precompiled@3.23.0':
resolution: {integrity: sha512-NVFZ+aosM+a90oUmlM8pRZkVZPFgMabCPicOaP0xklM7Lo6U6J9JqKs+ld1Z9K85k1mfQITc6r7VfZiifitPDA==}
engines: {node: '>=20'}
'@shikijs/langs@3.23.0':
resolution: {integrity: sha512-2Ep4W3Re5aB1/62RSYQInK9mM3HsLeB91cHqznAJMuylqjzNVAVCMnNWRHFtcNHXsoNRayP9z1qj4Sq3nMqYXg==}
'@shikijs/themes@3.23.0':
resolution: {integrity: sha512-5qySYa1ZgAT18HR/ypENL9cUSGOeI2x+4IvYJu4JgVJdizn6kG4ia5Q1jDEOi7gTbN4RbuYtmHh0W3eccOrjMA==}
'@shikijs/types@3.22.0':
resolution: {integrity: sha512-491iAekgKDBFE67z70Ok5a8KBMsQ2IJwOWw3us/7ffQkIBCyOQfm/aNwVMBUriP02QshIfgHCBSIYAl3u2eWjg==}
'@shikijs/types@3.23.0':
resolution: {integrity: sha512-3JZ5HXOZfYjsYSk0yPwBrkupyYSLpAE26Qc0HLghhZNGTZg/SKxXIIgoxOpmmeQP0RRSDJTk1/vPfw9tbw+jSQ==}
'@shikijs/vscode-textmate@10.0.2':
resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==}
@@ -3464,6 +3488,12 @@ packages:
once@1.4.0:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
oniguruma-parser@0.12.1:
resolution: {integrity: sha512-8Unqkvk1RYc6yq2WBYRj4hdnsAxVze8i7iPfQr8e4uSP3tRv0rpZcbGUDvxfQQcdwHt/e9PrMvGCsa8OqG9X3w==}
oniguruma-to-es@4.3.5:
resolution: {integrity: sha512-Zjygswjpsewa0NLTsiizVuMQZbp0MDyM6lIt66OxsF21npUDlzpHi1Mgb/qhQdkb+dWFTzJmFbEWdvZgRho8eQ==}
optionator@0.9.4:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
@@ -3774,6 +3804,15 @@ packages:
resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==}
engines: {node: '>=8'}
regex-recursion@6.0.2:
resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==}
regex-utilities@2.3.0:
resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==}
regex@6.1.0:
resolution: {integrity: sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==}
rehype-format@5.0.1:
resolution: {integrity: sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ==}
@@ -4472,6 +4511,16 @@ snapshots:
'@bcoe/v8-coverage@1.0.2': {}
'@blocknote/code-block@0.46.2(@blocknote/core@0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0))':
dependencies:
'@blocknote/core': 0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)
'@shikijs/core': 3.23.0
'@shikijs/engine-javascript': 3.23.0
'@shikijs/langs': 3.23.0
'@shikijs/langs-precompiled': 3.23.0
'@shikijs/themes': 3.23.0
'@shikijs/types': 3.22.0
'@blocknote/core@0.46.2(patch_hash=9a6d8a53058a8b6127326d40e8188f6b132b9b9e364b87e88e1c9aa3a1867aec)(@types/hast@3.0.4)(highlight.js@11.11.1)(lowlight@3.3.0)':
dependencies:
'@emoji-mart/data': 1.2.1
@@ -5992,11 +6041,42 @@ snapshots:
'@sentry/core': 10.47.0
react: 19.2.4
'@shikijs/core@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
hast-util-to-html: 9.0.5
'@shikijs/engine-javascript@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/vscode-textmate': 10.0.2
oniguruma-to-es: 4.3.5
'@shikijs/langs-precompiled@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
oniguruma-to-es: 4.3.5
'@shikijs/langs@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/themes@3.23.0':
dependencies:
'@shikijs/types': 3.23.0
'@shikijs/types@3.22.0':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/types@3.23.0':
dependencies:
'@shikijs/vscode-textmate': 10.0.2
'@types/hast': 3.0.4
'@shikijs/vscode-textmate@10.0.2': {}
'@standard-schema/spec@1.1.0': {}
@@ -7918,6 +7998,14 @@ snapshots:
dependencies:
wrappy: 1.0.2
oniguruma-parser@0.12.1: {}
oniguruma-to-es@4.3.5:
dependencies:
oniguruma-parser: 0.12.1
regex: 6.1.0
regex-recursion: 6.0.2
optionator@0.9.4:
dependencies:
deep-is: 0.1.4
@@ -8324,6 +8412,16 @@ snapshots:
indent-string: 4.0.0
strip-indent: 3.0.0
regex-recursion@6.0.2:
dependencies:
regex-utilities: 2.3.0
regex-utilities@2.3.0: {}
regex@6.1.0:
dependencies:
regex-utilities: 2.3.0
rehype-format@5.0.1:
dependencies:
'@types/hast': 3.0.4

View File

@@ -94,11 +94,16 @@ vi.mock('./utils/ai-chat', () => ({
// Mock BlockNote components (they need DOM APIs not available in jsdom)
vi.mock('@blocknote/core', () => ({
BlockNoteSchema: { create: () => ({}) },
BlockNoteSchema: { create: () => ({ extend: () => ({}) }) },
createCodeBlockSpec: vi.fn(() => ({})),
defaultInlineContentSpecs: {},
filterSuggestionItems: vi.fn(() => []),
}))
vi.mock('@blocknote/code-block', () => ({
codeBlockOptions: {},
}))
vi.mock('@blocknote/core/extensions', () => ({
filterSuggestionItems: vi.fn(() => []),
}))

View File

@@ -21,11 +21,16 @@ const mockEditor = vi.hoisted(() => ({
// Mock BlockNote components
vi.mock('@blocknote/core', () => ({
BlockNoteSchema: { create: () => ({}) },
BlockNoteSchema: { create: () => ({ extend: () => ({}) }) },
createCodeBlockSpec: vi.fn(() => ({})),
defaultInlineContentSpecs: {},
filterSuggestionItems: vi.fn(() => []),
}))
vi.mock('@blocknote/code-block', () => ({
codeBlockOptions: {},
}))
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock
const mockFilterSuggestionItems = vi.fn((...args: any[]) => args[0] ?? [])
vi.mock('@blocknote/core/extensions', () => ({

View File

@@ -207,7 +207,7 @@
}
/* --- Inline: code --- */
.editor__blocknote-container .bn-editor code {
.editor__blocknote-container .bn-inline-content code {
font-family: var(--inline-styles-code-font-family);
font-size: var(--inline-styles-code-font-size);
background-color: var(--inline-styles-code-background-color);
@@ -236,7 +236,16 @@
cursor: pointer;
}
/* Code blocks intentionally use BlockNote's default dark styling. */
/* Code blocks intentionally keep BlockNote's dark shell and syntax-highlighted content. */
.editor__blocknote-container .bn-block-content[data-content-type="codeBlock"] pre code {
background-color: transparent;
padding: 0;
border-radius: 0;
}
.editor__blocknote-container .bn-block-content[data-content-type="codeBlock"] pre code .shiki {
background-color: transparent !important;
}
/* --- Blockquote --- */
.editor__blocknote-container [data-content-type="blockquote"],

View File

@@ -1,5 +1,6 @@
/* eslint-disable react-refresh/only-export-components -- module-level schema, not a component file */
import { BlockNoteSchema, defaultInlineContentSpecs } from '@blocknote/core'
import { createCodeBlockSpec, BlockNoteSchema, defaultInlineContentSpecs } from '@blocknote/core'
import { codeBlockOptions } from '@blocknote/code-block'
import { createReactInlineContentSpec } from '@blocknote/react'
import { resolveWikilinkColor as resolveColor } from '../utils/wikilinkColors'
import { resolveEntry } from '../utils/wikilink'
@@ -56,9 +57,18 @@ export const WikiLink = createReactInlineContentSpec(
}
)
const codeBlock = createCodeBlockSpec({
...codeBlockOptions,
defaultLanguage: 'text',
})
export const schema = BlockNoteSchema.create({
inlineContentSpecs: {
...defaultInlineContentSpecs,
wikilink: WikiLink,
},
}).extend({
blockSpecs: {
codeBlock,
},
})

View File

@@ -33,6 +33,10 @@ async function backgroundColor(locator: Locator) {
return locator.evaluate((element) => getComputedStyle(element).backgroundColor)
}
async function textColor(locator: Locator) {
return locator.evaluate((element) => getComputedStyle(element).color)
}
test.describe('Editor code block theme', () => {
let tempVaultDir: string
@@ -47,15 +51,26 @@ test.describe('Editor code block theme', () => {
test('fenced code blocks keep the dark BlockNote surface while inline code stays muted', async ({ page }) => {
await openFixtureVault(page, tempVaultDir)
await page.getByText(CODE_NOTE_TITLE, { exact: true }).first().click()
const noteList = page.locator('[data-testid="note-list-container"]')
const noteItem = noteList.getByText(CODE_NOTE_TITLE, { exact: true })
await expect(noteItem).toBeVisible({ timeout: 10_000 })
await noteItem.click()
const inlineCode = page.locator('.bn-inline-content code').first()
const inlineCode = page
.locator('[data-content-type="paragraph"] [data-style-type="code"], [data-content-type="paragraph"] code')
.first()
const codeBlock = page.locator('.bn-block-content[data-content-type="codeBlock"]').first()
const fencedCode = codeBlock.locator('pre code').first()
const highlightedToken = codeBlock.locator('.shiki').first()
await expect(inlineCode).toBeVisible()
await expect(codeBlock).toBeVisible()
await expect(codeBlock).toBeVisible({ timeout: 10_000 })
await expect(inlineCode).toBeVisible({ timeout: 10_000 })
await expect(fencedCode).toBeVisible()
await expect.poll(() => backgroundColor(inlineCode)).toBe('rgb(240, 240, 239)')
await expect.poll(() => backgroundColor(codeBlock)).toBe('rgb(22, 22, 22)')
await expect.poll(() => backgroundColor(fencedCode)).toBe('rgba(0, 0, 0, 0)')
await expect.poll(() => textColor(fencedCode)).toBe('rgb(255, 255, 255)')
await expect(highlightedToken).toBeVisible()
})
})