feat: improve editor code blocks
This commit is contained in:
@@ -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 --- */
|
||||
|
||||
@@ -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<CodeBlockOptions> {
|
||||
delete options.createHighlighter
|
||||
return options
|
||||
}
|
||||
|
||||
interface InlineTextItem {
|
||||
text?: unknown
|
||||
}
|
||||
|
||||
interface CodeBlockRenderContext {
|
||||
blockContentDOMAttributes?: Record<string, string>
|
||||
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<typeof baseRender>, 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<typeof baseRender>,
|
||||
block,
|
||||
editor,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
...spec,
|
||||
implementation: {
|
||||
...spec.implementation,
|
||||
render,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<Render>[0]
|
||||
type CodeBlockEditor = Parameters<Render>[1]
|
||||
type RenderContext = ThisParameterType<Render>
|
||||
|
||||
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')
|
||||
|
||||
@@ -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 =
|
||||
|
||||
87
src/utils/codeBlockEnhancements.test.ts
Normal file
87
src/utils/codeBlockEnhancements.test.ts
Normal file
@@ -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 = `
|
||||
<div class="editor__blocknote-container">
|
||||
<div class="bn-block-content" data-content-type="codeBlock">
|
||||
<pre><code>${code}</code></pre>
|
||||
</div>
|
||||
<p>Outside text</p>
|
||||
</div>
|
||||
`
|
||||
|
||||
return document.querySelector<HTMLElement>('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 = '<p>Outside text</p>'
|
||||
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()
|
||||
})
|
||||
})
|
||||
83
src/utils/codeBlockEnhancements.ts
Normal file
83
src/utils/codeBlockEnhancements.ts
Normal file
@@ -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<HTMLElement>(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<HTMLElement>('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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user