fix: copy fenced code blocks exactly
This commit is contained in:
@@ -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)
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
124
tests/smoke/fenced-code-copy.spec.ts
Normal file
124
tests/smoke/fenced-code-copy.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user