diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md
index f3a84d0a..058d6da5 100644
--- a/docs/ABSTRACTIONS.md
+++ b/docs/ABSTRACTIONS.md
@@ -532,8 +532,8 @@ Defined in `src/components/editorSchema.tsx` and styled in `src/components/Edito
- 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.
+- Tolaria keeps `defaultLanguage: "text"` so unlabeled code blocks do not silently become JavaScript at creation time. Parsed unlabeled code blocks then run through Tolaria's lightweight language inference, while explicit fence languages and user dropdown choices still win.
+- Inline-code chip styling remains scoped to `.bn-inline-content code`, so fenced `pre > code` nodes keep the dedicated code-block shell instead of inheriting the muted inline surface.
### Markdown Math
@@ -572,7 +572,7 @@ Defined in `src/components/tolariaEditorFormatting.tsx` and `src/components/tola
- Tolaria's formatting-toolbar controller also keeps file/image actions mounted across the tiny hover gap between an image block and the floating toolbar, and while the toolbar itself is hovered, so image controls remain usable instead of collapsing mid-interaction.
- `useImageLightbox` listens for `dblclick` on the rich-editor container and opens `ImageLightbox` only when the event target resolves to a viewable BlockNote image. The target resolver handles media wrappers, ignores image captions/resize controls, missing sources, and tiny tracking-style images, preserving BlockNote's ordinary single-click image selection path.
- The `/` slash menu remains the supported path for markdown-safe block transformations such as headings, quotes, list blocks, Mermaid diagrams, and whiteboards. Tolaria filters out BlockNote's toggle-heading and toggle-list variants because those do not map cleanly to the markdown note model.
-- The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface. Tolaria renders the add-block button outside the drag handle so the handle stays next to the block content. The side menu aligns itself to the rendered text range for the hovered block, so H1/H2 typography, line-height, and theme changes do not need per-heading offsets. Block reordering uses a Tolaria-owned pointer gesture and direct BlockNote block moves instead of HTML5 `DataTransfer`, keeping it independent from Tauri's native file-drop system. Block-handle actions re-resolve the current live BlockNote block before mutating or dragging, so note reloads and sync churn cannot leave controls acting on stale block references.
+- The block-handle side menu keeps only actions that survive Tolaria's markdown round-trip. Delete and table-header toggles remain available; BlockNote's `Colors` submenu is removed because block colors are not part of Tolaria's supported markdown surface. Tolaria renders the add-block button outside the drag handle so the handle stays next to the block content. The side menu aligns itself to the first rendered text line for the hovered block, so H1/H2 typography, line-height, wrapping, and theme changes do not need per-heading offsets. Block reordering uses a Tolaria-owned pointer gesture and direct BlockNote block moves instead of HTML5 `DataTransfer`, keeping it independent from Tauri's native file-drop system. Block-handle actions re-resolve the current live BlockNote block before mutating or dragging, so note reloads and sync churn cannot leave controls acting on stale block references.
- BlockNote's table row/column handles are patched so stale or missing hovered-table state cancels the drag and hides handles instead of throwing. Add/remove row and column actions also validate the table position and cell indexes before resolving a ProseMirror `CellSelection`, so reloads or menu lag cannot turn stale handles into invalid table-selection positions. Browser and native table regressions should exercise row and column dragging plus add-menu actions because the state is tracked per orientation.
- `useNoteWikilinkDrop()` is the shared editor-drop abstraction for dragging note rows into either editor mode. It reads the existing note-retargeting drag payload, resolves the vault-relative stem, and inserts a canonical `[[wikilink]]` without hijacking unrelated plain-text drags.
- `plainTextPaste.ts` is the shared plain-text paste target registry. Rich BlockNote and raw CodeMirror surfaces register focused insertion targets, while ordinary focused text controls use DOM selection replacement, so the `Cmd+Shift+V` command can preserve caret/selection behavior without each surface inventing its own clipboard reader.
diff --git a/src/components/EditorTheme.css b/src/components/EditorTheme.css
index 9f4f8705..ee3b4743 100644
--- a/src/components/EditorTheme.css
+++ b/src/components/EditorTheme.css
@@ -489,6 +489,20 @@
z-index: 4;
}
+.editor__code-block-copy [data-editor-code-copy-button] {
+ background: transparent !important;
+ border-color: transparent !important;
+ box-shadow: none !important;
+ color: var(--editor-code-block-language);
+ padding: 0;
+}
+
+.editor__code-block-copy [data-editor-code-copy-button]:hover,
+.editor__code-block-copy [data-editor-code-copy-button]:focus-visible {
+ background: transparent !important;
+ color: var(--editor-code-block-text);
+}
+
.editor__blocknote-container .bn-block-content[data-content-type="codeBlock"] > pre {
color: inherit;
}
@@ -513,11 +527,6 @@
background-color: transparent !important;
}
-:root:not(.dark) .editor__blocknote-container .bn-block-content[data-content-type="codeBlock"] pre code .shiki,
-[data-theme="light"] .editor__blocknote-container .bn-block-content[data-content-type="codeBlock"] pre code .shiki {
- color: var(--editor-code-block-text) !important;
-}
-
/* --- Blockquote --- */
.editor__blocknote-container [data-content-type="blockquote"],
.editor__blocknote-container blockquote {
diff --git a/src/components/SingleEditorView.tsx b/src/components/SingleEditorView.tsx
index 85f7f8db..1dcbeaa5 100644
--- a/src/components/SingleEditorView.tsx
+++ b/src/components/SingleEditorView.tsx
@@ -469,7 +469,7 @@ function CodeBlockCopyButton({ copyTarget, locale }: { copyTarget: CodeBlockCopy
diff --git a/src/components/codeBlockOptions.ts b/src/components/codeBlockOptions.ts
index e1ad56d1..282b4e77 100644
--- a/src/components/codeBlockOptions.ts
+++ b/src/components/codeBlockOptions.ts
@@ -1,6 +1,11 @@
import { codeBlockOptions } from '@blocknote/code-block'
import type { CodeBlockOptions } from '@blocknote/core'
+const LIGHT_CODE_THEME = 'github-light'
+const DARK_CODE_THEME = 'github-dark'
+
+type TolariaCodeHighlighter = Awaited>>
+
function supportsShikiPrecompiledRegexFlags() {
try {
new RegExp('', 'd')
@@ -11,9 +16,31 @@ function supportsShikiPrecompiledRegexFlags() {
}
}
+function currentCodeBlockTheme() {
+ if (typeof document === 'undefined') return LIGHT_CODE_THEME
+
+ const root = document.documentElement
+ return root.classList.contains('dark') || root.dataset.theme === 'dark'
+ ? DARK_CODE_THEME
+ : LIGHT_CODE_THEME
+}
+
+function prioritizeTheme(themes: string[], theme: string) {
+ return [theme, ...themes.filter((candidate) => candidate !== theme)]
+}
+
+async function createTolariaCodeHighlighter(): Promise {
+ const highlighter = await codeBlockOptions.createHighlighter()
+ return {
+ ...highlighter,
+ getLoadedThemes: () => prioritizeTheme(highlighter.getLoadedThemes(), currentCodeBlockTheme()),
+ }
+}
+
export function createTolariaCodeBlockOptions(): Partial {
const options: Partial = {
...codeBlockOptions,
+ createHighlighter: createTolariaCodeHighlighter,
defaultLanguage: 'text',
}
diff --git a/src/components/editorSchema.webkit.test.ts b/src/components/editorSchema.webkit.test.ts
index 73a07396..6b0a7255 100644
--- a/src/components/editorSchema.webkit.test.ts
+++ b/src/components/editorSchema.webkit.test.ts
@@ -33,11 +33,35 @@ function installLegacyWebKitRegExp() {
}
afterEach(() => {
+ document.documentElement.classList.remove('dark')
+ delete document.documentElement.dataset.theme
restoreRegExpConstructor()
vi.resetModules()
})
describe('editor schema code block highlighting', () => {
+ it('uses the light Shiki theme first in light mode', async () => {
+ vi.resetModules()
+ document.documentElement.classList.remove('dark')
+ document.documentElement.dataset.theme = 'light'
+
+ const { createTolariaCodeBlockOptions } = await import('./codeBlockOptions')
+ const highlighter = await createTolariaCodeBlockOptions().createHighlighter?.()
+
+ expect(highlighter?.getLoadedThemes()[0]).toBe('github-light')
+ })
+
+ it('uses the dark Shiki theme first in dark mode', async () => {
+ vi.resetModules()
+ document.documentElement.classList.add('dark')
+ document.documentElement.dataset.theme = 'dark'
+
+ const { createTolariaCodeBlockOptions } = await import('./codeBlockOptions')
+ const highlighter = await createTolariaCodeBlockOptions().createHighlighter?.()
+
+ expect(highlighter?.getLoadedThemes()[0]).toBe('github-dark')
+ })
+
it('omits the Shiki highlighter when WebKit lacks precompiled regex flags', async () => {
installLegacyWebKitRegExp()
vi.resetModules()
diff --git a/src/components/tolariaBlockNoteSideMenu.tsx b/src/components/tolariaBlockNoteSideMenu.tsx
index fa4fd853..b7b5ba6f 100644
--- a/src/components/tolariaBlockNoteSideMenu.tsx
+++ b/src/components/tolariaBlockNoteSideMenu.tsx
@@ -189,7 +189,9 @@ function blockTextAnchorRect(blockElement: HTMLElement): DOMRect | null {
const ownerDocument = inlineContent.ownerDocument
const range = ownerDocument.createRange()
range.selectNodeContents(inlineContent)
- const textRect = range.getBoundingClientRect()
+ const firstLineRect = Array.from(range.getClientRects())
+ .find((rect) => rect.width > 0 && rect.height > 0)
+ const textRect = firstLineRect ?? range.getBoundingClientRect()
range.detach()
if (textRect.height > 0) return textRect
diff --git a/src/hooks/editorBlockResolution.ts b/src/hooks/editorBlockResolution.ts
index bbd996dd..8e44eee2 100644
--- a/src/hooks/editorBlockResolution.ts
+++ b/src/hooks/editorBlockResolution.ts
@@ -5,6 +5,7 @@ import { preProcessMermaidMarkdown, injectMermaidInBlocks } from '../utils/merma
import { preProcessTldrawMarkdown, injectTldrawInBlocks } from '../utils/tldrawMarkdown'
import { resolveImageUrls } from '../utils/vaultImages'
import { repairMalformedEditorBlocks } from './editorBlockRepair'
+import { inferCodeBlockLanguages } from '../utils/codeBlockLanguage'
import {
blankParagraphBlocks,
extractEditorBody,
@@ -150,7 +151,9 @@ function injectEditorMarkdownBlocks(blocks: EditorBlocks): EditorBlocks {
function repairParsedMarkdownBlocks(parsed: MarkdownParseResult): EditorBlocks {
const parseSafeBlocks = repairMalformedEditorBlocks(parsed.blocks) as EditorBlocks
if (parsed.usedSourceFallback) return parseSafeBlocks
- return repairMalformedEditorBlocks(injectEditorMarkdownBlocks(parseSafeBlocks)) as EditorBlocks
+ return inferCodeBlockLanguages(
+ repairMalformedEditorBlocks(injectEditorMarkdownBlocks(parseSafeBlocks)) as EditorBlocks,
+ ) as EditorBlocks
}
export async function resolveBlocksForTarget(
diff --git a/src/index.css b/src/index.css
index fd2991b3..e0d20363 100644
--- a/src/index.css
+++ b/src/index.css
@@ -115,8 +115,8 @@
--diff-removed-text: #F44336;
--diff-removed-bg: rgba(244, 67, 54, 0.12);
--diff-hunk-bg: rgba(33, 150, 243, 0.08);
- --editor-code-block-background: #F5F8FF;
- --editor-code-block-border: #DDE8FF;
+ --editor-code-block-background: var(--surface-sidebar);
+ --editor-code-block-border: var(--border-subtle);
--editor-code-block-text: var(--text-primary);
--editor-code-block-language: var(--text-secondary);
diff --git a/src/utils/codeBlockLanguage.test.ts b/src/utils/codeBlockLanguage.test.ts
new file mode 100644
index 00000000..03d2357e
--- /dev/null
+++ b/src/utils/codeBlockLanguage.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest'
+import { inferCodeBlockLanguage, inferCodeBlockLanguages } from './codeBlockLanguage'
+
+describe('code block language inference', () => {
+ it('detects TypeScript interface snippets', () => {
+ expect(inferCodeBlockLanguage([
+ 'interface VaultEntry {',
+ ' path: string;',
+ ' title: string;',
+ ' status: string | null;',
+ '}',
+ ].join('\n'))).toBe('typescript')
+ })
+
+ it('detects JSON before JavaScript-like punctuation', () => {
+ expect(inferCodeBlockLanguage('{ "id": "Demo", "count": 1 }')).toBe('json')
+ })
+
+ it('preserves explicit user-selected code block languages', () => {
+ const blocks = inferCodeBlockLanguages([{
+ type: 'codeBlock',
+ props: { language: 'python' },
+ content: [{ type: 'text', text: 'interface Demo { value: string }' }],
+ children: [],
+ }])
+
+ expect(blocks[0]).toMatchObject({
+ props: { language: 'python' },
+ })
+ })
+
+ it('updates unlabeled code blocks from plain text to the inferred language', () => {
+ const blocks = inferCodeBlockLanguages([{
+ type: 'codeBlock',
+ props: { language: 'text' },
+ content: [{ type: 'text', text: 'const total: number = 1' }],
+ children: [],
+ }])
+
+ expect(blocks[0]).toMatchObject({
+ props: { language: 'typescript' },
+ })
+ })
+})
diff --git a/src/utils/codeBlockLanguage.ts b/src/utils/codeBlockLanguage.ts
new file mode 100644
index 00000000..13436985
--- /dev/null
+++ b/src/utils/codeBlockLanguage.ts
@@ -0,0 +1,101 @@
+type UnknownRecord = Record
+
+const PLAIN_TEXT_LANGUAGES = new Set(['', 'none', 'plain', 'plaintext', 'text', 'txt'])
+const LANGUAGE_PATTERNS: Array<[string, RegExp]> = [
+ ['html', /^\s*<[/!A-Za-z][\s\S]*>\s*$/u],
+ ['python', /^\s*(?:def|class)\s+\w+.*:\s*$/mu],
+ ['python', /^\s*(?:from\s+\w+(?:\.\w+)*\s+import|import\s+\w+)/mu],
+ ['shellscript', /^\s*(?:#!.*\b(?:bash|sh|zsh)\b|(?:pnpm|npm|yarn|git|cd|echo|export)\b)/mu],
+ ['typescript', /\b(?:interface|type|enum|implements|readonly|namespace|declare)\b/u],
+ ['typescript', /\b(?:const|let|var|function)\s+\w+\s*(?:<[^>]+>)?\([^)]*:\s*[^)]*\)/u],
+ ['typescript', /:\s*(?:string|number|boolean|unknown|never|void|null|undefined|Record<|[A-Z]\w*(?:\[\])?)\b/u],
+ ['javascript', /\b(?:import|export|const|let|var|function|return)\b|=>/u],
+ ['sql', /^\s*(?:SELECT|WITH|INSERT|UPDATE|DELETE)\b[\s\S]*\bFROM\b/iu],
+ ['yaml', /^\s*[\w-]+\s*:\s*[\s\S]*$/u],
+]
+
+function isRecord(value: unknown): value is UnknownRecord {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+function textFromInlineContent(content: unknown): string {
+ if (typeof content === 'string') return content
+ if (!Array.isArray(content)) return ''
+
+ return content.map((item) => {
+ if (typeof item === 'string') return item
+ if (!isRecord(item)) return ''
+ if (typeof item.text === 'string') return item.text
+ return textFromInlineContent(item.content)
+ }).join('')
+}
+
+function normalizedLanguage(props: unknown): string {
+ if (!isRecord(props) || typeof props.language !== 'string') return ''
+ return props.language.trim().toLowerCase()
+}
+
+function isPlainTextLanguage(language: string): boolean {
+ return PLAIN_TEXT_LANGUAGES.has(language)
+}
+
+function isJson(source: string): boolean {
+ if (!/^[{[]/u.test(source)) return false
+
+ try {
+ JSON.parse(source)
+ return true
+ } catch {
+ return false
+ }
+}
+
+export function inferCodeBlockLanguage(source: string): string | null {
+ const trimmed = source.trim()
+ if (!trimmed) return null
+ if (isJson(trimmed)) return 'json'
+ return LANGUAGE_PATTERNS.find(([, pattern]) => pattern.test(trimmed))?.[0] ?? null
+}
+
+function inferChildren(children: unknown): unknown {
+ return Array.isArray(children) ? children.map(inferBlock) : children
+}
+
+function withInferredChildren(block: UnknownRecord, children: unknown): UnknownRecord {
+ return children === block.children ? block : { ...block, children }
+}
+
+function shouldInferLanguage(block: UnknownRecord): boolean {
+ return block.type === 'codeBlock' && isPlainTextLanguage(normalizedLanguage(block.props))
+}
+
+function withInferredLanguage(block: UnknownRecord, children: unknown, language: string): UnknownRecord {
+ const props = isRecord(block.props) ? block.props : {}
+ return {
+ ...block,
+ children,
+ props: {
+ ...props,
+ language,
+ },
+ }
+}
+
+function inferBlockLanguage(block: UnknownRecord): UnknownRecord {
+ const children = inferChildren(block.children)
+
+ if (!shouldInferLanguage(block)) return withInferredChildren(block, children)
+
+ const inferred = inferCodeBlockLanguage(textFromInlineContent(block.content))
+ if (!inferred) return withInferredChildren(block, children)
+
+ return withInferredLanguage(block, children, inferred)
+}
+
+function inferBlock(block: unknown): unknown {
+ return isRecord(block) ? inferBlockLanguage(block) : block
+}
+
+export function inferCodeBlockLanguages(blocks: unknown[]): unknown[] {
+ return blocks.map(inferBlock)
+}
diff --git a/tests/smoke/editor-block-reorder.spec.ts b/tests/smoke/editor-block-reorder.spec.ts
index ec1451c1..abfa795c 100644
--- a/tests/smoke/editor-block-reorder.spec.ts
+++ b/tests/smoke/editor-block-reorder.spec.ts
@@ -51,7 +51,8 @@ async function expectSideMenuCenteredOnText(page: Page, text: string): Promise rect.width > 0 && rect.height > 0) ?? range.getBoundingClientRect()
range.detach()
const sideMenuRect = sideMenu.getBoundingClientRect()
@@ -64,6 +65,41 @@ async function expectSideMenuCenteredOnText(page: Page, text: string): Promise {
+ const block = await blockOuterForText(page, text)
+ await block.hover()
+ await expect(page.locator('.bn-side-menu')).toBeVisible({ timeout: 5_000 })
+
+ const metrics = await block.evaluate((blockElement) => {
+ const content = blockElement.querySelector('.bn-block-content')
+ const inlineContent = content?.querySelector('.bn-inline-content') ?? content
+ const sideMenu = document.querySelector('.bn-side-menu')
+ if (!inlineContent || !sideMenu) return null
+
+ const range = document.createRange()
+ range.selectNodeContents(inlineContent)
+ const lineRects = Array.from(range.getClientRects())
+ .filter((rect) => rect.width > 0 && rect.height > 0)
+ const textRect = range.getBoundingClientRect()
+ range.detach()
+ if (lineRects.length < 2 || textRect.height <= lineRects[0].height) return null
+
+ const firstLineCenter = lineRects[0].top + lineRects[0].height / 2
+ const fullTextCenter = textRect.top + textRect.height / 2
+ const sideMenuRect = sideMenu.getBoundingClientRect()
+ const sideMenuCenter = sideMenuRect.top + sideMenuRect.height / 2
+
+ return {
+ firstLineDelta: Math.abs(sideMenuCenter - firstLineCenter),
+ fullTextDelta: Math.abs(sideMenuCenter - fullTextCenter),
+ }
+ })
+
+ expect(metrics).not.toBeNull()
+ expect(metrics!.firstLineDelta).toBeLessThanOrEqual(2)
+ expect(metrics!.fullTextDelta).toBeGreaterThan(8)
+}
+
async function dragHandleToBlock(page: Page, handle: Locator, targetBlock: Locator): Promise {
const handleBox = await handle.boundingBox()
const targetBox = await targetBlock.boundingBox()
@@ -113,3 +149,19 @@ test('dragging the left block handle reorders editor blocks', async ({ page }) =
await expect.poll(async () => editor.textContent()).toMatch(/Alpha Project[\s\S]*Notes[\s\S]*This is a test project/)
})
+
+test('left block handle aligns with the first line of wrapped text', async ({ page }) => {
+ await page.setViewportSize({ width: 760, height: 720 })
+ await page.getByText('Alpha Project', { exact: true }).first().click()
+ const editor = page.locator('.bn-editor')
+ await expect(editor).toBeVisible({ timeout: 5_000 })
+
+ await page.addStyleTag({
+ content: '.bn-editor { max-width: 320px !important; }',
+ })
+
+ await expectSideMenuCenteredOnFirstTextLine(
+ page,
+ 'This is a test project that references other notes.',
+ )
+})