mirror of
https://github.com/t8y2/dbx.git
synced 2026-11-01 08:26:35 +00:00
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.
This commit is contained in:
@@ -0,0 +1,269 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { EditorView } from "@codemirror/view";
|
||||
import {
|
||||
SearchQuery,
|
||||
setSearchQuery,
|
||||
findNext as cmFindNext,
|
||||
findPrevious as cmFindPrevious,
|
||||
replaceNext as cmReplaceNext,
|
||||
replaceAll as cmReplaceAll,
|
||||
} from "@codemirror/search";
|
||||
import { ChevronUp, ChevronDown, ChevronRight, X } from "lucide-vue-next";
|
||||
|
||||
const props = defineProps<{
|
||||
view: EditorView | null;
|
||||
}>();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const searchVisible = ref(false);
|
||||
const searchText = ref("");
|
||||
const replaceText = ref("");
|
||||
const showReplace = ref(false);
|
||||
const caseSensitive = ref(false);
|
||||
const useRegex = ref(false);
|
||||
const matchCount = ref(0);
|
||||
const currentMatchIndex = ref(0);
|
||||
const searchInputRef = ref<HTMLInputElement>();
|
||||
const replaceInputRef = ref<HTMLInputElement>();
|
||||
|
||||
function dispatchSearchQuery() {
|
||||
const v = props.view;
|
||||
if (!v) return;
|
||||
const q = new SearchQuery({
|
||||
search: searchText.value,
|
||||
caseSensitive: caseSensitive.value,
|
||||
regexp: useRegex.value,
|
||||
replace: replaceText.value,
|
||||
});
|
||||
v.dispatch({ effects: setSearchQuery.of(q) });
|
||||
updateMatchInfo();
|
||||
}
|
||||
|
||||
function updateMatchInfo() {
|
||||
const v = props.view;
|
||||
if (!v || !searchText.value) {
|
||||
matchCount.value = 0;
|
||||
currentMatchIndex.value = 0;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const q = new SearchQuery({
|
||||
search: searchText.value,
|
||||
caseSensitive: caseSensitive.value,
|
||||
regexp: useRegex.value,
|
||||
});
|
||||
if (!q.valid) {
|
||||
matchCount.value = 0;
|
||||
currentMatchIndex.value = 0;
|
||||
return;
|
||||
}
|
||||
const iter = q.getCursor(v.state);
|
||||
let count = 0;
|
||||
let curIdx = 0;
|
||||
const selFrom = v.state.selection.main.from;
|
||||
const selTo = v.state.selection.main.to;
|
||||
let r = iter.next();
|
||||
while (!r.done) {
|
||||
count++;
|
||||
if (r.value.from === selFrom && r.value.to === selTo) curIdx = count;
|
||||
r = iter.next();
|
||||
}
|
||||
matchCount.value = count;
|
||||
currentMatchIndex.value = curIdx || (count > 0 ? 1 : 0);
|
||||
} catch {
|
||||
matchCount.value = 0;
|
||||
currentMatchIndex.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function openSearch(): boolean {
|
||||
searchVisible.value = true;
|
||||
const v = props.view;
|
||||
if (v) {
|
||||
const sel = v.state.sliceDoc(v.state.selection.main.from, v.state.selection.main.to);
|
||||
if (sel && !sel.includes("\n")) searchText.value = sel;
|
||||
}
|
||||
nextTick(() => {
|
||||
searchInputRef.value?.focus();
|
||||
searchInputRef.value?.select();
|
||||
});
|
||||
if (searchText.value) dispatchSearchQuery();
|
||||
return true;
|
||||
}
|
||||
|
||||
function openReplace(): boolean {
|
||||
openSearch();
|
||||
showReplace.value = true;
|
||||
nextTick(() => {
|
||||
replaceInputRef.value?.focus();
|
||||
replaceInputRef.value?.select();
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function closeSearch() {
|
||||
const wasVisible = searchVisible.value;
|
||||
searchVisible.value = false;
|
||||
showReplace.value = false;
|
||||
const v = props.view;
|
||||
if (v) {
|
||||
v.dispatch({ effects: setSearchQuery.of(new SearchQuery({ search: "" })) });
|
||||
v.focus();
|
||||
}
|
||||
matchCount.value = 0;
|
||||
currentMatchIndex.value = 0;
|
||||
return wasVisible;
|
||||
}
|
||||
|
||||
function nextMatch() {
|
||||
const v = props.view;
|
||||
if (!v || !searchText.value) return;
|
||||
cmFindNext(v);
|
||||
updateMatchInfo();
|
||||
}
|
||||
|
||||
function prevMatch() {
|
||||
const v = props.view;
|
||||
if (!v || !searchText.value) return;
|
||||
cmFindPrevious(v);
|
||||
updateMatchInfo();
|
||||
}
|
||||
|
||||
function doReplace() {
|
||||
const v = props.view;
|
||||
if (!v || !searchText.value) return;
|
||||
cmReplaceNext(v);
|
||||
updateMatchInfo();
|
||||
}
|
||||
|
||||
function doReplaceAll() {
|
||||
const v = props.view;
|
||||
if (!v || !searchText.value) return;
|
||||
cmReplaceAll(v);
|
||||
updateMatchInfo();
|
||||
}
|
||||
|
||||
function onSearchKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
closeSearch();
|
||||
} else if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
nextMatch();
|
||||
} else if (e.key === "Enter" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
prevMatch();
|
||||
}
|
||||
}
|
||||
|
||||
watch([searchText, caseSensitive, useRegex, replaceText], () => {
|
||||
if (searchVisible.value) dispatchSearchQuery();
|
||||
});
|
||||
|
||||
defineExpose({ openSearch, openReplace, closeSearch });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-150"
|
||||
leave-active-class="transition-all duration-100"
|
||||
enter-from-class="opacity-0 -translate-y-1"
|
||||
leave-to-class="opacity-0 -translate-y-1"
|
||||
>
|
||||
<div
|
||||
v-if="searchVisible"
|
||||
class="absolute top-1 right-4 z-20 bg-background border rounded-md shadow-md p-1.5 flex flex-col gap-1"
|
||||
>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<button
|
||||
class="w-5 h-5 flex items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
:title="showReplace ? t('editor.search.collapseReplace') : t('editor.search.expandReplace')"
|
||||
@click="showReplace = !showReplace"
|
||||
>
|
||||
<ChevronRight class="w-3 h-3 transition-transform" :class="showReplace && 'rotate-90'" />
|
||||
</button>
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="searchText"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="w-48 h-6 text-xs bg-input border rounded px-2 outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground"
|
||||
:placeholder="t('editor.search.find')"
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
<button
|
||||
class="w-6 h-6 flex items-center justify-center rounded text-xs font-mono hover:bg-accent"
|
||||
:class="caseSensitive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'"
|
||||
:title="t('editor.search.caseSensitive')"
|
||||
@click="caseSensitive = !caseSensitive"
|
||||
>
|
||||
Aa
|
||||
</button>
|
||||
<button
|
||||
class="w-6 h-6 flex items-center justify-center rounded text-xs font-mono hover:bg-accent"
|
||||
:class="useRegex ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'"
|
||||
:title="t('editor.search.regex')"
|
||||
@click="useRegex = !useRegex"
|
||||
>
|
||||
.*
|
||||
</button>
|
||||
<span class="text-xs text-muted-foreground min-w-[3rem] text-center shrink-0">
|
||||
{{ searchText && matchCount > 0 ? `${currentMatchIndex}/${matchCount}` : t("editor.search.noResults") }}
|
||||
</span>
|
||||
<button
|
||||
class="w-5 h-5 flex items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
:title="t('editor.search.prevMatch')"
|
||||
@click="prevMatch"
|
||||
>
|
||||
<ChevronUp class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
class="w-5 h-5 flex items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
:title="t('editor.search.nextMatch')"
|
||||
@click="nextMatch"
|
||||
>
|
||||
<ChevronDown class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
class="w-5 h-5 flex items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
:title="t('editor.search.close')"
|
||||
@click="closeSearch"
|
||||
>
|
||||
<X class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="showReplace" class="flex items-center gap-0.5">
|
||||
<div class="w-5 h-5 shrink-0" />
|
||||
<input
|
||||
ref="replaceInputRef"
|
||||
v-model="replaceText"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="w-48 h-6 text-xs bg-input border rounded px-2 outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground"
|
||||
:placeholder="t('editor.search.replace')"
|
||||
@keydown.enter.prevent="doReplace"
|
||||
@keydown.escape.prevent="closeSearch"
|
||||
/>
|
||||
<button
|
||||
class="h-6 px-1.5 flex items-center justify-center rounded text-xs text-muted-foreground hover:bg-accent hover:text-foreground border"
|
||||
:title="t('editor.search.replace')"
|
||||
@click="doReplace"
|
||||
>
|
||||
{{ t("editor.search.replace") }}
|
||||
</button>
|
||||
<button
|
||||
class="h-6 px-1.5 flex items-center justify-center rounded text-xs text-muted-foreground hover:bg-accent hover:text-foreground border"
|
||||
:title="t('editor.search.replaceAll')"
|
||||
@click="doReplaceAll"
|
||||
>
|
||||
{{ t("editor.search.replaceAll") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
@@ -1,18 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, nextTick, onMounted, onBeforeUnmount, watch, shallowRef, computed } from "vue";
|
||||
import { ref, onMounted, onBeforeUnmount, watch, shallowRef, computed } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import type { CompletionContext } from "@codemirror/autocomplete";
|
||||
import type { EditorView as EditorViewType } from "@codemirror/view";
|
||||
import {
|
||||
SearchQuery,
|
||||
setSearchQuery,
|
||||
findNext as cmFindNext,
|
||||
findPrevious as cmFindPrevious,
|
||||
replaceNext as cmReplaceNext,
|
||||
replaceAll as cmReplaceAll,
|
||||
search as cmSearch,
|
||||
} from "@codemirror/search";
|
||||
import { ChevronUp, ChevronDown, ChevronRight, X } from "lucide-vue-next";
|
||||
import { search as cmSearch } from "@codemirror/search";
|
||||
import EditorSearchPanel from "./EditorSearchPanel.vue";
|
||||
import { resolveExecutableSql } from "@/lib/sqlExecutionTarget";
|
||||
import { formatSqlText, type SqlFormatDialect } from "@/lib/sqlFormatter";
|
||||
import { useConnectionStore } from "@/stores/connectionStore";
|
||||
@@ -139,22 +131,14 @@ const liveFontSize = ref(settingsStore.editorSettings.fontSize);
|
||||
const gestureStartFontSize = ref(settingsStore.editorSettings.fontSize);
|
||||
const isGestureZooming = ref(false);
|
||||
|
||||
const searchVisible = ref(false);
|
||||
const searchText = ref("");
|
||||
const replaceText = ref("");
|
||||
const showReplace = ref(false);
|
||||
const caseSensitive = ref(false);
|
||||
const useRegex = ref(false);
|
||||
const matchCount = ref(0);
|
||||
const currentMatchIndex = ref(0);
|
||||
const searchInputRef = ref<HTMLInputElement>();
|
||||
const replaceInputRef = ref<HTMLInputElement>();
|
||||
const searchPanelRef = ref<InstanceType<typeof EditorSearchPanel>>();
|
||||
|
||||
interface EditorGestureEvent extends Event {
|
||||
scale?: number;
|
||||
}
|
||||
|
||||
let editorViewModule: typeof import("@codemirror/view") | null = null;
|
||||
let codeMirrorPrec: typeof import("@codemirror/state").Prec | null = null;
|
||||
let fontThemeComp: import("@codemirror/state").Compartment | null = null;
|
||||
let codeMirrorTheme: import("@codemirror/state").Compartment | null = null;
|
||||
let wordWrapComp: import("@codemirror/state").Compartment | null = null;
|
||||
@@ -287,66 +271,73 @@ function handleTab(view: EditorViewType): boolean {
|
||||
|
||||
function runKeymapExtension(codeMirrorKeymap: (typeof import("@codemirror/view"))["keymap"]) {
|
||||
const shortcuts = settingsStore.editorSettings.shortcuts;
|
||||
return codeMirrorKeymap.of([
|
||||
{
|
||||
key: "Mod-=",
|
||||
run: () => {
|
||||
zoomIn();
|
||||
return true;
|
||||
const Prec = codeMirrorPrec;
|
||||
return [
|
||||
Prec?.high(
|
||||
codeMirrorKeymap.of([
|
||||
{
|
||||
key: shortcutToCodeMirrorKey(shortcuts.find),
|
||||
preventDefault: true,
|
||||
run: openSearch,
|
||||
},
|
||||
{
|
||||
key: shortcutToCodeMirrorKey(shortcuts.replace),
|
||||
preventDefault: true,
|
||||
run: openReplace,
|
||||
},
|
||||
]),
|
||||
) ?? [],
|
||||
codeMirrorKeymap.of([
|
||||
{
|
||||
key: "Mod-=",
|
||||
run: () => {
|
||||
zoomIn();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "Mod-+",
|
||||
run: () => {
|
||||
zoomIn();
|
||||
return true;
|
||||
{
|
||||
key: "Mod-+",
|
||||
run: () => {
|
||||
zoomIn();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "Mod--",
|
||||
run: () => {
|
||||
zoomOut();
|
||||
return true;
|
||||
{
|
||||
key: "Mod--",
|
||||
run: () => {
|
||||
zoomOut();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "Mod-0",
|
||||
run: () => {
|
||||
resetZoom();
|
||||
return true;
|
||||
{
|
||||
key: "Mod-0",
|
||||
run: () => {
|
||||
resetZoom();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "Mod-h",
|
||||
preventDefault: true,
|
||||
run: openReplace,
|
||||
},
|
||||
{
|
||||
key: "Ctrl-h",
|
||||
preventDefault: true,
|
||||
run: openReplace,
|
||||
},
|
||||
{
|
||||
key: shortcutToCodeMirrorKey(shortcuts.executeSql),
|
||||
preventDefault: true,
|
||||
run: () => {
|
||||
if (view.value) emit("execute", executableSqlFromView(view.value));
|
||||
return true;
|
||||
{
|
||||
key: shortcutToCodeMirrorKey(shortcuts.executeSql),
|
||||
preventDefault: true,
|
||||
run: () => {
|
||||
if (view.value) emit("execute", executableSqlFromView(view.value));
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: shortcutToCodeMirrorKey(shortcuts.saveSql),
|
||||
preventDefault: true,
|
||||
run: () => {
|
||||
emit("save");
|
||||
return true;
|
||||
{
|
||||
key: shortcutToCodeMirrorKey(shortcuts.saveSql),
|
||||
preventDefault: true,
|
||||
run: () => {
|
||||
emit("save");
|
||||
return true;
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
key: shortcutToCodeMirrorKey(shortcuts.acceptCompletion),
|
||||
run: (view) => codeMirrorAcceptCompletion?.(view) ?? false,
|
||||
},
|
||||
]);
|
||||
{
|
||||
key: shortcutToCodeMirrorKey(shortcuts.acceptCompletion),
|
||||
run: (view) => codeMirrorAcceptCompletion?.(view) ?? false,
|
||||
},
|
||||
]),
|
||||
];
|
||||
}
|
||||
|
||||
function wordWrapExtension() {
|
||||
@@ -945,6 +936,7 @@ onMounted(async () => {
|
||||
import("@codemirror/language"),
|
||||
]);
|
||||
editorViewModule = { EditorView, keymap, rectangularSelection } as typeof import("@codemirror/view");
|
||||
codeMirrorPrec = Prec;
|
||||
codeMirrorSnippetCompletion = snippetCompletion;
|
||||
fontThemeComp = new Compartment();
|
||||
codeMirrorTheme = new Compartment();
|
||||
@@ -1050,7 +1042,18 @@ onMounted(async () => {
|
||||
hoverTooltip((currentView, pos) => resolveSqlHoverTooltip(currentView, pos)),
|
||||
buildSqlSignatureExtension(),
|
||||
diagnosticComp.of(buildSqlDiagnosticExtension()),
|
||||
Prec.highest(keymap.of([...closeBracketsKeymap, { key: "Tab", run: handleTab }])),
|
||||
Prec.highest(
|
||||
keymap.of([
|
||||
...closeBracketsKeymap,
|
||||
{ key: "Tab", run: handleTab },
|
||||
{
|
||||
key: "Escape",
|
||||
run: () => {
|
||||
return searchPanelRef.value?.closeSearch() ?? false;
|
||||
},
|
||||
},
|
||||
]),
|
||||
),
|
||||
runKeymapComp.of(runKeymapExtension(keymap)),
|
||||
wordWrapComp.of(props.forceWordWrap || ss.wordWrap ? EditorView.lineWrapping : []),
|
||||
readOnlyComp.of([EditorState.readOnly.of(!!props.readOnly), EditorView.editable.of(!props.readOnly)]),
|
||||
@@ -1070,7 +1073,6 @@ onMounted(async () => {
|
||||
if (update.selectionSet || update.docChanged) {
|
||||
emit("selectionChange", selectedSqlFromView(update.view));
|
||||
emit("cursorChange", update.state.selection.main.head);
|
||||
if (searchVisible.value) updateMatchInfo();
|
||||
}
|
||||
}),
|
||||
fontThemeComp.of(
|
||||
@@ -1311,139 +1313,15 @@ onBeforeUnmount(() => {
|
||||
view.value?.destroy();
|
||||
});
|
||||
|
||||
function dispatchSearchQuery() {
|
||||
const v = view.value;
|
||||
if (!v) return;
|
||||
const q = new SearchQuery({
|
||||
search: searchText.value,
|
||||
caseSensitive: caseSensitive.value,
|
||||
regexp: useRegex.value,
|
||||
replace: replaceText.value,
|
||||
});
|
||||
v.dispatch({ effects: setSearchQuery.of(q) });
|
||||
updateMatchInfo();
|
||||
}
|
||||
|
||||
function updateMatchInfo() {
|
||||
const v = view.value;
|
||||
if (!v || !searchText.value) {
|
||||
matchCount.value = 0;
|
||||
currentMatchIndex.value = 0;
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const q = new SearchQuery({
|
||||
search: searchText.value,
|
||||
caseSensitive: caseSensitive.value,
|
||||
regexp: useRegex.value,
|
||||
});
|
||||
if (!q.valid) {
|
||||
matchCount.value = 0;
|
||||
currentMatchIndex.value = 0;
|
||||
return;
|
||||
}
|
||||
const iter = q.getCursor(v.state);
|
||||
let count = 0;
|
||||
let curIdx = 0;
|
||||
const selFrom = v.state.selection.main.from;
|
||||
const selTo = v.state.selection.main.to;
|
||||
let r = iter.next();
|
||||
while (!r.done) {
|
||||
count++;
|
||||
if (r.value.from === selFrom && r.value.to === selTo) curIdx = count;
|
||||
r = iter.next();
|
||||
}
|
||||
matchCount.value = count;
|
||||
currentMatchIndex.value = curIdx || (count > 0 ? 1 : 0);
|
||||
} catch {
|
||||
matchCount.value = 0;
|
||||
currentMatchIndex.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
function openSearch(): boolean {
|
||||
searchVisible.value = true;
|
||||
const v = view.value;
|
||||
if (v) {
|
||||
const sel = v.state.sliceDoc(v.state.selection.main.from, v.state.selection.main.to);
|
||||
if (sel && !sel.includes("\n")) searchText.value = sel;
|
||||
}
|
||||
nextTick(() => {
|
||||
searchInputRef.value?.focus();
|
||||
searchInputRef.value?.select();
|
||||
});
|
||||
if (searchText.value) dispatchSearchQuery();
|
||||
return true;
|
||||
return searchPanelRef.value?.openSearch() ?? false;
|
||||
}
|
||||
|
||||
function openReplace(): boolean {
|
||||
openSearch();
|
||||
showReplace.value = true;
|
||||
nextTick(() => {
|
||||
replaceInputRef.value?.focus();
|
||||
replaceInputRef.value?.select();
|
||||
});
|
||||
return true;
|
||||
return searchPanelRef.value?.openReplace() ?? false;
|
||||
}
|
||||
|
||||
function closeSearch() {
|
||||
searchVisible.value = false;
|
||||
showReplace.value = false;
|
||||
const v = view.value;
|
||||
if (v) {
|
||||
v.dispatch({ effects: setSearchQuery.of(new SearchQuery({ search: "" })) });
|
||||
v.focus();
|
||||
}
|
||||
matchCount.value = 0;
|
||||
currentMatchIndex.value = 0;
|
||||
}
|
||||
|
||||
function nextMatch() {
|
||||
const v = view.value;
|
||||
if (!v || !searchText.value) return;
|
||||
cmFindNext(v);
|
||||
updateMatchInfo();
|
||||
}
|
||||
|
||||
function prevMatch() {
|
||||
const v = view.value;
|
||||
if (!v || !searchText.value) return;
|
||||
cmFindPrevious(v);
|
||||
updateMatchInfo();
|
||||
}
|
||||
|
||||
function doReplace() {
|
||||
const v = view.value;
|
||||
if (!v || !searchText.value) return;
|
||||
cmReplaceNext(v);
|
||||
updateMatchInfo();
|
||||
}
|
||||
|
||||
function doReplaceAll() {
|
||||
const v = view.value;
|
||||
if (!v || !searchText.value) return;
|
||||
cmReplaceAll(v);
|
||||
updateMatchInfo();
|
||||
}
|
||||
|
||||
function onSearchKeydown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
closeSearch();
|
||||
} else if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
nextMatch();
|
||||
} else if (e.key === "Enter" && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
prevMatch();
|
||||
}
|
||||
}
|
||||
|
||||
watch([searchText, caseSensitive, useRegex, replaceText], () => {
|
||||
if (searchVisible.value) dispatchSearchQuery();
|
||||
});
|
||||
|
||||
defineExpose({ openSearch });
|
||||
defineExpose({ openSearch, openReplace });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -1454,102 +1332,6 @@ defineExpose({ openSearch });
|
||||
@gestureend="onEditorGestureEnd"
|
||||
>
|
||||
<div ref="editorRef" data-query-editor-root class="h-full w-full overflow-hidden" />
|
||||
<Transition
|
||||
enter-active-class="transition-all duration-150"
|
||||
leave-active-class="transition-all duration-100"
|
||||
enter-from-class="opacity-0 -translate-y-1"
|
||||
leave-to-class="opacity-0 -translate-y-1"
|
||||
>
|
||||
<div
|
||||
v-if="searchVisible"
|
||||
class="absolute top-1 right-4 z-20 bg-background border rounded-md shadow-md p-1.5 flex flex-col gap-1"
|
||||
>
|
||||
<div class="flex items-center gap-0.5">
|
||||
<input
|
||||
ref="searchInputRef"
|
||||
v-model="searchText"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="w-48 h-6 text-xs bg-input border rounded px-2 outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground"
|
||||
:placeholder="t('editor.search.find')"
|
||||
@keydown="onSearchKeydown"
|
||||
/>
|
||||
<button
|
||||
class="w-6 h-6 flex items-center justify-center rounded text-xs font-mono hover:bg-accent"
|
||||
:class="caseSensitive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'"
|
||||
:title="t('editor.search.caseSensitive')"
|
||||
@click="caseSensitive = !caseSensitive"
|
||||
>
|
||||
Aa
|
||||
</button>
|
||||
<button
|
||||
class="w-6 h-6 flex items-center justify-center rounded text-xs font-mono hover:bg-accent"
|
||||
:class="useRegex ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'"
|
||||
:title="t('editor.search.regex')"
|
||||
@click="useRegex = !useRegex"
|
||||
>
|
||||
.*
|
||||
</button>
|
||||
<span v-if="searchText" class="text-xs text-muted-foreground min-w-[3rem] text-center shrink-0">
|
||||
{{ matchCount > 0 ? `${currentMatchIndex}/${matchCount}` : t("editor.search.noResults") }}
|
||||
</span>
|
||||
<button
|
||||
class="w-5 h-5 flex items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
:title="t('editor.search.prevMatch')"
|
||||
@click="prevMatch"
|
||||
>
|
||||
<ChevronUp class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
class="w-5 h-5 flex items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
:title="t('editor.search.nextMatch')"
|
||||
@click="nextMatch"
|
||||
>
|
||||
<ChevronDown class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
class="w-5 h-5 flex items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
:title="showReplace ? t('editor.search.collapseReplace') : t('editor.search.expandReplace')"
|
||||
@click="showReplace = !showReplace"
|
||||
>
|
||||
<ChevronRight class="w-3 h-3 transition-transform" :class="showReplace && 'rotate-90'" />
|
||||
</button>
|
||||
<button
|
||||
class="w-5 h-5 flex items-center justify-center rounded text-muted-foreground hover:bg-accent hover:text-foreground"
|
||||
:title="t('editor.search.close')"
|
||||
@click="closeSearch"
|
||||
>
|
||||
<X class="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="showReplace" class="flex items-center gap-0.5">
|
||||
<input
|
||||
ref="replaceInputRef"
|
||||
v-model="replaceText"
|
||||
autocapitalize="off"
|
||||
autocorrect="off"
|
||||
spellcheck="false"
|
||||
class="w-48 h-6 text-xs bg-input border rounded px-2 outline-none focus:ring-1 focus:ring-ring placeholder:text-muted-foreground"
|
||||
:placeholder="t('editor.search.replace')"
|
||||
@keydown.enter.prevent="doReplace"
|
||||
/>
|
||||
<button
|
||||
class="h-6 px-1.5 flex items-center justify-center rounded text-xs text-muted-foreground hover:bg-accent hover:text-foreground border"
|
||||
:title="t('editor.search.replace')"
|
||||
@click="doReplace"
|
||||
>
|
||||
{{ t("editor.search.replace") }}
|
||||
</button>
|
||||
<button
|
||||
class="h-6 px-1.5 flex items-center justify-center rounded text-xs text-muted-foreground hover:bg-accent hover:text-foreground border"
|
||||
:title="t('editor.search.replaceAll')"
|
||||
@click="doReplaceAll"
|
||||
>
|
||||
{{ t("editor.search.replaceAll") }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
<EditorSearchPanel ref="searchPanelRef" :view="view" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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<void>;
|
||||
setValue: (value: string, columnType?: string) => void;
|
||||
getValue: () => string;
|
||||
openSearch: () => boolean;
|
||||
openReplace: () => boolean;
|
||||
destroy: () => void;
|
||||
view: Readonly<ShallowRef<EditorView | null>>;
|
||||
}
|
||||
@@ -55,6 +60,9 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel
|
||||
|
||||
let destroyed = false;
|
||||
let currentIsJson = false;
|
||||
let searchApp: ReturnType<typeof createApp> | null = null;
|
||||
let searchInstance: InstanceType<typeof EditorSearchPanel> | null = null;
|
||||
let wrapperEl: HTMLDivElement | null = null;
|
||||
|
||||
async function create(parent: HTMLElement, initialValue: string, columnType?: string): Promise<void> {
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user