fix(editor): improve selection rendering

This commit is contained in:
t8y2 2026-06-07 14:23:18 +08:00
parent e3087e0954
commit 15b792a424
5 changed files with 143 additions and 60 deletions

View File

@ -66,6 +66,7 @@ import {
fontSizeFromWheelDelta,
} from "@/lib/editorZoom";
import { shortcutToCodeMirrorKey } from "@/lib/shortcutRegistry";
import { trimmedSelectionLayer } from "@/lib/codemirrorTrimmedSelectionLayer";
import * as api from "@/lib/api";
import {
areSqlSemanticDiagnosticsEqual,
@ -87,7 +88,6 @@ import type {
SqlTableReference,
SqlTextSpan,
} from "@/types/database";
import { vscodeSelectionLayer } from "@/lib/codemirrorVscodeSelectionLayer";
const props = defineProps<{
modelValue: string;
@ -1486,7 +1486,7 @@ onMounted(async () => {
history(),
foldGutter(),
drawSelection(),
vscodeSelectionLayer(),
trimmedSelectionLayer(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),

View File

@ -8,11 +8,11 @@ import {
highlightSpecialChars,
highlightActiveLine,
} from "@codemirror/view";
import { vscodeSelectionLayer } from "@/lib/codemirrorVscodeSelectionLayer";
import { json } from "@codemirror/lang-json";
import { search as cmSearch } from "@codemirror/search";
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
import { bracketMatching } from "@codemirror/language";
import { trimmedSelectionLayer } from "@/lib/codemirrorTrimmedSelectionLayer";
import {
EDITOR_FONT_FAMILY_CSS_VAR,
EDITOR_FONT_SIZE_CSS_VAR,
@ -192,7 +192,7 @@ export function useCellDetailEditor(options: UseCellDetailEditorOptions): UseCel
highlightSpecialChars(),
history(),
drawSelection(),
vscodeSelectionLayer(),
trimmedSelectionLayer(),
dropCursor(),
highlightActiveLine(),
EditorView.theme({

View File

@ -0,0 +1,122 @@
import { layer, RectangleMarker, type BlockInfo, type EditorView } from "@codemirror/view";
const MIN_EMPTY_LINE_WIDTH = 14;
const END_OF_LINE_PADDING = 3;
const CONTIGUOUS_LINE_GAP = 1.5;
const CORNER_COVERAGE_GAP = 1;
type TrimmedSelectionRect = {
left: number;
top: number;
width: number;
height: number;
};
function layerBase(view: EditorView) {
const rect = view.scrollDOM.getBoundingClientRect();
return {
left: rect.left - view.scrollDOM.scrollLeft * view.scaleX,
top: rect.top - view.scrollDOM.scrollTop * view.scaleY,
};
}
function markerForLineRange(
view: EditorView,
from: number,
to: number,
lineFrom: number,
lineTo: number,
lineBlock: BlockInfo,
includesLineBreak: boolean,
base: { left: number; top: number },
): TrimmedSelectionRect | null {
const start = view.coordsAtPos(from, 1);
const end = from < to ? view.coordsAtPos(to, -1) : start;
const lineStart = view.coordsAtPos(lineFrom, 1) ?? start;
const lineEnd = view.coordsAtPos(lineTo, -1) ?? end;
if (!start || !end || !lineStart || !lineEnd) return null;
const left = from < to ? Math.min(start.left, end.left) : lineStart.left;
let right = from < to ? Math.max(start.right, end.right) : left + MIN_EMPTY_LINE_WIDTH;
if (includesLineBreak && to >= lineTo) {
right = Math.max(right, lineEnd.right + END_OF_LINE_PADDING);
}
const top = view.documentTop + lineBlock.top;
const bottom = top + lineBlock.height;
return {
left: left - base.left,
top: top - base.top,
width: Math.max(1, right - left),
height: Math.max(1, bottom - top),
};
}
function coversX(rect: TrimmedSelectionRect | undefined, x: number): boolean {
if (!rect) return false;
return rect.left <= x + CORNER_COVERAGE_GAP && rect.left + rect.width >= x - CORNER_COVERAGE_GAP;
}
function markerClass(rects: TrimmedSelectionRect[], index: number): string {
const prev = rects[index - 1];
const current = rects[index];
const next = rects[index + 1];
const touchesPrev = !!prev && Math.abs(prev.top + prev.height - current.top) <= CONTIGUOUS_LINE_GAP;
const touchesNext = !!next && Math.abs(current.top + current.height - next.top) <= CONTIGUOUS_LINE_GAP;
const left = current.left;
const right = current.left + current.width;
const classes = ["cm-trimmedSelection"];
if (!touchesPrev || !coversX(prev, left)) classes.push("cm-trimmedSelection-topLeft");
if (!touchesPrev || !coversX(prev, right)) classes.push("cm-trimmedSelection-topRight");
if (!touchesNext || !coversX(next, left)) classes.push("cm-trimmedSelection-bottomLeft");
if (!touchesNext || !coversX(next, right)) classes.push("cm-trimmedSelection-bottomRight");
return classes.join(" ");
}
export function trimmedSelectionLayer() {
return layer({
above: false,
class: "cm-trimmedSelectionLayer",
markers(view) {
const markers: InstanceType<typeof RectangleMarker>[] = [];
const base = layerBase(view);
for (const range of view.state.selection.ranges) {
if (range.empty) continue;
const rects: TrimmedSelectionRect[] = [];
for (const visible of view.visibleRanges) {
let pos = Math.max(range.from, visible.from, view.viewport.from);
const endPos = Math.min(range.to, visible.to, view.viewport.to);
while (pos < endPos) {
const line = view.state.doc.lineAt(pos);
const from = Math.max(pos, line.from);
const to = Math.min(endPos, line.to);
const lineBlock = view.lineBlockAt(line.from);
const includesLineBreak = endPos > line.to && range.to > line.to;
const rect = markerForLineRange(view, from, to, line.from, line.to, lineBlock, includesLineBreak, base);
if (rect) rects.push(rect);
const next = line.to + 1;
if (next <= pos) break;
pos = next;
}
}
rects.sort((a, b) => a.top - b.top || a.left - b.left);
rects.forEach((rect, index) => {
markers.push(new RectangleMarker(markerClass(rects, index), rect.left, rect.top, rect.width, rect.height));
});
}
return markers;
},
update(update) {
return update.docChanged || update.selectionSet || update.viewportChanged || update.geometryChanged;
},
});
}

View File

@ -1,53 +0,0 @@
import { layer, RectangleMarker, type EditorView } from "@codemirror/view";
function domRangeForSelection(view: EditorView, from: number, to: number): Range | null {
try {
const start = view.domAtPos(from);
const end = view.domAtPos(to, -1);
const range = document.createRange();
range.setStart(start.node, start.offset);
range.setEnd(end.node, end.offset);
return range;
} catch {
// domAtPos may throw for positions outside the viewport.
}
return null;
}
export function vscodeSelectionLayer() {
return layer({
above: false,
class: "cm-vscodeSelectionLayer",
markers(view) {
const markers: InstanceType<typeof RectangleMarker>[] = [];
const baseRect = view.scrollDOM.getBoundingClientRect();
const base = {
left: baseRect.left - view.scrollDOM.scrollLeft * view.scaleX,
top: baseRect.top - view.scrollDOM.scrollTop * view.scaleY,
};
for (const r of view.state.selection.ranges) {
if (r.empty) continue;
const range = domRangeForSelection(view, r.from, r.to);
if (!range) continue;
const rects = Array.from(range.getClientRects());
for (const rect of rects) {
if (rect.width <= 0 || rect.height <= 0) continue;
markers.push(
new RectangleMarker(
"cm-vscodeSelection",
rect.left - base.left,
rect.top - base.top,
rect.width,
rect.height,
),
);
}
range.detach();
}
return markers;
},
update(update, _dom) {
return update.docChanged || update.selectionSet || update.viewportChanged || update.geometryChanged;
},
});
}

View File

@ -9,6 +9,7 @@ type LucideIconNode = Array<[string, Record<string, string>]>;
export const EDITOR_FONT_SIZE_CSS_VAR = "--dbx-editor-font-size";
export const EDITOR_FONT_FAMILY_CSS_VAR = "--dbx-editor-font-family";
const EDITOR_SELECTION_BACKGROUND_CSS_VAR = "--dbx-editor-selection-background";
const SUPPORTS_COLOR_MIX =
typeof CSS !== "undefined" &&
@ -82,6 +83,7 @@ function createCustomTheme(
"&": {
backgroundColor: c.background,
color: c.foreground,
[EDITOR_SELECTION_BACKGROUND_CSS_VAR]: c.selection,
},
".cm-content": {
caretColor: c.cursor,
@ -317,9 +319,21 @@ export function buildEditorFontThemeRules(
height: "1.6em !important",
transform: "translateY(-0.3em)",
},
".cm-vscodeSelection": {
opacity: "0.38",
background: "rgb(148, 163, 184)",
".cm-trimmedSelection": {
backgroundColor: `var(${EDITOR_SELECTION_BACKGROUND_CSS_VAR}, rgb(148 163 184 / 38%))`,
borderRadius: "0",
},
".cm-trimmedSelection-topLeft": {
borderTopLeftRadius: "3px",
},
".cm-trimmedSelection-topRight": {
borderTopRightRadius: "3px",
},
".cm-trimmedSelection-bottomLeft": {
borderBottomLeftRadius: "3px",
},
".cm-trimmedSelection-bottomRight": {
borderBottomRightRadius: "3px",
},
".cm-gutters": {
borderRight: "0 !important",