feat: improve editor code blocks
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user