mirror of
https://github.com/Syngnat/GoNavi.git
synced 2026-08-11 09:13:36 +08:00
🐛 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:
@@ -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') {
|
||||
|
||||
337
frontend/src/utils/monacoClipboard.test.ts
Normal file
337
frontend/src/utils/monacoClipboard.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
238
frontend/src/utils/monacoClipboard.ts
Normal file
238
frontend/src/utils/monacoClipboard.ts
Normal 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();
|
||||
};
|
||||
};
|
||||
24
frontend/src/vite-env.d.ts
vendored
24
frontend/src/vite-env.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user