diff --git a/src/components/SingleEditorView.test.tsx b/src/components/SingleEditorView.test.tsx index 34fadcc4..764f9b37 100644 --- a/src/components/SingleEditorView.test.tsx +++ b/src/components/SingleEditorView.test.tsx @@ -254,6 +254,50 @@ function createEditor() { } } +function renderEditorHarness(editor = createEditor()) { + render( + , + ) + + 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( - , - ) - - 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( - , - ) - - 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) diff --git a/src/components/SingleEditorView.tsx b/src/components/SingleEditorView.tsx index 5eb90601..3ad7cb30 100644 --- a/src/components/SingleEditorView.tsx +++ b/src/components/SingleEditorView.tsx @@ -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(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(TITLE_HEADING_SELECTOR) @@ -334,6 +390,17 @@ function useCompositionAwareEditorChange(options: { }, []) } +function handleCodeBlockCopy(event: React.ClipboardEvent) { + 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 ( -
+
{isDragOver && (
Drop image here
diff --git a/tests/smoke/fenced-code-copy.spec.ts b/tests/smoke/fenced-code-copy.spec.ts new file mode 100644 index 00000000..db687d42 --- /dev/null +++ b/tests/smoke/fenced-code-copy.spec.ts @@ -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) + }) +})