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

- 在 Wails 环境使用原生剪贴板读取 Monaco 粘贴内容
- 复用 Monaco 粘贴处理器保留选区、多光标和撤销行为
- 增加原生回退、引用计数及只读编辑器回归测试
This commit is contained in:
AutumnNazi
2026-08-07 17:14:31 +08:00
parent fdb73e1f83
commit ebd08de6cf
3 changed files with 325 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 { installWailsMonacoClipboardPasteCommand } 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'
? installWailsMonacoClipboardPasteCommand(monaco)
: 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,153 @@
import { describe, expect, it, vi } from 'vitest';
import {
installWailsMonacoClipboardPasteCommand,
readClipboardTextWithFallback,
} from './monacoClipboard';
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('overrides Monaco paste with the Wails clipboard and restores the command after the last editor unmounts', async () => {
const browserReadText = vi.fn().mockRejectedValue(new DOMException('denied', 'NotAllowedError'));
const wailsReadText = vi.fn().mockResolvedValue('native text');
const trigger = vi.fn();
const focusedEditor = {
getRawOptions: () => ({ readOnly: false }),
hasModel: () => true,
hasTextFocus: () => true,
trigger,
};
let registeredCommand: (() => Promise<void>) | undefined;
const dispose = vi.fn();
const monaco = {
editor: {
addCommand: vi.fn((descriptor) => {
registeredCommand = descriptor.run;
return { dispose };
}),
getEditors: vi.fn(() => [focusedEditor]),
},
};
const scope = {
navigator: { clipboard: { readText: browserReadText } },
window: {
WailsInvoke: vi.fn(),
runtime: { ClipboardGetText: wailsReadText },
},
};
const releaseFirst = installWailsMonacoClipboardPasteCommand(monaco, scope);
const releaseSecond = installWailsMonacoClipboardPasteCommand(monaco, scope);
await registeredCommand?.();
expect(browserReadText).not.toHaveBeenCalled();
expect(wailsReadText).toHaveBeenCalledTimes(1);
expect(trigger).toHaveBeenCalledWith('keyboard', 'paste', {
text: 'native text',
pasteOnNewLine: false,
multicursorText: null,
mode: null,
});
releaseFirst();
expect(dispose).not.toHaveBeenCalled();
releaseSecond();
expect(dispose).toHaveBeenCalledTimes(1);
});
it('uses the browser reader if the Wails clipboard is temporarily unavailable', async () => {
const browserReadText = vi.fn().mockResolvedValue('browser text');
const wailsReadText = vi.fn().mockRejectedValue(new Error('native clipboard unavailable'));
const trigger = vi.fn();
let registeredCommand: (() => Promise<void>) | undefined;
const monaco = {
editor: {
addCommand: vi.fn((descriptor) => {
registeredCommand = descriptor.run;
return { dispose: vi.fn() };
}),
getEditors: vi.fn(() => [{
getRawOptions: () => ({ readOnly: false }),
hasModel: () => true,
hasTextFocus: () => true,
trigger,
}]),
},
};
const release = installWailsMonacoClipboardPasteCommand(monaco, {
navigator: { clipboard: { readText: browserReadText } },
window: {
WailsInvoke: vi.fn(),
runtime: { ClipboardGetText: wailsReadText },
},
});
await registeredCommand?.();
expect(wailsReadText).toHaveBeenCalledTimes(1);
expect(browserReadText).toHaveBeenCalledTimes(1);
expect(trigger).toHaveBeenCalledWith('keyboard', 'paste', expect.objectContaining({ text: 'browser text' }));
release();
});
it('does not override Monaco in a regular browser runtime without the native Wails bridge', () => {
const addCommand = vi.fn();
const release = installWailsMonacoClipboardPasteCommand({
editor: {
addCommand,
getEditors: vi.fn(() => []),
},
}, {
navigator: { clipboard: { readText: vi.fn().mockResolvedValue('browser text') } },
window: {
runtime: { ClipboardGetText: vi.fn().mockResolvedValue('bridge text') },
},
});
expect(addCommand).not.toHaveBeenCalled();
release();
});
it('does not paste into a read-only editor', async () => {
const trigger = vi.fn();
let registeredCommand: (() => Promise<void>) | undefined;
const release = installWailsMonacoClipboardPasteCommand({
editor: {
addCommand: vi.fn((descriptor) => {
registeredCommand = descriptor.run;
return { dispose: vi.fn() };
}),
getEditors: vi.fn(() => [{
getRawOptions: () => ({ readOnly: true }),
hasModel: () => true,
hasTextFocus: () => true,
trigger,
}]),
},
}, {
window: {
WailsInvoke: vi.fn(),
runtime: { ClipboardGetText: vi.fn().mockResolvedValue('must not paste') },
},
});
await registeredCommand?.();
expect(trigger).not.toHaveBeenCalled();
release();
});
});

View File

@@ -0,0 +1,159 @@
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 MonacoClipboardEditorLike {
getRawOptions?: () => { readOnly?: boolean };
hasModel?: () => boolean;
hasTextFocus?: () => boolean;
trigger?: (source: string, handlerId: string, payload: unknown) => void;
}
interface MonacoEditorApiLike {
addCommand?: (descriptor: {
id: string;
run: () => Promise<void>;
}) => { dispose: () => void };
getEditors?: () => MonacoClipboardEditorLike[];
}
export interface MonacoClipboardApiLike {
editor?: MonacoEditorApiLike;
}
interface InstalledClipboardCommand {
refCount: number;
dispose: () => void;
}
const MONACO_PASTE_COMMAND_ID = 'editor.action.clipboardPasteAction';
const installedClipboardCommands = new WeakMap<object, InstalledClipboardCommand>();
const noop = () => {};
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 installWailsMonacoClipboardPasteCommand = (
monaco: MonacoClipboardApiLike,
scope: MonacoClipboardScope = globalThis as unknown as MonacoClipboardScope,
): (() => void) => {
const wailsWindow = scope.window;
const runtime = wailsWindow?.runtime;
const editorApi = monaco.editor;
if (
typeof wailsWindow?.WailsInvoke !== 'function'
|| typeof runtime?.ClipboardGetText !== 'function'
|| !editorApi
|| typeof editorApi.addCommand !== 'function'
|| typeof editorApi.getEditors !== 'function'
) {
return noop;
}
const existing = installedClipboardCommands.get(editorApi);
if (existing) {
existing.refCount += 1;
let released = false;
return () => {
if (released) return;
released = true;
existing.refCount -= 1;
if (existing.refCount === 0) {
existing.dispose();
installedClipboardCommands.delete(editorApi);
}
};
}
const wailsReadText = runtime.ClipboardGetText.bind(runtime);
let browserReadText: ClipboardReadText | undefined;
try {
const clipboard = scope.navigator?.clipboard;
if (typeof clipboard?.readText === 'function') {
browserReadText = clipboard.readText.bind(clipboard);
}
} catch {
browserReadText = undefined;
}
let commandDisposable: { dispose: () => void };
try {
commandDisposable = editorApi.addCommand({
id: MONACO_PASTE_COMMAND_ID,
run: async () => {
const editor = editorApi.getEditors!().find((candidate) => (
candidate.hasModel?.() !== false && candidate.hasTextFocus?.() === true
));
if (!editor || editor.getRawOptions?.().readOnly === true || typeof editor.trigger !== 'function') {
return;
}
let text: string;
try {
text = await readClipboardTextWithFallback(wailsReadText, browserReadText);
} catch {
return;
}
if (!text) {
return;
}
// Keep Monaco responsible for selections, multi-cursor edits and the undo stack.
editor.trigger('keyboard', 'paste', {
text,
pasteOnNewLine: false,
multicursorText: null,
mode: null,
});
},
});
} catch {
return noop;
}
const installed: InstalledClipboardCommand = {
refCount: 1,
dispose: () => commandDisposable.dispose(),
};
installedClipboardCommands.set(editorApi, installed);
let released = false;
return () => {
if (released) return;
released = true;
installed.refCount -= 1;
if (installed.refCount === 0) {
installed.dispose();
installedClipboardCommands.delete(editorApi);
}
};
};