From 5215754ffa0735efe166fa050855c7d7109b9de6 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Tue, 12 May 2026 18:21:26 +0200 Subject: [PATCH] feat: improve editor code blocks --- src/components/EditorTheme.css | 42 +++++++++- src/components/codeBlockOptions.ts | 52 +++++++++++- src/components/editorSchema.tsx | 5 +- src/components/editorSchema.webkit.test.ts | 37 +++++++++ src/main.tsx | 2 + src/utils/codeBlockEnhancements.test.ts | 87 +++++++++++++++++++++ src/utils/codeBlockEnhancements.ts | 83 ++++++++++++++++++++ tests/smoke/editor-code-block-theme.spec.ts | 32 +++++++- 8 files changed, 332 insertions(+), 8 deletions(-) create mode 100644 src/utils/codeBlockEnhancements.test.ts create mode 100644 src/utils/codeBlockEnhancements.ts diff --git a/src/components/EditorTheme.css b/src/components/EditorTheme.css index cc497c57..3be198ca 100644 --- a/src/components/EditorTheme.css +++ b/src/components/EditorTheme.css @@ -488,6 +488,10 @@ background-color: var(--editor-code-block-background) !important; border: 1px solid var(--editor-code-block-border); color: var(--editor-code-block-text) !important; + column-gap: 12px; + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + overflow-x: hidden; position: relative; } @@ -510,8 +514,34 @@ color: var(--editor-code-block-text); } +.editor__blocknote-container .bn-block-content[data-content-type="codeBlock"]::before { + border-right: 1px solid color-mix(in srgb, var(--editor-code-block-language) 28%, transparent); + color: var(--editor-code-block-language); + content: attr(data-line-numbers); + font: inherit; + grid-column: 1; + grid-row: 2; + line-height: inherit; + min-width: 2ch; + padding-right: 10px; + pointer-events: none; + text-align: right; + user-select: none; + white-space: pre; +} + .editor__blocknote-container .bn-block-content[data-content-type="codeBlock"] > pre { color: inherit; + grid-column: 2; + grid-row: 2; + min-width: 0; + overflow-x: hidden; + white-space: pre-wrap; +} + +.editor__blocknote-container .bn-block-content[data-content-type="codeBlock"] > div { + grid-column: 1 / -1; + grid-row: 1; } .editor__blocknote-container .bn-block-content[data-content-type="codeBlock"] > div > select { @@ -525,13 +555,21 @@ .editor__blocknote-container .bn-block-content[data-content-type="codeBlock"] pre code { background-color: transparent; - color: inherit; - padding: 0; border-radius: 0; + color: inherit; + display: block; + grid-column: 2; + min-width: 0; + overflow-wrap: anywhere; + padding: 0; + white-space: pre-wrap; } .editor__blocknote-container .bn-block-content[data-content-type="codeBlock"] pre code .shiki { background-color: transparent !important; + color: inherit !important; + overflow-wrap: anywhere; + white-space: pre-wrap !important; } /* --- Blockquote --- */ diff --git a/src/components/codeBlockOptions.ts b/src/components/codeBlockOptions.ts index 271b6f82..15c4194a 100644 --- a/src/components/codeBlockOptions.ts +++ b/src/components/codeBlockOptions.ts @@ -1,6 +1,7 @@ import { codeBlockOptions } from '@blocknote/code-block' -import type { CodeBlockOptions } from '@blocknote/core' +import { createCodeBlockSpec, type CodeBlockOptions } from '@blocknote/core' import { supportsModernRegexFeatures } from '../utils/regexCapabilities' +import { lineNumbersForText } from '../utils/codeBlockEnhancements' const LIGHT_CODE_THEME = 'github-light' const DARK_CODE_THEME = 'github-dark' @@ -40,3 +41,52 @@ export function createTolariaCodeBlockOptions(): Partial { delete options.createHighlighter return options } + +interface InlineTextItem { + text?: unknown +} + +interface CodeBlockRenderContext { + blockContentDOMAttributes?: Record + props?: unknown + renderType?: unknown +} + +function textFromInlineContent(content: unknown): string { + if (!Array.isArray(content)) return '' + + return content + .map((item: InlineTextItem) => typeof item.text === 'string' ? item.text : '') + .join('') +} + +export function createTolariaCodeBlockSpec() { + const spec = createCodeBlockSpec(createTolariaCodeBlockOptions()) + if (!spec.implementation?.render) return spec + + const baseRender = spec.implementation.render + const render: typeof baseRender = function (this: ThisParameterType, block, editor) { + const context = this as CodeBlockRenderContext + const blockContentDOMAttributes = { + ...context.blockContentDOMAttributes, + 'data-line-numbers': lineNumbersForText(textFromInlineContent(block.content)), + } + + return baseRender.call( + { + ...context, + blockContentDOMAttributes, + } as ThisParameterType, + block, + editor, + ) + } + + return { + ...spec, + implementation: { + ...spec.implementation, + render, + }, + } +} diff --git a/src/components/editorSchema.tsx b/src/components/editorSchema.tsx index 75a8251f..a7e9b9a2 100644 --- a/src/components/editorSchema.tsx +++ b/src/components/editorSchema.tsx @@ -1,6 +1,5 @@ /* eslint-disable react-refresh/only-export-components -- module-level schema, not a component file */ import { - createCodeBlockSpec, BlockNoteSchema, defaultInlineContentSpecs, } from '@blocknote/core' @@ -12,7 +11,7 @@ import { MATH_BLOCK_TYPE, MATH_INLINE_TYPE, renderMathToHtml } from '../utils/ma import { MERMAID_BLOCK_TYPE, mermaidFenceSource } from '../utils/mermaidMarkdown' import { TLDRAW_BLOCK_TYPE, TLDRAW_DEFAULT_HEIGHT } from '../utils/tldrawMarkdown' import type { VaultEntry } from '../types' -import { createTolariaCodeBlockOptions } from './codeBlockOptions' +import { createTolariaCodeBlockSpec } from './codeBlockOptions' import { NoteTitleIcon } from './NoteTitleIcon' import { MermaidDiagram } from './MermaidDiagram' import { SafeHtmlSpan } from './SafeMarkup' @@ -213,7 +212,7 @@ const TldrawBlock = createReactBlockSpec( }, ) -const codeBlock = createCodeBlockSpec(createTolariaCodeBlockOptions()) +const codeBlock = createTolariaCodeBlockSpec() const mathBlock = MathBlock() const mermaidBlock = MermaidBlock() const tldrawBlock = TldrawBlock() diff --git a/src/components/editorSchema.webkit.test.ts b/src/components/editorSchema.webkit.test.ts index da43fc2a..cb785eba 100644 --- a/src/components/editorSchema.webkit.test.ts +++ b/src/components/editorSchema.webkit.test.ts @@ -41,6 +41,7 @@ function installLookbehindMissingRegExp() { } afterEach(() => { + document.body.innerHTML = '' document.documentElement.classList.remove('dark') delete document.documentElement.dataset.theme restoreRegExpConstructor() @@ -48,6 +49,42 @@ afterEach(() => { }) describe('editor schema code block highlighting', () => { + it('renders one line number per code block source line', async () => { + vi.resetModules() + + const { createTolariaCodeBlockSpec } = await import('./codeBlockOptions') + const codeBlockSpec = createTolariaCodeBlockSpec() + type Render = typeof codeBlockSpec.implementation.render + type CodeBlock = Parameters[0] + type CodeBlockEditor = Parameters[1] + type RenderContext = ThisParameterType + + const block = { + id: 'code-block-1', + type: 'codeBlock', + props: { language: 'text' }, + content: [{ type: 'text', text: 'const a = 1\nconsole.log(a)' }], + children: [], + } as CodeBlock + const editor = { + isEditable: true, + getBlock: () => block, + updateBlock: () => {}, + } as CodeBlockEditor + const rendered = codeBlockSpec.implementation.render.call( + { blockContentDOMAttributes: {}, props: undefined, renderType: 'dom' } as RenderContext, + block, + editor, + ) + const host = document.createElement('div') + host.append(rendered.dom) + document.body.append(host) + + expect(host.querySelector('[data-content-type="codeBlock"]')?.getAttribute('data-line-numbers')).toBe('1\n2') + + rendered.destroy?.() + }) + it('uses the light Shiki theme first in light mode', async () => { vi.resetModules() document.documentElement.classList.remove('dark') diff --git a/src/main.tsx b/src/main.tsx index 8c2f25d4..b855cf26 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -20,6 +20,7 @@ import { import { isRecoveredBlockNoteRenderError } from './components/blockNoteRenderRecovery' import { shouldUseLinuxWindowChrome } from './utils/platform' import { reloadFrontendOnceIfStartupFailed } from './utils/frontendReady' +import { installCodeBlockEnhancements } from './utils/codeBlockEnhancements' const EDITOR_DROP_SELECTOR = '.editor__blocknote-container' const TLDRAW_CONTEXT_MENU_SELECTOR = '.tldraw-whiteboard' @@ -69,6 +70,7 @@ if (shouldUseLinuxWindowChrome()) { } applyStoredThemeMode(document, window.localStorage) +installCodeBlockEnhancements(document) function dispatchDeterministicShortcutEvent(init: AppCommandShortcutEventInit) { const target = diff --git a/src/utils/codeBlockEnhancements.test.ts b/src/utils/codeBlockEnhancements.test.ts new file mode 100644 index 00000000..639d282a --- /dev/null +++ b/src/utils/codeBlockEnhancements.test.ts @@ -0,0 +1,87 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { + handleCodeBlockSelectAll, + installCodeBlockEnhancements, + lineNumbersForText, +} from './codeBlockEnhancements' + +function renderCodeBlock(code: string): HTMLElement { + document.body.innerHTML = ` +
+
+
${code}
+
+

Outside text

+
+ ` + + return document.querySelector('pre code')! +} + +function placeSelectionInside(element: HTMLElement): void { + const range = document.createRange() + range.setStart(element, 0) + range.collapse(true) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) +} + +describe('codeBlockEnhancements', () => { + afterEach(() => { + document.body.innerHTML = '' + window.getSelection()?.removeAllRanges() + }) + + it('builds one line number per source line', () => { + expect(lineNumbersForText('const a = 1\nconst b = 2\n')).toBe('1\n2') + }) + + it('keeps select-all scoped to the active code block', () => { + const code = renderCodeBlock('const a = 1\nconsole.log(a)') + placeSelectionInside(code) + + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'a', + metaKey: true, + }) + + expect(handleCodeBlockSelectAll(event, document)).toBe(true) + expect(event.defaultPrevented).toBe(true) + expect(window.getSelection()?.toString()).toBe('const a = 1\nconsole.log(a)') + }) + + it('ignores select-all outside editor code blocks', () => { + document.body.innerHTML = '

Outside text

' + placeSelectionInside(document.querySelector('p')!) + + const event = new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'a', + metaKey: true, + }) + + expect(handleCodeBlockSelectAll(event, document)).toBe(false) + expect(event.defaultPrevented).toBe(false) + }) + + it('installs a document listener for scoped select-all', () => { + const code = renderCodeBlock('one\ntwo') + const cleanup = installCodeBlockEnhancements(document) + placeSelectionInside(code) + + document.dispatchEvent(new KeyboardEvent('keydown', { + bubbles: true, + cancelable: true, + key: 'a', + metaKey: true, + })) + + expect(window.getSelection()?.toString()).toBe('one\ntwo') + + cleanup() + }) +}) diff --git a/src/utils/codeBlockEnhancements.ts b/src/utils/codeBlockEnhancements.ts new file mode 100644 index 00000000..336a1678 --- /dev/null +++ b/src/utils/codeBlockEnhancements.ts @@ -0,0 +1,83 @@ +const CODE_BLOCK_SELECTOR = '.editor__blocknote-container .bn-block-content[data-content-type="codeBlock"]' + +function elementFromNode(node: Node | null): Element | null { + if (!node) return null + return node instanceof Element ? node : node.parentElement +} + +function rootContains(root: ParentNode, element: HTMLElement): boolean { + return root === element.ownerDocument || root.contains(element) +} + +function closestCodeBlock(node: Node | null, root: ParentNode): HTMLElement | null { + const block = elementFromNode(node)?.closest(CODE_BLOCK_SELECTOR) + return block && rootContains(root, block) ? block : null +} + +function isSelectAllShortcut(event: KeyboardEvent): boolean { + return event.key.toLowerCase() === 'a' && (event.metaKey || event.ctrlKey) && !event.altKey +} + +function codeBlockFromSelection(selection: Selection | null, root: ParentNode): HTMLElement | null { + if (!selection || selection.rangeCount === 0) return null + + const anchorBlock = closestCodeBlock(selection.anchorNode, root) + if (!anchorBlock) return null + + const focusBlock = closestCodeBlock(selection.focusNode, root) + return focusBlock === anchorBlock ? anchorBlock : null +} + +function codeBlockFromActiveElement(doc: Document): HTMLElement | null { + const activeElement = doc.activeElement + return activeElement ? closestCodeBlock(activeElement, doc) : null +} + +function codeElementForBlock(codeBlock: HTMLElement): HTMLElement | null { + return codeBlock.querySelector('pre code') +} + +function selectCodeElement(codeElement: HTMLElement): void { + const doc = codeElement.ownerDocument + const selection = doc.getSelection() + if (!selection) return + + const range = doc.createRange() + range.selectNodeContents(codeElement) + selection.removeAllRanges() + selection.addRange(range) +} + +export function lineNumbersForText(text: string): string { + const normalized = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n') + const trimmed = normalized.endsWith('\n') ? normalized.slice(0, -1) : normalized + const lineCount = Math.max(1, trimmed.split('\n').length) + + return Array.from({ length: lineCount }, (_, index) => String(index + 1)).join('\n') +} + +export function handleCodeBlockSelectAll(event: KeyboardEvent, doc: Document = document): boolean { + if (!isSelectAllShortcut(event)) return false + + const selection = doc.getSelection() + const codeBlock = codeBlockFromSelection(selection, doc) ?? codeBlockFromActiveElement(doc) + const codeElement = codeBlock ? codeElementForBlock(codeBlock) : null + if (!codeElement) return false + + event.preventDefault() + event.stopPropagation() + selectCodeElement(codeElement) + return true +} + +export function installCodeBlockEnhancements(doc: Document = document): () => void { + const handleKeyDown = (event: KeyboardEvent) => { + handleCodeBlockSelectAll(event, doc) + } + + doc.addEventListener('keydown', handleKeyDown, true) + + return () => { + doc.removeEventListener('keydown', handleKeyDown, true) + } +} diff --git a/tests/smoke/editor-code-block-theme.spec.ts b/tests/smoke/editor-code-block-theme.spec.ts index c5a8ec81..7310bdb1 100644 --- a/tests/smoke/editor-code-block-theme.spec.ts +++ b/tests/smoke/editor-code-block-theme.spec.ts @@ -25,6 +25,7 @@ Inline \`const answer = 42\` should stay on the lighter inline chip. \`\`\`ts const answer = 42 console.log(answer) +console.log("a deliberately long line that should wrap instead of forcing the editor to scroll horizontally", answer) \`\`\` `) } @@ -37,6 +38,17 @@ async function textColor(locator: Locator) { return locator.evaluate((element) => getComputedStyle(element).color) } +async function computedStyle(locator: Locator, property: string) { + return locator.evaluate((element, name) => getComputedStyle(element).getPropertyValue(name), property) +} + +async function computedPseudoStyle(locator: Locator, pseudoElement: string, property: string) { + return locator.evaluate( + (element, args) => getComputedStyle(element, args.pseudoElement).getPropertyValue(args.property), + { pseudoElement, property }, + ) +} + test.describe('Editor code block theme', () => { let tempVaultDir: string @@ -66,19 +78,35 @@ test.describe('Editor code block theme', () => { await expect(codeBlock).toBeVisible({ timeout: 10_000 }) await expect(inlineCode).toBeVisible({ timeout: 10_000 }) await expect(fencedCode).toBeVisible() + await expect(codeBlock).toHaveAttribute('data-line-numbers', '1\n2\n3') await expect.poll(() => backgroundColor(inlineCode)).toBe('rgb(240, 240, 239)') - await expect.poll(() => backgroundColor(codeBlock)).toBe('rgb(245, 248, 255)') + await expect.poll(() => backgroundColor(codeBlock)).toBe('rgb(247, 246, 243)') await expect.poll(() => backgroundColor(fencedCode)).toBe('rgba(0, 0, 0, 0)') await expect.poll(() => textColor(fencedCode)).toBe('rgb(55, 53, 47)') await expect.poll(() => textColor(highlightedToken)).toBe('rgb(55, 53, 47)') + await expect.poll(() => computedStyle(fencedCode, 'white-space')).toBe('pre-wrap') + await expect.poll(() => computedStyle(fencedCode, 'overflow-wrap')).toBe('anywhere') + await expect.poll(() => computedPseudoStyle(codeBlock, '::before', 'white-space')).toBe('pre') + + await fencedCode.evaluate((element) => { + const range = document.createRange() + range.setStart(element, 0) + range.collapse(true) + const selection = window.getSelection() + selection?.removeAllRanges() + selection?.addRange(range) + }) + await page.keyboard.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A') + await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toContain('console.log(answer)') + await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).not.toContain(CODE_NOTE_TITLE) await page.getByTestId('status-theme-mode').click() await expect.poll(() => backgroundColor(codeBlock)).toBe('rgb(22, 22, 22)') await expect.poll(() => textColor(fencedCode)).toBe('rgb(255, 255, 255)') await page.getByTestId('status-theme-mode').click() - await expect.poll(() => backgroundColor(codeBlock)).toBe('rgb(245, 248, 255)') + await expect.poll(() => backgroundColor(codeBlock)).toBe('rgb(247, 246, 243)') await expect.poll(() => textColor(fencedCode)).toBe('rgb(55, 53, 47)') await expect(highlightedToken).toBeVisible() })