修复代码块复制附带 ` 围栏:纯内容复制(transformCopiedText=false + copy 拦截 + 复制按钮改纯内容)
This commit is contained in:
@@ -30,20 +30,18 @@ export function CodeBlockView({ node, updateAttributes, selected }: NodeViewProp
|
||||
const language = (node.attrs.language as string) || '';
|
||||
const codeClass = language ? `language-${language} hljs` : 'hljs';
|
||||
const [copied, setCopied] = useState(false);
|
||||
// FR-E5 扩展:复制时携带 Markdown 代码围栏(```language),方便粘贴到其他 Markdown 应用
|
||||
const copyWithFence = async () => {
|
||||
// 复制按钮:复制纯代码内容(不带 Markdown 围栏)
|
||||
const copyPlain = async () => {
|
||||
const code = node.textContent ?? '';
|
||||
const lang = language && language !== 'plaintext' ? language : '';
|
||||
const text = lang ? '```' + lang + '\n' + code + '\n```' : '```\n' + code + '\n```';
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
// 剪贴板不可用时回退到 execCommand
|
||||
try {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.value = code;
|
||||
ta.style.position = 'fixed';
|
||||
ta.style.opacity = '0';
|
||||
document.body.appendChild(ta);
|
||||
@@ -59,8 +57,8 @@ export function CodeBlockView({ node, updateAttributes, selected }: NodeViewProp
|
||||
<NodeViewWrapper as="div" style={{ background: 'var(--bg-secondary)', borderRadius: 6, margin: '0.6em 0', overflow: 'hidden', outline: selected ? '1px solid #4a6a8a' : 'none' }}>
|
||||
<div contentEditable={false} style={{ display: 'flex', justifyContent: 'flex-end', alignItems: 'center', gap: 8, padding: '4px 8px', borderBottom: '1px solid var(--control-border)' }}>
|
||||
<button
|
||||
onClick={() => void copyWithFence()}
|
||||
title="复制(携带 Markdown 代码围栏 ```language)"
|
||||
onClick={() => void copyPlain()}
|
||||
title="复制(纯代码内容)"
|
||||
style={{ border: 'none', background: 'transparent', color: copied ? 'var(--accent-text)' : 'var(--text-tertiary)', cursor: 'pointer', fontSize: 12, padding: '1px 4px', borderRadius: 4 }}
|
||||
onMouseEnter={e => { if (!copied) e.currentTarget.style.background = 'var(--control-border)'; }}
|
||||
onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; }}
|
||||
|
||||
@@ -59,7 +59,7 @@ export function TiptapEditor() {
|
||||
Link.configure({ openOnClick: false }),
|
||||
Img,
|
||||
Placeholder.configure({ placeholder: '开始写作…' }),
|
||||
Markdown.configure({ html: false, breaks: false, linkify: true, transformPastedText: true, transformCopiedText: true }),
|
||||
Markdown.configure({ html: false, breaks: false, linkify: true, transformPastedText: true, transformCopiedText: false }),
|
||||
BlockOps,
|
||||
FindReplaceHighlight,
|
||||
MathBlock,
|
||||
@@ -71,6 +71,29 @@ export function TiptapEditor() {
|
||||
editorProps: {
|
||||
// 关闭 Chromium 内置拼写检查——contenteditable 默认开启,会对英文技术词画红色波浪线
|
||||
attributes: { spellcheck: 'false' },
|
||||
// 代码块内复制:只复制纯代码内容,不附带 ``` 围栏(tiptap-markdown 默认会序列化出围栏)
|
||||
handleDOMEvents: {
|
||||
copy: (view, event) => {
|
||||
const { selection, doc } = view.state;
|
||||
if (selection.empty) return false;
|
||||
// 判断选区是否完全落在某个代码块内容内(含 from=0 选中整块的情况)
|
||||
let inCode = false;
|
||||
doc.descendants((node, pos) => {
|
||||
if (node.type.name === 'codeBlock') {
|
||||
const start = pos + 1;
|
||||
const end = pos + node.nodeSize - 1;
|
||||
if (selection.from >= start && selection.to <= end) { inCode = true; return false; }
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!inCode) return false;
|
||||
const text = doc.textBetween(selection.from, selection.to, '\n', ' ');
|
||||
event.preventDefault();
|
||||
// 异步写入覆盖剪贴板,确保拿到纯代码文本(不带 ``` 围栏)
|
||||
navigator.clipboard.writeText(text).catch(() => {});
|
||||
return true;
|
||||
},
|
||||
},
|
||||
// FR-E6:剪贴板粘贴图片 -> 入库 .attachments -> 插入相对路径
|
||||
handlePaste: (_view, event) => {
|
||||
const ev = event as ClipboardEvent;
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import { _electron as electron } from 'playwright';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import nodePath from 'node:path';
|
||||
import os from 'node:os';
|
||||
|
||||
const MAIN_JS = nodePath.join(process.cwd(), 'dist-electron/main/index.js');
|
||||
|
||||
async function launch(userData: string) {
|
||||
const app = await electron.launch({ args: ['--user-data-dir=' + userData, MAIN_JS] });
|
||||
const page = await app.firstWindow();
|
||||
await page.waitForLoadState('domcontentloaded');
|
||||
const invoke = (ch: string, args: any) => page.evaluate(({ ch, args }) => (globalThis as any).dsh.invoke(ch, args), { ch, args });
|
||||
const clip = () => app.evaluate(({ clipboard }) => clipboard.readText());
|
||||
return { app, page, invoke, clip };
|
||||
}
|
||||
|
||||
async function openDocWithCode(a: any) {
|
||||
await a.invoke('vault:switch', { root: a.v });
|
||||
await a.page.getByRole('button', { name: /新建文档/ }).click();
|
||||
await a.page.getByPlaceholder(/输入文档标题/).fill('CodeCopy');
|
||||
await a.page.getByPlaceholder(/输入文档标题/).press('Enter');
|
||||
await expect(a.page.getByTestId('tabbar').getByText('CodeCopy', { exact: true })).toBeVisible({ timeout: 10000 });
|
||||
const ed = a.page.locator('.ProseMirror');
|
||||
await ed.click();
|
||||
await a.page.locator('button[title="代码块 Ctrl+Shift+K"]').click();
|
||||
await a.page.keyboard.type('sudo apt-get update');
|
||||
}
|
||||
|
||||
// 复制代码块内容不应附带 ``` 围栏
|
||||
test('代码块顶部“复制”按钮复制纯内容(无围栏)', async () => {
|
||||
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-cc1-'));
|
||||
const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v');
|
||||
await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true });
|
||||
const a = await launch(ud);
|
||||
a.v = v;
|
||||
await openDocWithCode(a);
|
||||
await a.page.locator('.ProseMirror button').filter({ hasText: /复制/ }).first().click();
|
||||
await a.page.waitForTimeout(200);
|
||||
const text = await a.clip();
|
||||
console.log('CLIP_BUTTON', JSON.stringify(text));
|
||||
expect(text.trim()).toBe('sudo apt-get update');
|
||||
await a.app.close();
|
||||
});
|
||||
|
||||
// Ctrl+C 选中代码块内容复制也不应带围栏
|
||||
test('代码块内 Ctrl+C 复制纯内容(无围栏)', async () => {
|
||||
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-cc2-'));
|
||||
const ud = nodePath.join(tmp, 'ud'); const v = nodePath.join(tmp, 'v');
|
||||
await fs.mkdir(ud, { recursive: true }); await fs.mkdir(v, { recursive: true });
|
||||
const a = await launch(ud);
|
||||
a.v = v;
|
||||
await openDocWithCode(a);
|
||||
const code = a.page.locator('.ProseMirror pre code');
|
||||
await code.click();
|
||||
// 选中代码块内全部内容
|
||||
await a.page.keyboard.press('Control+a');
|
||||
await a.page.keyboard.press('Control+c');
|
||||
await a.page.waitForTimeout(200);
|
||||
const text = await a.clip();
|
||||
console.log('CLIP_CTRLC', JSON.stringify(text));
|
||||
expect(text.trim()).toBe('sudo apt-get update');
|
||||
await a.app.close();
|
||||
});
|
||||
@@ -54,8 +54,8 @@ test('回归:文件列表缩进递进且带图标', async () => {
|
||||
await a.app.close();
|
||||
});
|
||||
|
||||
// 回归:代码块复制携带 Markdown 围栏(FR-反馈3)
|
||||
test('回归:复制代码块携带 ```language 围栏', async () => {
|
||||
// 回归:代码块复制应为纯代码内容,不附带 ```language 围栏(用户反馈修正)
|
||||
test('回归:复制代码块为纯内容(无围栏)', async () => {
|
||||
const tmp = await fs.mkdtemp(nodePath.join(os.tmpdir(), 'hn-reg3-'));
|
||||
const userData = nodePath.join(tmp, 'ud'); const vault = nodePath.join(tmp, 'v');
|
||||
await fs.mkdir(userData, { recursive: true }); await fs.mkdir(vault, { recursive: true });
|
||||
@@ -75,7 +75,8 @@ test('回归:复制代码块携带 ```language 围栏', async () => {
|
||||
await copyBtn.click();
|
||||
await a.page.waitForTimeout(400);
|
||||
const text = await a.page.evaluate(() => (globalThis as any).__cap?.[0] ?? null);
|
||||
expect(text).toBe('```bash\necho hello\nls -la\n```');
|
||||
// 纯代码内容,不带 ``` 围栏
|
||||
expect(text).toBe('echo hello\nls -la');
|
||||
await a.app.close();
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user