🐛 fix(query-editor): 修复右键菜单无法粘贴内容 (#875)

## 背景

Issue #873 中,Windows 安装包内的 SQL 编辑器通过右键菜单执行“粘贴”时,浏览器剪贴板 API 会受到 WebView
权限限制,导致 Monaco 无法取得剪贴板内容。

## 变更点

- 在 Wails 运行环境中覆盖 Monaco 的粘贴命令,优先读取 Wails 原生剪贴板
- 将原生剪贴板文本交回 Monaco 的 paste handler,保留选区、多光标和撤销行为
- 使用引用计数管理全局命令生命周期,并保留浏览器剪贴板回退
- 增加原生读取、浏览器回退、多编辑器卸载及只读编辑器回归测试

## 影响范围

- 仅影响 Wails 环境下 SQL 编辑器的右键粘贴
- 普通浏览器环境继续使用 Monaco 默认粘贴逻辑
- 不涉及后端接口、配置、依赖或生成文件变更

## 验证

- 定向单元测试:14/14 通过
- TypeScript 类型检查通过
- 前端生产构建通过(7981 modules transformed)
- Wails runtime 实际验证右键粘贴可插入剪贴板文本
- 前端全量测试中 3 个测试文件因并发资源争用超时,隔离重跑 131/131 通过

Closes #873
This commit is contained in:
virgosgod-maker
2026-08-07 21:45:05 +08:00
committed by GitHub
4 changed files with 612 additions and 2 deletions

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import Editor, { loader, type BeforeMount, type EditorProps, type OnMount } from '@monaco-editor/react';
import { useStore } from '../store';
import { sanitizeDataTableFontSize } from '../utils/dataGridDisplay';
@@ -7,6 +7,7 @@ import {
resolveSqlEditorFontSize,
resolveSqlEditorSuggestionLayout,
} from '../utils/sqlEditorTypography';
import { installWailsMonacoClipboardPasteHandler } from '../utils/monacoClipboard';
export type { BeforeMount, OnMount } from '@monaco-editor/react';
export type GonaviMonacoTypography = 'code' | 'data' | 'sql';
@@ -808,6 +809,7 @@ const MonacoEditor: React.FC<MonacoEditorProps> = ({
const sqlEditorFontSizeFollowGlobal = useStore((state) => state.appearance.sqlEditorFontSizeFollowGlobal);
const monoFontFamily = useStore((state) => state.appearance.customMonoFontFamily);
const globalFontSize = useStore((state) => state.fontSize);
const clipboardPasteCleanupRef = useRef<(() => void) | null>(null);
// Monaco theme is process-global; never fall back to "light" or other editors get polluted.
const resolvedTheme = theme
?? (appTheme === 'dark' ? 'transparent-dark' : 'transparent-light');
@@ -833,17 +835,26 @@ const MonacoEditor: React.FC<MonacoEditorProps> = ({
};
}, []);
useEffect(() => () => {
clipboardPasteCleanupRef.current?.();
clipboardPasteCleanupRef.current = null;
}, []);
const handleBeforeMount: BeforeMount = useCallback((monaco) => {
registerGonaviMonacoThemes(monaco);
beforeMount?.(monaco);
}, [beforeMount]);
const handleMount: OnMount = useCallback((editor, monaco) => {
clipboardPasteCleanupRef.current?.();
clipboardPasteCleanupRef.current = gonaviTypography === 'sql'
? installWailsMonacoClipboardPasteHandler(monaco, editor)
: null;
installOceanBaseOracleNavigationFallback(editor);
installPrintableInputFallback(editor, monaco);
installWebKitImeScrollStabilizer(editor);
onMount?.(editor, monaco);
}, [onMount]);
}, [gonaviTypography, onMount]);
const resolvedOptions = useMemo(() => {
if (uiVersion !== 'v2') {

View File

@@ -0,0 +1,337 @@
import { describe, expect, it, vi } from 'vitest';
import {
installWailsMonacoClipboardPasteHandler,
readClipboardTextWithFallback,
type MonacoClipboardInternals,
} from './monacoClipboard';
type PasteImplementation = () => boolean | Promise<void>;
const createPasteAction = () => {
const implementations: Array<{ priority: number; implementation: PasteImplementation }> = [];
return {
addImplementation: vi.fn((priority: number, _name: string, implementation: PasteImplementation) => {
const entry = { priority, implementation };
implementations.push(entry);
implementations.sort((left, right) => right.priority - left.priority);
return {
dispose: () => {
const index = implementations.indexOf(entry);
if (index >= 0) {
implementations.splice(index, 1);
}
},
};
}),
get implementations() {
return implementations.map((entry) => entry.implementation);
},
};
};
const createInternals = (
pasteAction: ReturnType<typeof createPasteAction>,
metadataByText = new Map<string, {
isFromEmptySelection?: boolean;
multicursorText?: string[] | null;
mode?: unknown;
}>(),
): MonacoClipboardInternals => ({
pasteAction,
metadataManager: {
get: vi.fn((text: string) => metadataByText.get(text) ?? null),
},
});
const createEditor = (overrides: Record<string, unknown> = {}) => ({
getOption: vi.fn(() => true),
getRawOptions: vi.fn(() => ({ readOnly: false })),
hasModel: vi.fn(() => true),
hasTextFocus: vi.fn(() => false),
onDidDispose: vi.fn(() => ({ dispose: vi.fn() })),
trigger: vi.fn(),
...overrides,
});
const wailsScope = (readText = vi.fn().mockResolvedValue('native text')) => ({
window: {
WailsInvoke: vi.fn(),
runtime: { ClipboardGetText: readText },
},
});
const runPasteAction = async (implementations: PasteImplementation[]) => {
for (const implementation of implementations) {
const result = implementation();
if (result !== false) {
await result;
return;
}
}
};
describe('Monaco clipboard fallback', () => {
it('uses the primary clipboard reader when it can read text', async () => {
const primaryReadText = vi.fn().mockResolvedValue('native text');
const fallbackReadText = vi.fn().mockResolvedValue('browser text');
await expect(readClipboardTextWithFallback(primaryReadText, fallbackReadText))
.resolves.toBe('native text');
expect(fallbackReadText).not.toHaveBeenCalled();
});
it('falls back when the primary clipboard reader rejects the read', async () => {
const primaryReadText = vi.fn().mockRejectedValue(new Error('native clipboard unavailable'));
const fallbackReadText = vi.fn().mockResolvedValue('SELECT * FROM users;');
await expect(readClipboardTextWithFallback(primaryReadText, fallbackReadText))
.resolves.toBe('SELECT * FROM users;');
});
it('only handles paste while a registered SQL editor has text focus', async () => {
const pasteAction = createPasteAction();
const internals = createInternals(pasteAction);
const sqlEditor = createEditor({ hasTextFocus: vi.fn(() => true) });
const nonSqlEditor = createEditor({ hasTextFocus: vi.fn(() => true) });
const scope = wailsScope();
const releaseSql = installWailsMonacoClipboardPasteHandler(
{ editor: { EditorOption: { emptySelectionClipboard: 45 } } },
sqlEditor,
scope,
internals,
);
expect(pasteAction.addImplementation).toHaveBeenCalledTimes(1);
await runPasteAction(pasteAction.implementations);
expect(scope.window.runtime.ClipboardGetText).toHaveBeenCalledTimes(1);
expect(sqlEditor.trigger).toHaveBeenCalledWith('keyboard', 'paste', {
text: 'native text',
pasteOnNewLine: false,
multicursorText: null,
mode: null,
});
expect(nonSqlEditor.trigger).not.toHaveBeenCalled();
releaseSql();
});
it('leaves the global paste action to Monaco when only a non-SQL editor is focused', async () => {
const pasteAction = createPasteAction();
const internals = createInternals(pasteAction);
const sqlEditor = createEditor({ hasTextFocus: vi.fn(() => false) });
const scope = wailsScope();
const defaultPaste = vi.fn(() => true);
pasteAction.addImplementation(10000, 'monaco-default-paste', defaultPaste);
const release = installWailsMonacoClipboardPasteHandler(
{ editor: { EditorOption: { emptySelectionClipboard: 45 } } },
sqlEditor,
scope,
internals,
);
await runPasteAction(pasteAction.implementations);
expect(defaultPaste).toHaveBeenCalledTimes(1);
expect(scope.window.runtime.ClipboardGetText).not.toHaveBeenCalled();
expect(sqlEditor.trigger).not.toHaveBeenCalled();
release();
});
it('routes paste to the SQL editor that currently has focus and cleans each editor independently', async () => {
const pasteAction = createPasteAction();
const internals = createInternals(pasteAction);
let focusedEditor = 'first';
const firstEditor = createEditor({ hasTextFocus: vi.fn(() => focusedEditor === 'first') });
const secondEditor = createEditor({ hasTextFocus: vi.fn(() => focusedEditor === 'second') });
const scope = wailsScope(vi.fn().mockResolvedValue('native text'));
const monaco = { editor: { EditorOption: { emptySelectionClipboard: 45 } } };
const releaseFirst = installWailsMonacoClipboardPasteHandler(monaco, firstEditor, scope, internals);
const releaseSecond = installWailsMonacoClipboardPasteHandler(monaco, secondEditor, scope, internals);
await runPasteAction(pasteAction.implementations);
expect(firstEditor.trigger).toHaveBeenCalledTimes(1);
expect(secondEditor.trigger).not.toHaveBeenCalled();
focusedEditor = 'second';
await runPasteAction(pasteAction.implementations);
expect(firstEditor.trigger).toHaveBeenCalledTimes(1);
expect(secondEditor.trigger).toHaveBeenCalledTimes(1);
releaseSecond();
expect(pasteAction.implementations).toHaveLength(1);
expect(pasteAction.implementations[0]()).toBe(false);
releaseFirst();
expect(pasteAction.implementations).toHaveLength(0);
});
it('automatically unregisters a disposed SQL editor', () => {
const pasteAction = createPasteAction();
const internals = createInternals(pasteAction);
let onDispose: (() => void) | undefined;
const editor = createEditor({
onDidDispose: vi.fn((listener: () => void) => {
onDispose = listener;
return { dispose: vi.fn() };
}),
});
installWailsMonacoClipboardPasteHandler(
{ editor: { EditorOption: { emptySelectionClipboard: 45 } } },
editor,
wailsScope(),
internals,
);
expect(pasteAction.implementations).toHaveLength(1);
onDispose?.();
expect(pasteAction.implementations).toHaveLength(0);
});
it('does not paste after the SQL editor is released during an async clipboard read', async () => {
const pasteAction = createPasteAction();
const internals = createInternals(pasteAction);
const editor = createEditor({ hasTextFocus: vi.fn(() => true) });
let resolveRead: ((text: string) => void) | undefined;
const wailsReadText = vi.fn(() => new Promise<string>((resolve) => {
resolveRead = resolve;
}));
const release = installWailsMonacoClipboardPasteHandler(
{ editor: { EditorOption: { emptySelectionClipboard: 45 } } },
editor,
wailsScope(wailsReadText),
internals,
);
const pastePromise = runPasteAction(pasteAction.implementations);
release();
resolveRead?.('late text');
await pastePromise;
expect(editor.trigger).not.toHaveBeenCalled();
expect(pasteAction.implementations).toHaveLength(0);
});
it('reuses Monaco metadata for matching multi-cursor and whole-line copies only', async () => {
const pasteAction = createPasteAction();
const metadata = new Map([
['first value\nsecond value', {
isFromEmptySelection: false,
multicursorText: ['first value', 'second value'],
mode: null,
}],
['whole line\n', {
isFromEmptySelection: true,
multicursorText: null,
mode: null,
}],
]);
const internals = createInternals(pasteAction, metadata);
const editor = createEditor({ hasTextFocus: vi.fn(() => true) });
const wailsReadText = vi.fn()
.mockResolvedValueOnce('first value\nsecond value')
.mockResolvedValueOnce('whole line\n')
.mockResolvedValueOnce('foreign text');
const release = installWailsMonacoClipboardPasteHandler(
{ editor: { EditorOption: { emptySelectionClipboard: 45 } } },
editor,
wailsScope(wailsReadText),
internals,
);
await runPasteAction(pasteAction.implementations);
expect(editor.trigger).toHaveBeenLastCalledWith('keyboard', 'paste', {
text: 'first value\nsecond value',
pasteOnNewLine: false,
multicursorText: ['first value', 'second value'],
mode: null,
});
await runPasteAction(pasteAction.implementations);
expect(editor.trigger).toHaveBeenLastCalledWith('keyboard', 'paste', {
text: 'whole line\n',
pasteOnNewLine: true,
multicursorText: null,
mode: null,
});
await runPasteAction(pasteAction.implementations);
expect(editor.trigger).toHaveBeenLastCalledWith('keyboard', 'paste', {
text: 'foreign text',
pasteOnNewLine: false,
multicursorText: null,
mode: null,
});
expect(internals.metadataManager.get).toHaveBeenCalledWith('foreign text');
release();
});
it('uses the browser reader if the Wails clipboard is temporarily unavailable', async () => {
const pasteAction = createPasteAction();
const internals = createInternals(pasteAction);
const browserReadText = vi.fn().mockResolvedValue('browser text');
const wailsReadText = vi.fn().mockRejectedValue(new Error('native clipboard unavailable'));
const editor = createEditor({ hasTextFocus: vi.fn(() => true) });
const release = installWailsMonacoClipboardPasteHandler(
{ editor: { EditorOption: { emptySelectionClipboard: 45 } } },
editor,
{
navigator: { clipboard: { readText: browserReadText } },
...wailsScope(wailsReadText),
},
internals,
);
await runPasteAction(pasteAction.implementations);
expect(wailsReadText).toHaveBeenCalledTimes(1);
expect(browserReadText).toHaveBeenCalledTimes(1);
expect(editor.trigger).toHaveBeenCalledWith('keyboard', 'paste', expect.objectContaining({ text: 'browser text' }));
release();
});
it('does not register outside the Wails runtime', () => {
const pasteAction = createPasteAction();
const release = installWailsMonacoClipboardPasteHandler(
{ editor: { EditorOption: { emptySelectionClipboard: 45 } } },
createEditor(),
{
navigator: { clipboard: { readText: vi.fn().mockResolvedValue('browser text') } },
window: { runtime: { ClipboardGetText: vi.fn().mockResolvedValue('bridge text') } },
},
createInternals(pasteAction),
);
expect(pasteAction.addImplementation).not.toHaveBeenCalled();
release();
});
it('does not read or paste into a read-only SQL editor', () => {
const pasteAction = createPasteAction();
const internals = createInternals(pasteAction);
const editor = createEditor({
getRawOptions: vi.fn(() => ({ readOnly: true })),
hasTextFocus: vi.fn(() => true),
});
const scope = wailsScope();
const release = installWailsMonacoClipboardPasteHandler(
{ editor: { EditorOption: { emptySelectionClipboard: 45 } } },
editor,
scope,
internals,
);
expect(pasteAction.implementations[0]()).toBe(false);
expect(scope.window.runtime.ClipboardGetText).not.toHaveBeenCalled();
expect(editor.trigger).not.toHaveBeenCalled();
release();
});
});

View File

@@ -0,0 +1,238 @@
type ClipboardReadText = () => string | Promise<string>;
interface ClipboardLike {
readText: ClipboardReadText;
}
interface WailsClipboardRuntimeLike {
ClipboardGetText?: ClipboardReadText;
}
interface WailsWindowLike {
WailsInvoke?: unknown;
runtime?: WailsClipboardRuntimeLike;
}
export interface MonacoClipboardScope {
navigator?: {
clipboard?: ClipboardLike;
};
window?: WailsWindowLike;
}
interface DisposableLike {
dispose: () => void;
}
interface MonacoClipboardEditorLike {
getOption?: (option: any) => unknown;
getRawOptions?: () => { readOnly?: boolean };
hasModel?: () => boolean;
hasTextFocus?: () => boolean;
onDidDispose?: (listener: () => void) => DisposableLike;
trigger?: (source: string, handlerId: string, payload: unknown) => void;
}
interface MonacoEditorApiLike {
EditorOption?: {
emptySelectionClipboard?: unknown;
};
}
export interface MonacoClipboardApiLike {
editor?: MonacoEditorApiLike;
}
interface MonacoClipboardMetadata {
isFromEmptySelection?: boolean;
multicursorText?: string[] | null;
mode?: unknown;
}
interface MonacoClipboardMetadataManagerLike {
get: (text: string) => MonacoClipboardMetadata | null;
}
export interface MonacoClipboardPasteActionLike {
addImplementation?: (
priority: number,
name: string,
implementation: () => boolean | Promise<void>,
) => DisposableLike;
}
export interface MonacoClipboardInternals {
metadataManager: MonacoClipboardMetadataManagerLike;
pasteAction?: MonacoClipboardPasteActionLike;
}
const MONACO_PASTE_IMPLEMENTATION_PRIORITY = 10001;
const noop = () => {};
let monacoClipboardInternalsPromise: Promise<MonacoClipboardInternals | null> | null = null;
const isWailsClipboardRuntime = (scope: MonacoClipboardScope): boolean => (
typeof scope.window?.WailsInvoke === 'function'
&& typeof scope.window.runtime?.ClipboardGetText === 'function'
);
const getBrowserClipboardReader = (scope: MonacoClipboardScope): ClipboardReadText | undefined => {
try {
const clipboard = scope.navigator?.clipboard;
return typeof clipboard?.readText === 'function' ? clipboard.readText.bind(clipboard) : undefined;
} catch {
return undefined;
}
};
const loadMonacoClipboardInternals = (): Promise<MonacoClipboardInternals | null> => {
if (!monacoClipboardInternalsPromise) {
monacoClipboardInternalsPromise = Promise.all([
import('monaco-editor/esm/vs/editor/contrib/clipboard/browser/clipboard.js'),
import('monaco-editor/esm/vs/editor/browser/controller/editContext/clipboardUtils.js'),
]).then(([clipboardModule, clipboardUtilsModule]) => {
// Monaco 0.55.1 ships these symbols without public declarations. The main editor bundle
// imports both modules, so these are the same instances used by Monaco's default action.
const pasteAction = (clipboardModule as unknown as {
PasteAction?: MonacoClipboardPasteActionLike;
}).PasteAction;
const metadataManager = (clipboardUtilsModule as unknown as {
InMemoryClipboardMetadataManager?: { INSTANCE?: MonacoClipboardMetadataManagerLike };
}).InMemoryClipboardMetadataManager?.INSTANCE;
return metadataManager ? { pasteAction, metadataManager } : null;
}).catch(() => null);
}
return monacoClipboardInternalsPromise;
};
const createPastePayload = (
monaco: MonacoClipboardApiLike,
editor: MonacoClipboardEditorLike,
text: string,
metadataManager: MonacoClipboardMetadataManagerLike,
) => {
// This is Monaco's own text-keyed fallback for data that cannot carry custom clipboard MIME types.
const metadata = metadataManager.get(text);
const emptySelectionClipboard = monaco.editor?.EditorOption?.emptySelectionClipboard;
const pasteOnNewLine = emptySelectionClipboard !== undefined
&& editor.getOption?.(emptySelectionClipboard) === true
&& metadata?.isFromEmptySelection === true;
return {
text,
pasteOnNewLine,
// Wails only exposes text. Never reconstruct multicursorText from arbitrary line breaks.
multicursorText: metadata && typeof metadata.multicursorText !== 'undefined'
? metadata.multicursorText
: null,
mode: metadata?.mode ?? null,
};
};
const installPasteImplementation = (
monaco: MonacoClipboardApiLike,
editor: MonacoClipboardEditorLike,
scope: MonacoClipboardScope,
internals: MonacoClipboardInternals,
): (() => void) => {
const pasteAction = internals.pasteAction;
const wailsReadText = scope.window?.runtime?.ClipboardGetText;
if (!pasteAction?.addImplementation || typeof wailsReadText !== 'function') {
return noop;
}
const browserReadText = getBrowserClipboardReader(scope);
let released = false;
const implementationDisposable = pasteAction.addImplementation(
MONACO_PASTE_IMPLEMENTATION_PRIORITY,
'gonavi-wails-sql-editor',
() => {
const trigger = editor.trigger;
if (
editor.hasModel?.() === false
|| editor.hasTextFocus?.() !== true
|| editor.getRawOptions?.().readOnly === true
|| typeof trigger !== 'function'
) {
// Let Monaco's default implementation handle another focused editor.
return false;
}
return (async () => {
let text: string;
try {
text = await readClipboardTextWithFallback(wailsReadText.bind(scope.window?.runtime), browserReadText);
} catch {
return;
}
if (
released
|| !text
|| editor.hasModel?.() === false
|| editor.hasTextFocus?.() !== true
|| editor.getRawOptions?.().readOnly === true
) {
return;
}
trigger('keyboard', 'paste', createPastePayload(monaco, editor, text, internals.metadataManager));
})();
},
);
let editorDisposeDisposable: DisposableLike | undefined;
const release = () => {
if (released) return;
released = true;
implementationDisposable.dispose();
editorDisposeDisposable?.dispose();
};
editorDisposeDisposable = editor.onDidDispose?.(release);
return release;
};
export const readClipboardTextWithFallback = async (
primaryReadText: ClipboardReadText,
fallbackReadText?: ClipboardReadText,
): Promise<string> => {
try {
return String(await primaryReadText() ?? '');
} catch (primaryError) {
if (!fallbackReadText) {
throw primaryError;
}
return String(await fallbackReadText() ?? '');
}
};
export const installWailsMonacoClipboardPasteHandler = (
monaco: MonacoClipboardApiLike,
editor: MonacoClipboardEditorLike,
scope: MonacoClipboardScope = globalThis as unknown as MonacoClipboardScope,
internals?: MonacoClipboardInternals,
): (() => void) => {
if (!isWailsClipboardRuntime(scope)) {
return noop;
}
if (internals) {
return installPasteImplementation(monaco, editor, scope, internals);
}
let released = false;
let installedCleanup = noop;
void loadMonacoClipboardInternals().then((loadedInternals) => {
if (!released && loadedInternals) {
installedCleanup = installPasteImplementation(monaco, editor, scope, loadedInternals);
}
});
return () => {
if (released) return;
released = true;
installedCleanup();
};
};

View File

@@ -5,6 +5,30 @@ declare module 'monaco-editor/esm/nls.messages.zh-cn' {
export default messages;
}
declare module 'monaco-editor/esm/vs/editor/contrib/clipboard/browser/clipboard.js' {
export const PasteAction: {
addImplementation(
priority: number,
name: string,
implementation: () => boolean | Promise<void>,
): { dispose(): void };
} | undefined;
}
declare module 'monaco-editor/esm/vs/editor/browser/controller/editContext/clipboardUtils.js' {
interface ClipboardMetadata {
isFromEmptySelection?: boolean;
multicursorText?: string[] | null;
mode?: unknown;
}
export const InMemoryClipboardMetadataManager: {
INSTANCE: {
get(text: string): ClipboardMetadata | null;
};
};
}
interface ImportMetaEnv {
readonly VITE_GONAVI_ENABLE_MAC_WINDOW_DIAGNOSTICS?: string;
}