fix: copy fenced code blocks exactly

This commit is contained in:
lucaronin
2026-04-27 18:31:51 +02:00
parent cb3274cdbc
commit 39f44b86aa
3 changed files with 273 additions and 41 deletions

View File

@@ -254,6 +254,50 @@ function createEditor() {
}
}
function renderEditorHarness(editor = createEditor()) {
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
const container = screen.getByTestId('blocknote-view').closest('.editor__blocknote-container')
expect(container).toBeTruthy()
return { container: container!, editor }
}
function createCodeBlockFixture(text: string) {
const codeBlock = document.createElement('div')
codeBlock.setAttribute('data-content-type', 'codeBlock')
const pre = document.createElement('pre')
const code = document.createElement('code')
code.textContent = text
pre.appendChild(code)
codeBlock.appendChild(pre)
return { codeBlock, code }
}
function selectNodeContents(node: Node) {
const range = document.createRange()
range.selectNodeContents(node)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
}
function appendToolbarButton(container: Element, className: string, text: string) {
const toolbar = document.createElement('div')
toolbar.className = className
const button = document.createElement('button')
button.type = 'button'
button.textContent = text
toolbar.appendChild(button)
container.appendChild(toolbar)
return button
}
describe('SingleEditorView', () => {
beforeEach(() => {
vi.clearAllMocks()
@@ -448,6 +492,39 @@ describe('SingleEditorView', () => {
expect(onChange).toHaveBeenCalledTimes(1)
})
it('copies selected fenced code text without markdown escape backslashes', () => {
const json = '{\n "id": "Demo"\n}'
const { container } = renderEditorHarness()
const { codeBlock, code } = createCodeBlockFixture(json)
container.appendChild(codeBlock)
selectNodeContents(code)
const clipboardData = { setData: vi.fn() }
fireEvent.copy(code, { clipboardData })
expect(clipboardData.setData).toHaveBeenCalledWith('text/plain', json)
})
it('does not override full-note copy selections that merely include a code block', () => {
const { container } = renderEditorHarness()
const paragraph = document.createElement('p')
paragraph.textContent = 'Before'
const { codeBlock, code } = createCodeBlockFixture('const value = 1')
container.append(paragraph, codeBlock)
const range = document.createRange()
range.setStartBefore(paragraph)
range.setEndAfter(codeBlock)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
const clipboardData = { setData: vi.fn() }
fireEvent.copy(code, { clipboardData })
expect(clipboardData.setData).not.toHaveBeenCalled()
})
it('routes clicks on the empty title wrapper back into the H1 block', async () => {
const editor = createEditor()
@@ -489,26 +566,8 @@ describe('SingleEditorView', () => {
})
it('ignores editor-container click handling for link toolbar interactions', () => {
const editor = createEditor()
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
const container = screen.getByTestId('blocknote-view').closest('.editor__blocknote-container')
expect(container).toBeTruthy()
const linkToolbar = document.createElement('div')
linkToolbar.className = 'bn-link-toolbar'
const linkAction = document.createElement('button')
linkAction.type = 'button'
linkAction.textContent = 'Open in a new tab'
linkToolbar.appendChild(linkAction)
container?.appendChild(linkToolbar)
const { container, editor } = renderEditorHarness()
const linkAction = appendToolbarButton(container, 'bn-link-toolbar', 'Open in a new tab')
fireEvent.click(linkAction)
@@ -517,26 +576,8 @@ describe('SingleEditorView', () => {
})
it('ignores editor-container click handling for BlockNote side-menu actions', () => {
const editor = createEditor()
render(
<SingleEditorView
editor={editor as never}
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
)
const container = screen.getByTestId('blocknote-view').closest('.editor__blocknote-container')
expect(container).toBeTruthy()
const sideMenu = document.createElement('div')
sideMenu.className = 'bn-side-menu'
const action = document.createElement('button')
action.type = 'button'
action.textContent = 'Add block'
sideMenu.appendChild(action)
container?.appendChild(sideMenu)
const { container, editor } = renderEditorHarness()
const action = appendToolbarButton(container, 'bn-side-menu', 'Add block')
fireEvent.click(action)

View File

@@ -224,6 +224,62 @@ function isSelectionInsideElement(element: HTMLElement): boolean {
const TITLE_HEADING_SELECTOR = 'h1, [data-content-type="heading"][data-level="1"], [data-content-type="heading"]:not([data-level])'
const TITLE_HEADING_WRAPPER_SELECTOR = '.bn-block-outer, .bn-block'
const CODE_BLOCK_SELECTOR = '[data-content-type="codeBlock"]'
function nodeElement(node: Node | null): HTMLElement | null {
if (!node) return null
if (node instanceof HTMLElement) return node
return node.parentElement
}
function hasSingleActiveRange(selection: Selection | null): selection is Selection {
return Boolean(selection && selection.rangeCount === 1 && !selection.isCollapsed)
}
function closestCodeBlockInContainer(options: {
range: Range
container: HTMLElement
}): HTMLElement | null {
const { range, container } = options
const codeBlock = nodeElement(range.commonAncestorContainer)
?.closest<HTMLElement>(CODE_BLOCK_SELECTOR)
return codeBlock && container.contains(codeBlock) ? codeBlock : null
}
function nodeBelongsToElement(node: Node, element: HTMLElement): boolean {
const elementNode = nodeElement(node)
return Boolean(elementNode && element.contains(elementNode))
}
function rangeBelongsToElement(range: Range, element: HTMLElement): boolean {
return nodeBelongsToElement(range.startContainer, element)
&& nodeBelongsToElement(range.endContainer, element)
}
function selectedCodeBlockRange(options: {
selection: Selection | null
container: HTMLElement
}): Range | null {
const { selection, container } = options
if (!hasSingleActiveRange(selection)) return null
const range = selection.getRangeAt(0)
const codeBlock = closestCodeBlockInContainer({ range, container })
if (!codeBlock || !rangeBelongsToElement(range, codeBlock)) return null
return range
}
function selectedCodeBlockText(options: {
selection: Selection | null
container: HTMLElement
}): string | null {
const range = selectedCodeBlockRange(options)
if (!range) return null
return options.selection?.toString() || range.cloneContents().textContent || ''
}
function findTitleHeadingElement(target: HTMLElement): HTMLElement | null {
const directHeading = target.closest<HTMLElement>(TITLE_HEADING_SELECTOR)
@@ -334,6 +390,17 @@ function useCompositionAwareEditorChange(options: {
}, [])
}
function handleCodeBlockCopy(event: React.ClipboardEvent<HTMLDivElement>) {
const codeText = selectedCodeBlockText({
selection: window.getSelection(),
container: event.currentTarget,
})
if (codeText === null) return
event.clipboardData.setData('text/plain', codeText)
event.preventDefault()
}
function buildBaseSuggestionItems(entries: VaultEntry[]) {
return deduplicateByPath(entries.map(entry => ({
title: entry.title,
@@ -460,7 +527,7 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
})
return (
<div ref={containerRef} className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`} style={cssVars as React.CSSProperties} onClick={handleContainerClick}>
<div ref={containerRef} className={`editor__blocknote-container${isDragOver ? ' editor__blocknote-container--drag-over' : ''}`} style={cssVars as React.CSSProperties} onClick={handleContainerClick} onCopyCapture={handleCodeBlockCopy}>
{isDragOver && (
<div className="editor__drop-overlay">
<div className="editor__drop-overlay-label">Drop image here</div>

View File

@@ -0,0 +1,124 @@
import { expect, test, type Page } from '@playwright/test'
import fs from 'fs'
import path from 'path'
import {
createFixtureVaultCopy,
openFixtureVault,
removeFixtureVaultCopy,
} from '../helpers/fixtureVault'
import { executeCommand, openCommandPalette, sendShortcut } from './helpers'
const CODE_COPY_NOTE_RELATIVE_PATH = path.join('note', 'fenced-code-copy.md')
const CODE_COPY_NOTE_TITLE = 'Fenced Code Copy'
const JSON_SNIPPET = '{\n "id": "Demo",\n "enabled": true\n}'
const TYPESCRIPT_SNIPPET = 'const answer = 42\nconsole.log(answer)'
const RICH_CODE_SELECTOR = '.bn-block-content[data-content-type="codeBlock"] pre code'
function writeCodeCopyFixtureNote(tempVaultDir: string) {
const notePath = path.join(tempVaultDir, CODE_COPY_NOTE_RELATIVE_PATH)
fs.mkdirSync(path.dirname(notePath), { recursive: true })
fs.writeFileSync(notePath, `---
Is A: Note
Status: Active
---
# ${CODE_COPY_NOTE_TITLE}
Copy this JSON exactly:
\`\`\`json
${JSON_SNIPPET}
\`\`\`
Copy this TypeScript exactly:
\`\`\`ts
${TYPESCRIPT_SNIPPET}
\`\`\`
`)
}
async function clearClipboard(page: Page) {
await page.evaluate(() => navigator.clipboard.writeText(''))
}
async function readClipboard(page: Page) {
return page.evaluate(() => navigator.clipboard.readText())
}
async function copySelectedText(page: Page) {
await clearClipboard(page)
await sendShortcut(page, 'c', ['Control'])
return readClipboard(page)
}
async function selectRichCodeBlock(page: Page, blockIndex: number) {
await page.locator(RICH_CODE_SELECTOR).nth(blockIndex).waitFor({ timeout: 10_000 })
await page.evaluate(({ selector, index }) => {
const code = document.querySelectorAll(selector)[index]
if (!code) throw new Error(`Missing rendered code block ${index}`)
const range = document.createRange()
range.selectNodeContents(code)
const selection = window.getSelection()
selection?.removeAllRanges()
selection?.addRange(range)
}, { selector: RICH_CODE_SELECTOR, index: blockIndex })
}
async function selectRawEditorText(page: Page, text: string) {
await expect(page.locator('.cm-content')).toBeVisible({ timeout: 10_000 })
await page.evaluate((expectedText) => {
const content = document.querySelector('.cm-content') as (HTMLElement & {
cmTile?: {
view?: {
state: { doc: { toString: () => string } }
focus: () => void
dispatch: (transaction: { selection: { anchor: number; head: number } }) => void
}
}
}) | null
const view = content?.cmTile?.view
if (!view) throw new Error('CodeMirror view is not available')
const documentText = view.state.doc.toString()
const start = documentText.indexOf(expectedText)
if (start === -1) throw new Error(`Raw editor text not found: ${expectedText}`)
view.focus()
view.dispatch({ selection: { anchor: start, head: start + expectedText.length } })
}, text)
}
test.describe('Fenced code copy', () => {
let tempVaultDir: string
test.beforeEach(async ({ page }) => {
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
tempVaultDir = createFixtureVaultCopy()
writeCodeCopyFixtureNote(tempVaultDir)
await openFixtureVault(page, tempVaultDir)
})
test.afterEach(() => {
removeFixtureVaultCopy(tempVaultDir)
})
test('copies rich and raw fenced code selections without escape backslashes', async ({ page }) => {
const noteList = page.locator('[data-testid="note-list-container"]')
const noteItem = noteList.getByText(CODE_COPY_NOTE_TITLE, { exact: true })
await expect(noteItem).toBeVisible({ timeout: 10_000 })
await noteItem.click()
await selectRichCodeBlock(page, 0)
await expect.poll(() => copySelectedText(page)).toBe(JSON_SNIPPET)
await selectRichCodeBlock(page, 1)
await expect.poll(() => copySelectedText(page)).toBe(TYPESCRIPT_SNIPPET)
await openCommandPalette(page)
await executeCommand(page, 'Toggle Raw')
await selectRawEditorText(page, JSON_SNIPPET)
await expect.poll(() => copySelectedText(page)).toBe(JSON_SNIPPET)
})
})