From dbb12e96fefd0fa213df228e6f357b45b6cfc123 Mon Sep 17 00:00:00 2001 From: lucaronin Date: Sun, 3 May 2026 11:34:56 +0200 Subject: [PATCH] feat(editor): add code block copy action --- docs/ABSTRACTIONS.md | 1 + lara.lock | 2 + src/components/EditorTheme.css | 10 + src/components/SingleEditorView.test.tsx | 39 +++- src/components/SingleEditorView.tsx | 261 +++++++++++++++++++---- src/lib/locales/de-DE.json | 1 + src/lib/locales/en.json | 1 + src/lib/locales/es-419.json | 1 + src/lib/locales/es-ES.json | 1 + src/lib/locales/fr-FR.json | 1 + src/lib/locales/it-IT.json | 1 + src/lib/locales/ja-JP.json | 1 + src/lib/locales/ko-KR.json | 1 + src/lib/locales/pl-PL.json | 1 + src/lib/locales/pt-BR.json | 1 + src/lib/locales/pt-PT.json | 1 + src/lib/locales/ru-RU.json | 1 + src/lib/locales/zh-CN.json | 1 + src/lib/locales/zh-TW.json | 1 + tests/smoke/fenced-code-copy.spec.ts | 13 +- 20 files changed, 294 insertions(+), 46 deletions(-) diff --git a/docs/ABSTRACTIONS.md b/docs/ABSTRACTIONS.md index 55095988..54e93ec6 100644 --- a/docs/ABSTRACTIONS.md +++ b/docs/ABSTRACTIONS.md @@ -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. diff --git a/lara.lock b/lara.lock index 52102f00..7c5cbcc6 100644 --- a/lara.lock +++ b/lara.lock @@ -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 diff --git a/src/components/EditorTheme.css b/src/components/EditorTheme.css index a12fa4d9..b798ce52 100644 --- a/src/components/EditorTheme.css +++ b/src/components/EditorTheme.css @@ -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 { diff --git a/src/components/SingleEditorView.test.tsx b/src/components/SingleEditorView.test.tsx index 603c1e24..e5fc0883 100644 --- a/src/components/SingleEditorView.test.tsx +++ b/src/components/SingleEditorView.test.tsx @@ -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, @@ -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) diff --git a/src/components/SingleEditorView.tsx b/src/components/SingleEditorView.tsx index 42ddf8b7..85f7f8db 100644 --- a/src/components/SingleEditorView.tsx +++ b/src/components/SingleEditorView.tsx @@ -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('pre code') + return codeElement?.textContent ?? '' +} + +async function writeClipboardText(text: string): Promise { + 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) { + const [copyTarget, setCopyTarget] = useState(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(CODE_BLOCK_SELECTOR) + if (codeBlock && container.contains(codeBlock)) { + showCopyTarget(codeBlock) + return + } + + setCopyTarget(null) + }, [containerRef, showCopyTarget]) + + const handleMouseMove = useCallback((event: React.MouseEvent) => { + updateFromEventTarget(event.target) + }, [updateFromEventTarget]) + + const handleFocus = useCallback((event: React.FocusEvent) => { + 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(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) => { + 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) => { + event.preventDefault() + event.stopPropagation() + }, []) + + return ( +
+ + + +
+ ) +} + function findTitleHeadingElement(target: HTMLElement): HTMLElement | null { const directHeading = target.closest(TITLE_HEADING_SELECTOR) if (directHeading) return directHeading @@ -569,6 +721,55 @@ function useSuggestionMenuItems(options: { } } +type EditorInteractionControllersProps = ReturnType & { + runEditorAction: (action: SuggestionAction) => void +} + +function EditorInteractionControllers({ + getPersonMentionItems, + getSlashMenuItems, + getWikilinkItems, + runEditorAction, +}: EditorInteractionControllersProps) { + return ( + <> + + + + + runEditorAction(item.onItemClick)} + /> + runEditorAction(item.onItemClick)} + /> + + ) +} + /** Insert an image block after the current cursor position. */ function useInsertImageCallback(editor: ReturnType) { 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) => { + 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 && (
@@ -711,40 +920,12 @@ export function SingleEditorView({ editor, entries, onNavigateWikilink, onChange slashMenu={false} sideMenu={false} > - - - - - runEditorAction(item.onItemClick)} - /> - runEditorAction(item.onItemClick)} + + {copyTarget && }
) diff --git a/src/lib/locales/de-DE.json b/src/lib/locales/de-DE.json index 843a6393..056f0bce 100644 --- a/src/lib/locales/de-DE.json +++ b/src/lib/locales/de-DE.json @@ -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", diff --git a/src/lib/locales/en.json b/src/lib/locales/en.json index 259bb5d5..f9922b89 100644 --- a/src/lib/locales/en.json +++ b/src/lib/locales/en.json @@ -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", diff --git a/src/lib/locales/es-419.json b/src/lib/locales/es-419.json index 72069e33..56a77932 100644 --- a/src/lib/locales/es-419.json +++ b/src/lib/locales/es-419.json @@ -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", diff --git a/src/lib/locales/es-ES.json b/src/lib/locales/es-ES.json index a23e4939..9ef17e8f 100644 --- a/src/lib/locales/es-ES.json +++ b/src/lib/locales/es-ES.json @@ -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", diff --git a/src/lib/locales/fr-FR.json b/src/lib/locales/fr-FR.json index 0787ddf0..0e5c697d 100644 --- a/src/lib/locales/fr-FR.json +++ b/src/lib/locales/fr-FR.json @@ -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", diff --git a/src/lib/locales/it-IT.json b/src/lib/locales/it-IT.json index df4b50e9..4ac6f751 100644 --- a/src/lib/locales/it-IT.json +++ b/src/lib/locales/it-IT.json @@ -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", diff --git a/src/lib/locales/ja-JP.json b/src/lib/locales/ja-JP.json index 9eeb521f..992fbb27 100644 --- a/src/lib/locales/ja-JP.json +++ b/src/lib/locales/ja-JP.json @@ -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": "タイトルと一致するようにファイル名を変更する", diff --git a/src/lib/locales/ko-KR.json b/src/lib/locales/ko-KR.json index 75968ed7..d024e051 100644 --- a/src/lib/locales/ko-KR.json +++ b/src/lib/locales/ko-KR.json @@ -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": "파일 이름을 제목과 일치하도록 변경", diff --git a/src/lib/locales/pl-PL.json b/src/lib/locales/pl-PL.json index d6c3dd35..39165881 100644 --- a/src/lib/locales/pl-PL.json +++ b/src/lib/locales/pl-PL.json @@ -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", diff --git a/src/lib/locales/pt-BR.json b/src/lib/locales/pt-BR.json index 28542068..a8938b8d 100644 --- a/src/lib/locales/pt-BR.json +++ b/src/lib/locales/pt-BR.json @@ -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", diff --git a/src/lib/locales/pt-PT.json b/src/lib/locales/pt-PT.json index 4c16dda8..24061c7d 100644 --- a/src/lib/locales/pt-PT.json +++ b/src/lib/locales/pt-PT.json @@ -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", diff --git a/src/lib/locales/ru-RU.json b/src/lib/locales/ru-RU.json index d32dc5eb..acaae2c7 100644 --- a/src/lib/locales/ru-RU.json +++ b/src/lib/locales/ru-RU.json @@ -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": "Переименовать файл в соответствии с заголовком", diff --git a/src/lib/locales/zh-CN.json b/src/lib/locales/zh-CN.json index b1c5d16d..918f4738 100644 --- a/src/lib/locales/zh-CN.json +++ b/src/lib/locales/zh-CN.json @@ -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": "将文件重命名为匹配标题", diff --git a/src/lib/locales/zh-TW.json b/src/lib/locales/zh-TW.json index 8fdb70fc..40f74124 100644 --- a/src/lib/locales/zh-TW.json +++ b/src/lib/locales/zh-TW.json @@ -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": "將檔案重新命名為匹配標題", diff --git a/tests/smoke/fenced-code-copy.spec.ts b/tests/smoke/fenced-code-copy.spec.ts index db687d42..337427dd 100644 --- a/tests/smoke/fenced-code-copy.spec.ts +++ b/tests/smoke/fenced-code-copy.spec.ts @@ -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)