feat: highlight selected SQL matches

This commit is contained in:
t8y2
2026-06-07 17:43:11 +08:00
parent a473f4ff8f
commit 4a64240e8a
2 changed files with 143 additions and 0 deletions
@@ -67,6 +67,7 @@ import {
} from "@/lib/editorZoom";
import { shortcutToCodeMirrorKey } from "@/lib/shortcutRegistry";
import { trimmedSelectionLayer } from "@/lib/codemirrorTrimmedSelectionLayer";
import { selectionMatchOccurrences } from "@/lib/codemirrorSelectionMatches";
import * as api from "@/lib/api";
import {
areSqlSemanticDiagnosticsEqual,
@@ -1487,6 +1488,7 @@ onMounted(async () => {
foldGutter(),
drawSelection(),
trimmedSelectionLayer(),
selectionMatchOccurrences(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
@@ -0,0 +1,141 @@
import type { EditorState, Extension } from "@codemirror/state";
import { highlightSelectionMatches, SearchCursor } from "@codemirror/search";
import { EditorView, ViewPlugin, type ViewUpdate } from "@codemirror/view";
const MIN_SELECTION_MATCH_LENGTH = 2;
const MAX_SELECTION_MATCH_LENGTH = 200;
const MAX_SCROLLBAR_MARKS = 1200;
interface SelectionMatchMarker {
from: number;
to: number;
lineNumber: number;
selected: boolean;
}
function selectedMatchQuery(state: EditorState): string | null {
const selection = state.selection.main;
if (selection.empty) return null;
if (selection.to - selection.from > MAX_SELECTION_MATCH_LENGTH) return null;
const text = state.sliceDoc(selection.from, selection.to);
if (text.length < MIN_SELECTION_MATCH_LENGTH || !text.trim() || /[\r\n]/.test(text)) return null;
return text;
}
function buildSelectionMatchMarkers(state: EditorState): SelectionMatchMarker[] {
const query = selectedMatchQuery(state);
if (!query) return [];
const selection = state.selection.main;
const markers: SelectionMatchMarker[] = [];
const seenLines = new Set<number>();
const cursor = new SearchCursor(state.doc, query);
for (let match = cursor.next(); !match.done; match = cursor.next()) {
const { from, to } = match.value;
if (from === to) continue;
const line = state.doc.lineAt(from);
const selected = from === selection.from && to === selection.to;
const lineKey = selected ? -line.number : line.number;
if (seenLines.has(lineKey)) continue;
seenLines.add(lineKey);
markers.push({ from, to, lineNumber: line.number, selected });
if (markers.length >= MAX_SCROLLBAR_MARKS) break;
}
return markers;
}
class SelectionMatchScrollbar {
private readonly layer: HTMLElement;
private signature = "";
constructor(view: EditorView) {
this.layer = document.createElement("div");
this.layer.className = "cm-selectionMatchScrollbarLayer";
view.dom.appendChild(this.layer);
this.render(view);
}
update(update: ViewUpdate) {
if (update.docChanged || update.selectionSet || update.geometryChanged) {
this.render(update.view);
}
}
destroy() {
this.layer.remove();
}
private render(view: EditorView) {
const markers = buildSelectionMatchMarkers(view.state);
const lineCount = view.state.doc.lines;
const signature = markers
.map((marker) => `${marker.lineNumber}:${marker.from}:${marker.to}:${marker.selected}`)
.join("|");
if (signature === this.signature) return;
this.signature = signature;
this.layer.replaceChildren(
...markers.map((marker) => {
const el = document.createElement("div");
el.className = marker.selected
? "cm-selectionMatchScrollbarMark cm-selectionMatchScrollbarMark-selected"
: "cm-selectionMatchScrollbarMark";
const ratio = lineCount <= 1 ? 0 : (marker.lineNumber - 1) / (lineCount - 1);
el.style.top = `${Math.max(0, Math.min(1, ratio)) * 100}%`;
return el;
}),
);
}
}
const selectionMatchScrollbar = ViewPlugin.fromClass(SelectionMatchScrollbar);
export function selectionMatchOccurrences(): Extension {
return [
highlightSelectionMatches({
minSelectionLength: MIN_SELECTION_MATCH_LENGTH,
maxMatches: 500,
}),
selectionMatchScrollbar,
EditorView.baseTheme({
"&": {
position: "relative",
},
".cm-selectionMatch": {
backgroundColor: "color-mix(in oklab, var(--foreground) 8%, transparent)",
outline: "1px solid color-mix(in oklab, var(--foreground) 10%, transparent)",
},
".cm-selectionMatch-main": {
backgroundColor: "color-mix(in oklab, var(--primary) 14%, transparent)",
outline: "1px solid color-mix(in oklab, var(--primary) 24%, transparent)",
},
".cm-selectionMatchScrollbarLayer": {
bottom: "2px",
pointerEvents: "none",
position: "absolute",
right: "2px",
top: "2px",
width: "5px",
zIndex: "40",
},
".cm-selectionMatchScrollbarMark": {
backgroundColor: "color-mix(in oklab, var(--primary) 48%, transparent)",
borderRadius: "999px",
height: "2px",
position: "absolute",
right: "0",
transform: "translateY(-50%)",
width: "3px",
},
".cm-selectionMatchScrollbarMark-selected": {
backgroundColor: "color-mix(in oklab, var(--primary) 72%, transparent)",
height: "3px",
width: "4px",
},
}),
];
}