mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-11 01:03:51 +08:00
🐛 fix(query-editor): 修复右键菜单无法粘贴内容
- 在 Wails 环境使用原生剪贴板读取 Monaco 粘贴内容 - 复用 Monaco 粘贴处理器保留选区、多光标和撤销行为 - 增加原生回退、引用计数及只读编辑器回归测试
This commit is contained in:
@@ -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') {
|
||||
|
||||
153
frontend/src/utils/monacoClipboard.test.ts
Normal file
153
frontend/src/utils/monacoClipboard.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
159
frontend/src/utils/monacoClipboard.ts
Normal file
159
frontend/src/utils/monacoClipboard.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user