feat(editor): add code block copy action

This commit is contained in:
lucaronin
2026-05-03 11:34:56 +02:00
parent c795f118d0
commit dbb12e96fe
20 changed files with 294 additions and 46 deletions

View File

@@ -800,6 +800,7 @@ Managed by `useSettings` hook and `SettingsPanel` component. `theme_mode` is ins
### Product Events
- **File previews** — `file_preview_opened`, `file_preview_action`, and `file_preview_failed` report only preview/action categories such as `image`, `pdf`, `unsupported`, `open_external`, `copy_path`, and `reveal`.
- **Inline image lightbox** — `inline_image_lightbox_opened` records that a rich-editor inline image was opened from double-click, without sending note paths, image URLs, alt text, or file names.
- **Code block copy** — `code_block_copied` records that the rich-editor code-block copy action was used, without sending note paths, languages, or code content.
- **AI agent sessions** — `ai_agent_message_sent`, `ai_agent_message_blocked`, `ai_agent_response_completed`, `ai_agent_response_failed`, and `ai_agent_permission_mode_changed` use only agent ids, permission modes, counts, and coarse status categories.
- **All Notes visibility** — `all_notes_visibility_changed` records only the toggled category and enabled state.

View File

@@ -370,7 +370,9 @@ files:
editor.toolbar.delete: f48134a07b016a1de05d4ba6d2fbfb41
editor.toolbar.revealFile: 76f4ed52da9937ae432dcf5a108fabb4
editor.toolbar.copyFilePath: 64c6cec5ca57efbb8c9c93ae5fbccd27
editor.toolbar.moreActions: 89e19353176b94068dec4c768c25e70b
editor.toolbar.openProperties: 948b90b60b8ce827f179e8c5b85b112e
editor.codeBlock.copy: 8e477020fb3f7ab844b91ff1d7de8347
editor.imageLightbox.title: 2c5a15c875bcdc2478c4a5cad044e10c
editor.filename.rename: c31c2b468229232ad6287e734fe67d96
editor.filename.renameToTitle: bff34a6a2dd1eb87c59403946679237f

View File

@@ -4,6 +4,10 @@
============================================== */
/* --- Editor root --- */
.editor__blocknote-container {
position: relative;
}
.editor__blocknote-container .bn-editor {
font-family: var(--editor-font-family);
font-size: var(--editor-font-size);
@@ -408,6 +412,12 @@
background-color: var(--editor-code-block-background) !important;
border: 1px solid var(--editor-code-block-border);
color: var(--editor-code-block-text) !important;
position: relative;
}
.editor__code-block-copy {
position: absolute;
z-index: 4;
}
.editor__blocknote-container .bn-block-content[data-content-type="codeBlock"] > pre {

View File

@@ -1,9 +1,10 @@
import { act, fireEvent, render, screen } from '@testing-library/react'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { ReactNode } from 'react'
import type { VaultEntry } from '../types'
import { RUNTIME_STYLE_NONCE } from '../lib/runtimeStyleNonce'
import { insertPlainTextFromClipboardText } from '../utils/plainTextPaste'
import { TooltipProvider } from './ui/tooltip'
const state = vi.hoisted(() => ({
capturedLinkToolbarProps: null as null | Record<string, unknown>,
@@ -265,6 +266,7 @@ function renderEditorHarness(editor = createEditor()) {
entries={[makeEntry()]}
onNavigateWikilink={vi.fn()}
/>,
{ wrapper: TooltipProvider },
)
const container = screen.getByTestId('blocknote-view').closest('.editor__blocknote-container')
@@ -571,11 +573,14 @@ describe('SingleEditorView', () => {
expect(onChange).toHaveBeenCalledTimes(1)
})
it('copies selected fenced code text without markdown escape backslashes', () => {
it('copies selected fenced code text without markdown escape backslashes', async () => {
const json = '{\n "id": "Demo"\n}'
const { container } = renderEditorHarness()
const { codeBlock, code } = createCodeBlockFixture(json)
container.appendChild(codeBlock)
await act(async () => {
container.appendChild(codeBlock)
await Promise.resolve()
})
selectNodeContents(code)
const clipboardData = { setData: vi.fn() }
@@ -584,12 +589,36 @@ describe('SingleEditorView', () => {
expect(clipboardData.setData).toHaveBeenCalledWith('text/plain', json)
})
it('does not override full-note copy selections that merely include a code block', () => {
it('copies fenced code from the code-block action button', async () => {
const json = '{\n "id": "Demo"\n}'
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(window.navigator, 'clipboard', {
configurable: true,
value: { writeText },
})
const { container, editor } = renderEditorHarness()
const { codeBlock } = createCodeBlockFixture(json)
act(() => {
container.appendChild(codeBlock)
})
fireEvent.mouseMove(codeBlock)
const copyButton = await screen.findByRole('button', { name: 'Copy code to clipboard' })
fireEvent.click(copyButton)
await waitFor(() => expect(writeText).toHaveBeenCalledWith(json))
expect(editor.focus).not.toHaveBeenCalled()
})
it('does not override full-note copy selections that merely include a code block', async () => {
const { container } = renderEditorHarness()
const paragraph = document.createElement('p')
paragraph.textContent = 'Before'
const { codeBlock, code } = createCodeBlockFixture('const value = 1')
container.append(paragraph, codeBlock)
await act(async () => {
container.append(paragraph, codeBlock)
await Promise.resolve()
})
const range = document.createRange()
range.setStartBefore(paragraph)

View File

@@ -1,4 +1,5 @@
import { useEffect, useCallback, useMemo, useRef, useContext } from 'react'
import { useEffect, useCallback, useMemo, useRef, useContext, useState } from 'react'
import { invoke } from '@tauri-apps/api/core'
import { trackEvent } from '../lib/telemetry'
import {
useCreateBlockNote,
@@ -16,12 +17,14 @@ import {
} from '@blocknote/react'
import { components } from '@blocknote/mantine'
import { MantineContext, MantineProvider } from '@mantine/core'
import { Copy } from '@phosphor-icons/react'
import { ExternalLink } from 'lucide-react'
import { useDocumentThemeMode } from '../hooks/useDocumentThemeMode'
import { useEditorTheme } from '../hooks/useTheme'
import { useImageDrop } from '../hooks/useImageDrop'
import { useImageLightbox } from '../hooks/useImageLightbox'
import type { AppLocale } from '../lib/i18n'
import { createTranslator, type AppLocale } from '../lib/i18n'
import { isTauri } from '../mock-tauri'
import { buildTypeEntryMap } from '../utils/typeColors'
import { preFilterWikilinks, deduplicateByPath, MIN_QUERY_LENGTH } from '../utils/wikilinkSuggestions'
import { filterPersonMentions, PERSON_MENTION_MIN_QUERY } from '../utils/personMentionSuggestions'
@@ -42,6 +45,8 @@ import { TolariaSideMenu } from './tolariaBlockNoteSideMenu'
import { useEditorLinkActivation } from './useEditorLinkActivation'
import { findNearestTextCursorBlock } from './blockNoteCursorTarget'
import { ImageLightbox } from './ImageLightbox'
import { ActionTooltip } from './ui/action-tooltip'
import { Button } from './ui/button'
import {
activatePlainTextPasteTarget,
registerPlainTextPasteTarget,
@@ -59,6 +64,7 @@ const CONTAINER_CLICK_IGNORE_SELECTOR = [
'.bn-link-toolbar',
'.bn-side-menu',
'.bn-form-popover',
'[data-editor-code-copy]',
'[role="menu"]',
'[role="dialog"]',
].join(', ')
@@ -280,6 +286,7 @@ 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"]'
const CODE_BLOCK_COPY_RESET_MS = 1200
function nodeElement(node: Node | null): HTMLElement | null {
if (!node) return null
@@ -336,6 +343,151 @@ function selectedCodeBlockText(options: {
return options.selection?.toString() || range.cloneContents().textContent || ''
}
function codeBlockText(codeBlock: HTMLElement): string {
const codeElement = codeBlock.querySelector<HTMLElement>('pre code')
return codeElement?.textContent ?? ''
}
async function writeClipboardText(text: string): Promise<void> {
if (isTauri()) {
await invoke('copy_text_to_clipboard', { text })
return
}
if (!navigator.clipboard?.writeText) {
throw new Error('Clipboard API is unavailable')
}
await navigator.clipboard.writeText(text)
}
type CodeBlockCopyTarget = {
codeBlock: HTMLElement
left: number
top: number
}
function codeBlockCopyTarget(codeBlock: HTMLElement, container: HTMLElement): CodeBlockCopyTarget {
const codeBlockRect = codeBlock.getBoundingClientRect()
const containerRect = container.getBoundingClientRect()
return {
codeBlock,
left: codeBlockRect.right - containerRect.left + container.scrollLeft - 30,
top: codeBlockRect.top - containerRect.top + container.scrollTop + 6,
}
}
function sameCopyTarget(left: CodeBlockCopyTarget | null, right: CodeBlockCopyTarget): boolean {
return Boolean(
left
&& left.codeBlock === right.codeBlock
&& left.left === right.left
&& left.top === right.top,
)
}
function useCodeBlockCopyTarget(containerRef: React.RefObject<HTMLDivElement | null>) {
const [copyTarget, setCopyTarget] = useState<CodeBlockCopyTarget | null>(null)
const showCopyTarget = useCallback((codeBlock: HTMLElement) => {
const container = containerRef.current
if (!container || !container.contains(codeBlock)) return
const nextTarget = codeBlockCopyTarget(codeBlock, container)
setCopyTarget((previous) => sameCopyTarget(previous, nextTarget) ? previous : nextTarget)
}, [containerRef])
const updateFromEventTarget = useCallback((target: EventTarget | null) => {
const container = containerRef.current
if (!(target instanceof HTMLElement) || !container) return
if (target.closest('[data-editor-code-copy]')) return
const codeBlock = target.closest<HTMLElement>(CODE_BLOCK_SELECTOR)
if (codeBlock && container.contains(codeBlock)) {
showCopyTarget(codeBlock)
return
}
setCopyTarget(null)
}, [containerRef, showCopyTarget])
const handleMouseMove = useCallback((event: React.MouseEvent<HTMLDivElement>) => {
updateFromEventTarget(event.target)
}, [updateFromEventTarget])
const handleFocus = useCallback((event: React.FocusEvent<HTMLDivElement>) => {
updateFromEventTarget(event.target)
}, [updateFromEventTarget])
const clearCopyTarget = useCallback(() => setCopyTarget(null), [])
return { clearCopyTarget, copyTarget, handleFocus, handleMouseMove }
}
function CodeBlockCopyButton({ copyTarget, locale }: { copyTarget: CodeBlockCopyTarget; locale: AppLocale }) {
const [active, setActive] = useState(false)
const resetTimerRef = useRef<number | null>(null)
const t = useMemo(() => createTranslator(locale), [locale])
const label = t('editor.codeBlock.copy')
useEffect(() => () => {
if (resetTimerRef.current !== null) window.clearTimeout(resetTimerRef.current)
}, [])
const handleCopy = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
event.stopPropagation()
void writeClipboardText(codeBlockText(copyTarget.codeBlock))
.then(() => {
trackEvent('code_block_copied')
setActive(true)
if (resetTimerRef.current !== null) window.clearTimeout(resetTimerRef.current)
resetTimerRef.current = window.setTimeout(() => {
setActive(false)
resetTimerRef.current = null
}, CODE_BLOCK_COPY_RESET_MS)
})
.catch((error) => {
console.warn('[editor] Failed to copy code block:', error)
})
}, [copyTarget])
const stopEditorMouseDown = useCallback((event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault()
event.stopPropagation()
}, [])
return (
<div
className="editor__code-block-copy"
contentEditable={false}
data-editor-code-copy
style={{ left: copyTarget.left, top: copyTarget.top }}
>
<ActionTooltip copy={{ label }} side="left" align="center">
<Button
aria-label={label}
className="border border-border/70 bg-background/90 text-muted-foreground shadow-sm backdrop-blur hover:bg-background hover:text-foreground focus-visible:bg-background focus-visible:text-foreground"
data-editor-code-copy-button
onBlur={() => setActive(false)}
onClick={handleCopy}
onFocus={() => setActive(true)}
onMouseDown={stopEditorMouseDown}
onMouseEnter={() => setActive(true)}
onMouseLeave={() => setActive(false)}
size="icon-xs"
type="button"
variant="ghost"
>
<Copy aria-hidden="true" size={14} weight={active ? 'fill' : 'regular'} />
</Button>
</ActionTooltip>
</div>
)
}
function findTitleHeadingElement(target: HTMLElement): HTMLElement | null {
const directHeading = target.closest<HTMLElement>(TITLE_HEADING_SELECTOR)
if (directHeading) return directHeading
@@ -569,6 +721,55 @@ function useSuggestionMenuItems(options: {
}
}
type EditorInteractionControllersProps = ReturnType<typeof useSuggestionMenuItems> & {
runEditorAction: (action: SuggestionAction) => void
}
function EditorInteractionControllers({
getPersonMentionItems,
getSlashMenuItems,
getWikilinkItems,
runEditorAction,
}: EditorInteractionControllersProps) {
return (
<>
<SideMenuController sideMenu={TolariaSideMenu} />
<TolariaFormattingToolbarController
formattingToolbar={TolariaFormattingToolbar}
floatingUIOptions={{
elementProps: {
onMouseDownCapture: handleToolbarMouseDownCapture,
},
}}
/>
<LinkToolbarController
linkToolbar={TolariaLinkToolbar}
floatingUIOptions={{
elementProps: {
onMouseDownCapture: handleToolbarMouseDownCapture,
},
}}
/>
<SuggestionMenuController
triggerCharacter="/"
getItems={getSlashMenuItems}
/>
<SuggestionMenuController
triggerCharacter="[["
getItems={getWikilinkItems}
suggestionMenuComponent={WikilinkSuggestionMenu}
onItemClick={(item: WikilinkSuggestionItem) => runEditorAction(item.onItemClick)}
/>
<SuggestionMenuController
triggerCharacter="@"
getItems={getPersonMentionItems}
suggestionMenuComponent={WikilinkSuggestionMenu}
onItemClick={(item: WikilinkSuggestionItem) => runEditorAction(item.onItemClick)}
/>
</>
)
}
/** Insert an image block after the current cursor position. */
function useInsertImageCallback(editor: ReturnType<typeof useCreateBlockNote>) {
const editorRef = useRef(editor)
@@ -642,6 +843,12 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
const onImageUrl = useInsertImageCallback(editor)
const { isDragOver } = useImageDrop({ containerRef, onImageUrl, vaultPath })
const lightbox = useImageLightbox({ containerRef })
const {
clearCopyTarget,
copyTarget,
handleFocus: handleCodeBlockCopyFocus,
handleMouseMove: handleCodeBlockCopyMouseMove,
} = useCodeBlockCopyTarget(containerRef)
useBlockNoteSideMenuHoverGuard(containerRef)
useEditorLinkActivation(containerRef, onNavigateWikilink)
@@ -672,12 +879,12 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
editor,
runEditorAction,
})
const handleFocusCapture = useCallback((event: React.FocusEvent<HTMLDivElement>) => {
activatePlainTextPaste()
handleCodeBlockCopyFocus(event)
}, [activatePlainTextPaste, handleCodeBlockCopyFocus])
const insertWikilink = useInsertWikilink(editor, runEditorAction)
const {
getWikilinkItems,
getPersonMentionItems,
getSlashMenuItems,
} = useSuggestionMenuItems({
const suggestionMenuItems = useSuggestionMenuItems({
baseItems,
editor,
insertWikilink,
@@ -693,8 +900,10 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
style={cssVars as React.CSSProperties}
onClick={handleContainerClick}
onCopyCapture={handleCodeBlockCopy}
onFocusCapture={activatePlainTextPaste}
onFocusCapture={handleFocusCapture}
onMouseLeave={clearCopyTarget}
onMouseDownCapture={activatePlainTextPaste}
onMouseMove={handleCodeBlockCopyMouseMove}
>
{isDragOver && (
<div className="editor__drop-overlay">
@@ -711,40 +920,12 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange
slashMenu={false}
sideMenu={false}
>
<SideMenuController sideMenu={TolariaSideMenu} />
<TolariaFormattingToolbarController
formattingToolbar={TolariaFormattingToolbar}
floatingUIOptions={{
elementProps: {
onMouseDownCapture: handleToolbarMouseDownCapture,
},
}}
/>
<LinkToolbarController
linkToolbar={TolariaLinkToolbar}
floatingUIOptions={{
elementProps: {
onMouseDownCapture: handleToolbarMouseDownCapture,
},
}}
/>
<SuggestionMenuController
triggerCharacter="/"
getItems={getSlashMenuItems}
/>
<SuggestionMenuController
triggerCharacter="[["
getItems={getWikilinkItems}
suggestionMenuComponent={WikilinkSuggestionMenu}
onItemClick={(item: WikilinkSuggestionItem) => runEditorAction(item.onItemClick)}
/>
<SuggestionMenuController
triggerCharacter="@"
getItems={getPersonMentionItems}
suggestionMenuComponent={WikilinkSuggestionMenu}
onItemClick={(item: WikilinkSuggestionItem) => runEditorAction(item.onItemClick)}
<EditorInteractionControllers
{...suggestionMenuItems}
runEditorAction={runEditorAction}
/>
</SharedContextBlockNoteView>
{copyTarget && <CodeBlockCopyButton copyTarget={copyTarget} locale={locale} />}
<ImageLightbox image={lightbox.image} locale={locale} onClose={lightbox.close} />
</div>
)

View File

@@ -370,6 +370,7 @@
"editor.toolbar.copyFilePath": "Dateipfad kopieren",
"editor.toolbar.moreActions": "Weitere Notizaktionen",
"editor.toolbar.openProperties": "Eigenschaften-Panel öffnen",
"editor.codeBlock.copy": "Code in die Zwischenablage kopieren",
"editor.imageLightbox.title": "Bildvorschau",
"editor.filename.rename": "Dateinamen umbenennen",
"editor.filename.renameToTitle": "Datei entsprechend dem Titel umbenennen",

View File

@@ -370,6 +370,7 @@
"editor.toolbar.copyFilePath": "Copy file path",
"editor.toolbar.moreActions": "More note actions",
"editor.toolbar.openProperties": "Open the properties panel",
"editor.codeBlock.copy": "Copy code to clipboard",
"editor.imageLightbox.title": "Image preview",
"editor.filename.rename": "Rename filename",
"editor.filename.renameToTitle": "Rename the file to match the title",

View File

@@ -370,6 +370,7 @@
"editor.toolbar.copyFilePath": "Copiar la ruta del archivo",
"editor.toolbar.moreActions": "Más acciones de nota",
"editor.toolbar.openProperties": "Abrir el panel de propiedades",
"editor.codeBlock.copy": "Copiar código al portapapeles",
"editor.imageLightbox.title": "Vista previa de la imagen",
"editor.filename.rename": "Cambiar el nombre del archivo",
"editor.filename.renameToTitle": "Cambiar el nombre del archivo para que coincida con el título",

View File

@@ -370,6 +370,7 @@
"editor.toolbar.copyFilePath": "Copiar la ruta del archivo",
"editor.toolbar.moreActions": "Más acciones de nota",
"editor.toolbar.openProperties": "Abrir el panel de propiedades",
"editor.codeBlock.copy": "Copiar código al portapapeles",
"editor.imageLightbox.title": "Vista previa de la imagen",
"editor.filename.rename": "Cambiar el nombre del archivo",
"editor.filename.renameToTitle": "Cambiar el nombre del archivo para que coincida con el título",

View File

@@ -370,6 +370,7 @@
"editor.toolbar.copyFilePath": "Copier le chemin du fichier",
"editor.toolbar.moreActions": "Autres actions de note",
"editor.toolbar.openProperties": "Ouvrir le panneau des propriétés",
"editor.codeBlock.copy": "Copier le code dans le presse-papiers",
"editor.imageLightbox.title": "Aperçu de l'image",
"editor.filename.rename": "Renommer le fichier",
"editor.filename.renameToTitle": "Renommer le fichier pour qu'il corresponde au titre",

View File

@@ -370,6 +370,7 @@
"editor.toolbar.copyFilePath": "Copia il percorso del file",
"editor.toolbar.moreActions": "Altre azioni della nota",
"editor.toolbar.openProperties": "Apri il pannello delle proprietà",
"editor.codeBlock.copy": "Copia il codice negli appunti",
"editor.imageLightbox.title": "Anteprima immagine",
"editor.filename.rename": "Rinomina nome file",
"editor.filename.renameToTitle": "Rinomina il file in modo che corrisponda al titolo",

View File

@@ -370,6 +370,7 @@
"editor.toolbar.copyFilePath": "ファイルパスをコピー",
"editor.toolbar.moreActions": "その他のノート操作",
"editor.toolbar.openProperties": "プロパティパネルを開く",
"editor.codeBlock.copy": "コードをクリップボードにコピー",
"editor.imageLightbox.title": "画像プレビュー",
"editor.filename.rename": "ファイル名を変更",
"editor.filename.renameToTitle": "タイトルと一致するようにファイル名を変更する",

View File

@@ -370,6 +370,7 @@
"editor.toolbar.copyFilePath": "파일 경로 복사",
"editor.toolbar.moreActions": "추가 노트 작업",
"editor.toolbar.openProperties": "속성 패널 열기",
"editor.codeBlock.copy": "클립보드에 코드 복사",
"editor.imageLightbox.title": "이미지 미리 보기",
"editor.filename.rename": "파일 이름 변경",
"editor.filename.renameToTitle": "파일 이름을 제목과 일치하도록 변경",

View File

@@ -370,6 +370,7 @@
"editor.toolbar.copyFilePath": "Kopiuj ścieżkę pliku",
"editor.toolbar.moreActions": "Więcej działań notatki",
"editor.toolbar.openProperties": "Otwórz panel właściwości",
"editor.codeBlock.copy": "Skopiuj kod do schowka",
"editor.imageLightbox.title": "Podgląd obrazu",
"editor.filename.rename": "Zmień nazwę pliku",
"editor.filename.renameToTitle": "Zmień nazwę pliku na zgodną z tytułem",

View File

@@ -370,6 +370,7 @@
"editor.toolbar.copyFilePath": "Copiar caminho do arquivo",
"editor.toolbar.moreActions": "Mais ações da nota",
"editor.toolbar.openProperties": "Abrir o painel de propriedades",
"editor.codeBlock.copy": "Copiar código para a área de transferência",
"editor.imageLightbox.title": "Pré-visualização da imagem",
"editor.filename.rename": "Renomear nome do arquivo",
"editor.filename.renameToTitle": "Renomear o arquivo para corresponder ao título",

View File

@@ -370,6 +370,7 @@
"editor.toolbar.copyFilePath": "Copiar caminho do ficheiro",
"editor.toolbar.moreActions": "Mais ações da nota",
"editor.toolbar.openProperties": "Abrir o painel de propriedades",
"editor.codeBlock.copy": "Copiar código para a área de transferência",
"editor.imageLightbox.title": "Pré-visualização da imagem",
"editor.filename.rename": "Mudar o nome do ficheiro",
"editor.filename.renameToTitle": "Mudar o nome do ficheiro para corresponder ao título",

View File

@@ -370,6 +370,7 @@
"editor.toolbar.copyFilePath": "Копировать путь к файлу",
"editor.toolbar.moreActions": "Другие действия с заметкой",
"editor.toolbar.openProperties": "Открыть панель свойств",
"editor.codeBlock.copy": "Скопировать код в буфер обмена",
"editor.imageLightbox.title": "Предварительный просмотр изображения",
"editor.filename.rename": "Переименовать файл",
"editor.filename.renameToTitle": "Переименовать файл в соответствии с заголовком",

View File

@@ -370,6 +370,7 @@
"editor.toolbar.copyFilePath": "复制文件路径",
"editor.toolbar.moreActions": "更多笔记操作",
"editor.toolbar.openProperties": "打开属性面板",
"editor.codeBlock.copy": "将代码复制到剪贴板",
"editor.imageLightbox.title": "图像预览",
"editor.filename.rename": "重命名文件名",
"editor.filename.renameToTitle": "将文件重命名为匹配标题",

View File

@@ -370,6 +370,7 @@
"editor.toolbar.copyFilePath": "複製檔案路徑",
"editor.toolbar.moreActions": "更多筆記操作",
"editor.toolbar.openProperties": "開啟屬性面板",
"editor.codeBlock.copy": "複製代碼至剪貼簿",
"editor.imageLightbox.title": "圖片預覽",
"editor.filename.rename": "重新命名檔名",
"editor.filename.renameToTitle": "將檔案重新命名為匹配標題",

View File

@@ -52,6 +52,14 @@ async function copySelectedText(page: Page) {
return readClipboard(page)
}
async function copyRichCodeBlockFromButton(page: Page, blockIndex: number) {
await clearClipboard(page)
const codeBlock = page.locator('.bn-block-content[data-content-type="codeBlock"]').nth(blockIndex)
await codeBlock.hover()
await page.getByRole('button', { name: 'Copy code to clipboard' }).click()
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 }) => {
@@ -104,12 +112,15 @@ test.describe('Fenced code copy', () => {
removeFixtureVaultCopy(tempVaultDir)
})
test('copies rich and raw fenced code selections without escape backslashes', async ({ page }) => {
test('copies rich code blocks from the button and 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 expect.poll(() => copyRichCodeBlockFromButton(page, 0)).toBe(JSON_SNIPPET)
await expect.poll(() => copyRichCodeBlockFromButton(page, 1)).toBe(TYPESCRIPT_SNIPPET)
await selectRichCodeBlock(page, 0)
await expect.poll(() => copySelectedText(page)).toBe(JSON_SNIPPET)