feat(editor): add code block copy action
This commit is contained in:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "タイトルと一致するようにファイル名を変更する",
|
||||
|
||||
@@ -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": "파일 이름을 제목과 일치하도록 변경",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Переименовать файл в соответствии с заголовком",
|
||||
|
||||
@@ -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": "将文件重命名为匹配标题",
|
||||
|
||||
@@ -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": "將檔案重新命名為匹配標題",
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user