From 20fcea82b4c64612a4f52129a135c213e7e762ef Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Wed, 27 May 2026 12:12:39 +0800 Subject: [PATCH] feat: extract EditorSearchPanel into reusable component Move search/replace panel from QueryEditor into EditorSearchPanel.vue, and use it in both QueryEditor and cell detail editor via programmatic mounting. Removes dependency on CodeMirror's built-in search panel in the cell detail editor, giving it the same custom search UI. --- .../components/editor/EditorSearchPanel.vue | 269 ++++++++++++ .../src/components/editor/QueryEditor.vue | 384 ++++-------------- .../src/composables/useCellDetailEditor.ts | 61 ++- .../queryEditorSearchReplace.test.ts | 55 ++- 4 files changed, 454 insertions(+), 315 deletions(-) create mode 100644 apps/desktop/src/components/editor/EditorSearchPanel.vue diff --git a/apps/desktop/src/components/editor/EditorSearchPanel.vue b/apps/desktop/src/components/editor/EditorSearchPanel.vue new file mode 100644 index 0000000000..937204ca4f --- /dev/null +++ b/apps/desktop/src/components/editor/EditorSearchPanel.vue @@ -0,0 +1,269 @@ + + + diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index b6513ae2e6..b0b75b501e 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -1,18 +1,10 @@ diff --git a/apps/desktop/src/composables/useCellDetailEditor.ts b/apps/desktop/src/composables/useCellDetailEditor.ts index dcc1acabb8..f7f65ab4e8 100644 --- a/apps/desktop/src/composables/useCellDetailEditor.ts +++ b/apps/desktop/src/composables/useCellDetailEditor.ts @@ -1,4 +1,4 @@ -import { shallowRef, onBeforeUnmount, type ShallowRef } from "vue"; +import { shallowRef, onBeforeUnmount, type ShallowRef, createApp } from "vue"; import { EditorState, Compartment } from "@codemirror/state"; import { EditorView, @@ -11,9 +11,12 @@ import { import { json } from "@codemirror/lang-json"; import { defaultKeymap, history, historyKeymap } from "@codemirror/commands"; import { bracketMatching } from "@codemirror/language"; -import { searchKeymap, search as cmSearch } from "@codemirror/search"; import { loadEditorTheme, editorFontTheme } from "@/lib/editorThemes"; +import { shortcutToCodeMirrorKey } from "@/lib/shortcutRegistry"; +import { useSettingsStore } from "@/stores/settingsStore"; import { isJsonColumnType } from "@/lib/cellDetailPresentation"; +import i18n from "@/i18n"; +import EditorSearchPanel from "@/components/editor/EditorSearchPanel.vue"; import type { EditorTheme } from "@/stores/settingsStore"; import type { AppThemeAppearance } from "@/lib/appTheme"; @@ -32,6 +35,8 @@ export interface UseCellDetailEditorReturn { create: (parent: HTMLElement, initialValue: string, columnType?: string) => Promise; setValue: (value: string, columnType?: string) => void; getValue: () => string; + openSearch: () => boolean; + openReplace: () => boolean; destroy: () => void; view: Readonly>; } @@ -55,6 +60,9 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel let destroyed = false; let currentIsJson = false; + let searchApp: ReturnType | null = null; + let searchInstance: InstanceType | null = null; + let wrapperEl: HTMLDivElement | null = null; async function create(parent: HTMLElement, initialValue: string, columnType?: string): Promise { if (destroyed) return; @@ -64,6 +72,7 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel const theme = await loadEditorTheme(options.editorTheme(), options.appAppearance()); const fontTheme = editorFontTheme(EditorView, options.fontSize(), options.fontFamily(), { scrollable: false }); + const shortcuts = useSettingsStore().editorSettings.shortcuts; const state = EditorState.create({ doc, @@ -81,9 +90,20 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel }), EditorState.allowMultipleSelections.of(true), bracketMatching(), - keymap.of([...defaultKeymap, ...historyKeymap, ...searchKeymap]), - // Feature extensions - cmSearch({ top: true }), + keymap.of([ + ...defaultKeymap, + ...historyKeymap, + { + key: shortcutToCodeMirrorKey(shortcuts.find), + preventDefault: true, + run: () => openSearch(), + }, + { + key: shortcutToCodeMirrorKey(shortcuts.replace), + preventDefault: true, + run: () => openReplace(), + }, + ]), EditorView.lineWrapping, languageComp.of(currentIsJson ? json() : []), themeComp.of(theme), @@ -92,6 +112,7 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel { key: "Escape", run: () => { + if (searchInstance && (searchInstance as any).closeSearch()) return true; options.onEscape?.(); return true; }, @@ -112,7 +133,18 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel ], }); - view.value = new EditorView({ state, parent }); + wrapperEl = document.createElement("div"); + wrapperEl.style.cssText = "position: relative; width: 100%; height: 100%;"; + parent.appendChild(wrapperEl); + + view.value = new EditorView({ state, parent: wrapperEl }); + + // Mount search panel component + const searchMount = document.createElement("div"); + wrapperEl.appendChild(searchMount); + searchApp = createApp(EditorSearchPanel, { view: view.value }); + searchApp.use(i18n); + searchInstance = searchApp.mount(searchMount) as any; } function setValue(value: string, columnType?: string) { @@ -138,16 +170,31 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel return view.value?.state.doc.toString() ?? ""; } + function openSearch(): boolean { + return (searchInstance as any)?.openSearch?.() ?? false; + } + + function openReplace(): boolean { + return (searchInstance as any)?.openReplace?.() ?? false; + } + function destroy() { if (destroyed) return; destroyed = true; + searchApp?.unmount(); + searchApp = null; + searchInstance = null; view.value?.destroy(); view.value = null; + if (wrapperEl?.parentNode) { + wrapperEl.parentNode.removeChild(wrapperEl); + } + wrapperEl = null; } onBeforeUnmount(() => { destroy(); }); - return { create, setValue, getValue, destroy, view }; + return { create, setValue, getValue, openSearch, openReplace, destroy, view }; } diff --git a/packages/app-tests/queryEditorSearchReplace.test.ts b/packages/app-tests/queryEditorSearchReplace.test.ts index fec9a77551..5f454181a4 100644 --- a/packages/app-tests/queryEditorSearchReplace.test.ts +++ b/packages/app-tests/queryEditorSearchReplace.test.ts @@ -3,16 +3,57 @@ import { readFileSync } from "node:fs"; import test from "node:test"; const source = readFileSync("apps/desktop/src/components/editor/QueryEditor.vue", "utf8"); +const searchPanelSource = readFileSync("apps/desktop/src/components/editor/EditorSearchPanel.vue", "utf8"); +const appSource = readFileSync("apps/desktop/src/App.vue", "utf8"); +const cellDetailEditorSource = readFileSync("apps/desktop/src/composables/useCellDetailEditor.ts", "utf8"); +const contentAreaSource = readFileSync("apps/desktop/src/components/layout/ContentArea.vue", "utf8"); +const dataGridSource = readFileSync("apps/desktop/src/components/grid/DataGrid.vue", "utf8"); +const editorThemeSource = readFileSync("apps/desktop/src/lib/editorThemes.ts", "utf8"); -test("query editor opens replace panel with Mod-H", () => { - assert.match(source, /key:\s*"Mod-h"/); - assert.match(source, /key:\s*"Ctrl-h"/); +test("query editor opens search and replace with configurable shortcuts", () => { + assert.match(source, /shortcutToCodeMirrorKey\(shortcuts\.find\)/); + assert.match(source, /shortcutToCodeMirrorKey\(shortcuts\.replace\)/); assert.match(source, /run:\s*openReplace/); - assert.match(source, /showReplace\.value\s*=\s*true/); - assert.match(source, /replaceInputRef\.value\?\.focus\(\)/); + assert.match(source, /defineExpose\(\{\s*openSearch,\s*openReplace\s*\}\)/); + assert.match(searchPanelSource, /showReplace\.value\s*=\s*true/); + assert.match(searchPanelSource, /replaceInputRef\.value\?\.focus\(\)/); + assert.match(searchPanelSource, /defineExpose\(\{\s*openSearch,\s*openReplace,\s*closeSearch\s*\}\)/); }); test("query editor localizes the replace all button", () => { - assert.match(source, /t\("editor\.search\.replaceAll"\)/); - assert.doesNotMatch(source, />\s*全部\s*\s*全部\s* { + assert.doesNotMatch(editorThemeSource, /"\.cm-searchMatch"/); + assert.doesNotMatch(editorThemeSource, /"\.cm-searchMatch-selected"/); +}); + +test("cell detail editor uses custom search panel with configurable shortcuts", () => { + assert.match(cellDetailEditorSource, /shortcutToCodeMirrorKey\(shortcuts\.find\)/); + assert.match(cellDetailEditorSource, /shortcutToCodeMirrorKey\(shortcuts\.replace\)/); + assert.match(cellDetailEditorSource, /openSearch:\s*\(\)\s*=>\s*boolean/); + assert.match(cellDetailEditorSource, /openReplace:\s*\(\)\s*=>\s*boolean/); + assert.match(cellDetailEditorSource, /createApp\(EditorSearchPanel/); + assert.match(cellDetailEditorSource, /searchApp\.use\(i18n\)/); + assert.match(dataGridSource, /openCellDetailSearch/); +}); + +test("data grid uses Mod-R for refresh instead of editor replace", () => { + assert.match(dataGridSource, /data-grid-root/); + assert.match(dataGridSource, /data-cell-detail-editor-root/); + assert.match(dataGridSource, /if \(event\.defaultPrevented\) return/); + assert.match(dataGridSource, /isModRShortcut\(event\)/); + assert.match(dataGridSource, /await onToolbarRefresh\(\)/); +}); + +test("app keydown routes Mod-R directly before browser reload handling", () => { + assert.match(appSource, /if \(e\.defaultPrevented\) return/); + assert.match(appSource, /isModRShortcut\(e\)/); + assert.match(appSource, /contentAreaRef\.value\?\.handleModRTarget\(e\.target\)/); + assert.match(contentAreaSource, /function handleModRTarget\(target: Element\): boolean/); + assert.match(contentAreaSource, /queryEditorRef\.value\?\.openReplace\(\)/); + assert.match(contentAreaSource, /dataGridRef\.value\?\.openCellDetailSearch\(\)/); + assert.match(contentAreaSource, /if \(target\.closest\("\[data-grid-root\]"\)\) return refreshData\(\)/); });