fix(editor): rebuild current SQL statement frame with pixel-accurate layer
This commit is contained in:
parent
7c2528d32f
commit
a4129c02fa
|
|
@ -95,7 +95,8 @@ import { eventToModifierOnlyShortcut, eventToShortcut } from "@/lib/editor/keybo
|
|||
import { SHORTCUT_DEFINITIONS, findShortcutConflict, normalizeShortcutSettings, type ShortcutActionId } from "@/lib/editor/shortcutRegistry";
|
||||
import { formatShortcutDisplay } from "@/lib/editor/shortcutDisplay";
|
||||
import { normalizeSidebarHiddenTablePrefixes } from "@/lib/sidebar/sidebarTableNameDisplay";
|
||||
import { currentStatementFrameRangeTo, visualSqlColumnsWithInlineHints } from "@/lib/sql/currentStatementFrame";
|
||||
import { currentStatementFrameRangeTo } from "@/lib/sql/currentStatementFrame";
|
||||
import { currentStatementFrameLayer } from "@/lib/editor/codemirrorCurrentStatementFrameLayer";
|
||||
import { normalizeSqlFormatterSettings, type SqlFormatterSettings } from "@/lib/sql/sqlFormatterConfig";
|
||||
import { validateConfigName, generateId, type AiConfigItem, type ConfigNameValidationResult } from "@/lib/ai/aiConfigList";
|
||||
import { currentExecutableStatementRange, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
|
||||
|
|
@ -3211,79 +3212,28 @@ function handlePreviewRunGutterMouseDown(currentView: EditorViewType, line: { fr
|
|||
return true;
|
||||
}
|
||||
|
||||
function buildPreviewCurrentStatementFrameExtension(viewModule: Pick<typeof import("@codemirror/view"), "Decoration" | "EditorView" | "ViewPlugin">, enabled: boolean) {
|
||||
function buildPreviewCurrentStatementFrameExtension(viewModule: Pick<typeof import("@codemirror/view"), "EditorView" | "layer" | "RectangleMarker">, enabled: boolean) {
|
||||
if (!enabled) return [];
|
||||
const { Decoration, EditorView, ViewPlugin } = viewModule;
|
||||
const { EditorView } = viewModule;
|
||||
const frameTheme = EditorView.baseTheme({
|
||||
".cm-db-current-statement-line": {
|
||||
position: "relative",
|
||||
},
|
||||
".cm-db-current-statement-line::after": {
|
||||
content: '""',
|
||||
position: "absolute",
|
||||
top: "0",
|
||||
bottom: "0",
|
||||
left: "0",
|
||||
boxSizing: "border-box",
|
||||
width: "var(--dbx-current-statement-frame-width, 100%)",
|
||||
borderRight: "1px solid rgb(34 197 94 / 0.75)",
|
||||
borderLeft: "1px solid rgb(34 197 94 / 0.75)",
|
||||
".cm-db-currentStatementFrameLayer": {
|
||||
pointerEvents: "none",
|
||||
},
|
||||
".cm-db-current-statement-line--first::after": {
|
||||
borderTop: "1px solid rgb(34 197 94 / 0.75)",
|
||||
},
|
||||
".cm-db-current-statement-line--last::after": {
|
||||
borderBottom: "1px solid rgb(34 197 94 / 0.75)",
|
||||
".cm-db-currentStatementFrame": {
|
||||
boxSizing: "border-box",
|
||||
border: "1px solid rgb(34 197 94 / 0.75)",
|
||||
borderRadius: "2px",
|
||||
pointerEvents: "none",
|
||||
},
|
||||
});
|
||||
const framePlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: import("@codemirror/view").DecorationSet;
|
||||
constructor(view: import("@codemirror/view").EditorView) {
|
||||
this.decorations = this.getDeco(view);
|
||||
}
|
||||
update(update: import("@codemirror/view").ViewUpdate) {
|
||||
this.decorations = this.getDeco(update.view);
|
||||
}
|
||||
getDeco(view: import("@codemirror/view").EditorView) {
|
||||
if (view.state.selection.ranges.some((range) => !range.empty)) return Decoration.none;
|
||||
const range = currentExecutableStatementRange(view.state.doc.toString(), view.state.selection.main.head, "mysql");
|
||||
if (!range) return Decoration.none;
|
||||
const frameLayer = currentStatementFrameLayer({ layer: viewModule.layer, RectangleMarker: viewModule.RectangleMarker }, (view) => {
|
||||
if (view.state.selection.ranges.some((range) => !range.empty)) return null;
|
||||
const range = currentExecutableStatementRange(view.state.doc.toString(), view.state.selection.main.head, "mysql");
|
||||
if (!range) return null;
|
||||
return { from: range.from, to: previewCurrentStatementFrameTo(view, range) };
|
||||
});
|
||||
|
||||
const startLine = view.state.doc.lineAt(range.from);
|
||||
const frameTo = previewCurrentStatementFrameTo(view, range);
|
||||
const endLine = view.state.doc.lineAt(Math.max(range.from, frameTo - 1));
|
||||
let maxWidth = 1;
|
||||
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) {
|
||||
const line = view.state.doc.line(lineNumber);
|
||||
const lineRangeTo = Math.min(line.to, frameTo);
|
||||
maxWidth = Math.max(maxWidth, visualSqlColumnsWithInlineHints(view.state.doc.sliceString(line.from, lineRangeTo), line.from, lineRangeTo));
|
||||
}
|
||||
|
||||
const deco: any[] = [];
|
||||
const frameWidth = `calc(${maxWidth}ch + 2ch)`;
|
||||
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) {
|
||||
const line = view.state.doc.line(lineNumber);
|
||||
const classes = ["cm-db-current-statement-line"];
|
||||
if (lineNumber === startLine.number) classes.push("cm-db-current-statement-line--first");
|
||||
if (lineNumber === endLine.number) classes.push("cm-db-current-statement-line--last");
|
||||
deco.push(
|
||||
Decoration.line({
|
||||
class: classes.join(" "),
|
||||
attributes: {
|
||||
style: `--dbx-current-statement-frame-width: ${frameWidth};`,
|
||||
},
|
||||
}).range(line.from),
|
||||
);
|
||||
}
|
||||
return Decoration.set(deco);
|
||||
}
|
||||
},
|
||||
{ decorations: (v) => v.decorations },
|
||||
);
|
||||
|
||||
return [framePlugin, frameTheme];
|
||||
return [frameLayer, frameTheme];
|
||||
}
|
||||
|
||||
function previewCurrentStatementFrameTo(view: import("@codemirror/view").EditorView, range: SqlTextRange): number {
|
||||
|
|
@ -3348,12 +3298,19 @@ watch(previewRef, async (el) => {
|
|||
previewInitialized = true;
|
||||
if (previewView.value) return;
|
||||
|
||||
const [{ EditorView, Decoration, ViewPlugin, gutter, GutterMarker }, { EditorState, Compartment, StateEffect, StateField }, { sql, MySQL }, { basicSetup }] = await Promise.all([import("@codemirror/view"), import("@codemirror/state"), import("@codemirror/lang-sql"), import("codemirror")]);
|
||||
const [{ EditorView, Decoration, ViewPlugin, gutter, GutterMarker, layer, RectangleMarker }, { EditorState, Compartment, StateEffect, StateField }, { sql, MySQL }, { basicSetup }] = await Promise.all([
|
||||
import("@codemirror/view"),
|
||||
import("@codemirror/state"),
|
||||
import("@codemirror/lang-sql"),
|
||||
import("codemirror"),
|
||||
]);
|
||||
|
||||
editorViewModule = {
|
||||
Decoration,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
layer,
|
||||
RectangleMarker,
|
||||
} as typeof import("@codemirror/view");
|
||||
fontThemeComp = new Compartment();
|
||||
themeComp = new Compartment();
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ import { completionMatchRanges } from "@/lib/common/completionMatch";
|
|||
import { executionCandidateForMode, resolveExecutableSql, type SqlExecutionSnapshot, type SqlExecutionOverride, type SqlExecutionCandidate } from "@/lib/sql/sqlExecutionTarget";
|
||||
import { buildExecutionCandidates, hasMultipleExecutionTargets, supportsExecutionTargetPicker, type SqlTextRange } from "@/lib/sql/sqlStatementRanges";
|
||||
import { executableStatementRangeAtCursor, executableStatementRangeCacheForDoc, executableStatementRangeStartingAt as executableStatementRangeStartingAtLine, type ExecutableStatementRangeCache } from "@/lib/sql/executableStatementRangeCache";
|
||||
import { currentStatementFrameRangeTo, shouldRebuildCurrentStatementFrame, visualSqlColumnsWithInlineHints } from "@/lib/sql/currentStatementFrame";
|
||||
import { expandToSqlStatementWindow, parseInsertValueHints } from "@/lib/sql/insertValueHints";
|
||||
import { currentStatementFrameRangeTo } from "@/lib/sql/currentStatementFrame";
|
||||
import { expandToSqlStatementWindow } from "@/lib/sql/insertValueHints";
|
||||
import { insertValueHintColumnNames } from "@/lib/sql/insertValueHintColumns";
|
||||
import { formatSqlForEditing, compressSqlText, type SqlFormatDialect } from "@/lib/sql/sqlFormatter";
|
||||
import { detectAndFormatStructured } from "@/lib/sql/autoFormat";
|
||||
|
|
@ -101,6 +101,7 @@ import { completionLabelPresentation } from "@/lib/editor/sqlCompletionPresentat
|
|||
import { clampEditorFontSize, createEditorZoomCommitScheduler, fontSizeFromGestureScale, fontSizeFromWheelDelta } from "@/lib/editor/editorZoom";
|
||||
import { normalizeShortcutSettings, shortcutToCodeMirrorKey } from "@/lib/editor/shortcutRegistry";
|
||||
import { trimmedSelectionLayer } from "@/lib/editor/codemirrorTrimmedSelectionLayer";
|
||||
import { currentStatementFrameLayer } from "@/lib/editor/codemirrorCurrentStatementFrameLayer";
|
||||
import { selectionMatchOccurrences } from "@/lib/editor/codemirrorSelectionMatches";
|
||||
import { createInsertValueHintsExtension, requestInsertValueHintsRefresh } from "@/lib/editor/codemirrorInsertValueHints";
|
||||
import { focusEditorView } from "@/lib/editor/queryEditorFocus";
|
||||
|
|
@ -3927,7 +3928,29 @@ onMounted(async () => {
|
|||
})();
|
||||
|
||||
const [
|
||||
{ EditorView, keymap, rectangularSelection, hoverTooltip, showTooltip, closeHoverTooltips, Decoration, tooltips, gutter, GutterMarker, lineNumberMarkers, lineNumbers, highlightActiveLineGutter, highlightSpecialChars, drawSelection, dropCursor, crosshairCursor, scrollPastEnd, ViewPlugin },
|
||||
{
|
||||
EditorView,
|
||||
keymap,
|
||||
rectangularSelection,
|
||||
hoverTooltip,
|
||||
showTooltip,
|
||||
closeHoverTooltips,
|
||||
Decoration,
|
||||
tooltips,
|
||||
gutter,
|
||||
GutterMarker,
|
||||
lineNumberMarkers,
|
||||
lineNumbers,
|
||||
highlightActiveLineGutter,
|
||||
highlightSpecialChars,
|
||||
drawSelection,
|
||||
dropCursor,
|
||||
crosshairCursor,
|
||||
scrollPastEnd,
|
||||
ViewPlugin,
|
||||
layer,
|
||||
RectangleMarker,
|
||||
},
|
||||
{ EditorState, EditorSelection, Compartment, Prec, RangeSet, StateEffect, StateField },
|
||||
langSql,
|
||||
{ autocompletion, startCompletion, acceptCompletion, closeBrackets, closeBracketsKeymap, snippetCompletion, completionStatus, completionKeymap, insertCompletionText, nextSnippetField, closeCompletion },
|
||||
|
|
@ -4253,74 +4276,43 @@ onMounted(async () => {
|
|||
await ensureCodeMirrorVim();
|
||||
}
|
||||
|
||||
const currentStatementFrameHighlighter = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: import("@codemirror/view").DecorationSet;
|
||||
private configuration: string;
|
||||
constructor(view: import("@codemirror/view").EditorView) {
|
||||
this.configuration = this.currentConfiguration();
|
||||
this.decorations = this.getDeco(view);
|
||||
}
|
||||
update(update: import("@codemirror/view").ViewUpdate) {
|
||||
const configuration = this.currentConfiguration();
|
||||
if (!shouldRebuildCurrentStatementFrame({ docChanged: update.docChanged, selectionSet: update.selectionSet, configurationChanged: configuration !== this.configuration })) return;
|
||||
this.configuration = configuration;
|
||||
this.decorations = this.getDeco(update.view);
|
||||
}
|
||||
currentConfiguration() {
|
||||
return `${settingsStore.editorSettings.showCurrentStatementFrame}:${settingsStore.editorSettings.showInsertValueHints}:${props.databaseType ?? ""}`;
|
||||
}
|
||||
getDeco(view: import("@codemirror/view").EditorView) {
|
||||
if (!settingsStore.editorSettings.showCurrentStatementFrame) return Decoration.none;
|
||||
if (view.state.selection.ranges.some((range) => !range.empty)) return Decoration.none;
|
||||
const range = currentExecutableStatementRange(view);
|
||||
if (!range) return Decoration.none;
|
||||
|
||||
const startLine = view.state.doc.lineAt(range.from);
|
||||
const frameTo = currentStatementFrameTo(view, range);
|
||||
const endLine = view.state.doc.lineAt(Math.max(range.from, frameTo - 1));
|
||||
let insertValueHints: Array<{ from: number; column: string }> = [];
|
||||
try {
|
||||
if (settingsStore.editorSettings.showInsertValueHints && props.databaseType !== "redis" && props.databaseType !== "mongodb" && props.databaseType !== "elasticsearch" && props.databaseType !== "easysearch" && props.databaseType !== "victoriametrics") {
|
||||
const statementSql = view.state.doc.sliceString(range.from, range.to);
|
||||
if (/\binsert\b/i.test(statementSql)) {
|
||||
insertValueHints = parseInsertValueHints(statementSql, { resolveTableColumns: getInsertValueHintTableColumns }).map((hint) => ({
|
||||
...hint,
|
||||
from: hint.from + range.from,
|
||||
}));
|
||||
const currentStatementFrameExtension = currentStatementFrameLayer({ layer, RectangleMarker }, (view) => {
|
||||
if (!settingsStore.editorSettings.showCurrentStatementFrame) return null;
|
||||
if (view.state.selection.ranges.some((range) => !range.empty)) return null;
|
||||
let range = currentExecutableStatementRange(view);
|
||||
if (!range) {
|
||||
const cursorPos = view.state.selection.main.head;
|
||||
const cursorLine = view.state.doc.lineAt(cursorPos);
|
||||
executableStatementRangeCache = executableStatementRangeCacheForDoc(executableStatementRangeCache, view.state.doc, props.databaseType, sqlStatementParameterOptions());
|
||||
// Find ranges that overlap the cursor line, then expand to include
|
||||
// adjacent ranges (handles parser fragments from edge cases like
|
||||
// ultra-long comments splitting a statement).
|
||||
let mergedFrom = cursorLine.from;
|
||||
let mergedTo = cursorLine.from;
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const cachedRange of executableStatementRangeCache.ranges) {
|
||||
// Only merge ranges that overlap the cursor line or are adjacent
|
||||
// to the current merged region
|
||||
if (cachedRange.from <= mergedTo && cachedRange.to >= mergedFrom) {
|
||||
const newFrom = Math.min(mergedFrom, cachedRange.from);
|
||||
const newTo = Math.max(mergedTo, cachedRange.to);
|
||||
if (newFrom !== mergedFrom || newTo !== mergedTo) {
|
||||
mergedFrom = newFrom;
|
||||
mergedTo = newTo;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
insertValueHints = [];
|
||||
}
|
||||
let maxWidth = 1;
|
||||
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) {
|
||||
const line = view.state.doc.line(lineNumber);
|
||||
const lineRangeTo = Math.min(line.to, frameTo);
|
||||
maxWidth = Math.max(maxWidth, visualSqlColumnsWithInlineHints(view.state.doc.sliceString(line.from, lineRangeTo), line.from, lineRangeTo, insertValueHints));
|
||||
}
|
||||
|
||||
const deco: any[] = [];
|
||||
const frameWidth = `calc(${maxWidth}ch + 2ch)`;
|
||||
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) {
|
||||
const line = view.state.doc.line(lineNumber);
|
||||
const classes = ["cm-db-current-statement-line"];
|
||||
if (lineNumber === startLine.number) classes.push("cm-db-current-statement-line--first");
|
||||
if (lineNumber === endLine.number) classes.push("cm-db-current-statement-line--last");
|
||||
deco.push(
|
||||
Decoration.line({
|
||||
class: classes.join(" "),
|
||||
attributes: {
|
||||
style: `--dbx-current-statement-frame-width: ${frameWidth};`,
|
||||
},
|
||||
}).range(line.from),
|
||||
);
|
||||
}
|
||||
return Decoration.set(deco);
|
||||
}
|
||||
},
|
||||
{ decorations: (v) => v.decorations },
|
||||
);
|
||||
if (mergedTo > mergedFrom) {
|
||||
range = { from: mergedFrom, to: mergedTo, sql: view.state.doc.sliceString(mergedFrom, mergedTo) };
|
||||
}
|
||||
}
|
||||
if (!range) return null;
|
||||
return { from: range.from, to: currentStatementFrameTo(view, range) };
|
||||
});
|
||||
|
||||
function currentStatementFrameTo(view: import("@codemirror/view").EditorView, range: SqlTextRange): number {
|
||||
return currentStatementFrameRangeTo(view.state.doc, range);
|
||||
|
|
@ -4374,7 +4366,7 @@ onMounted(async () => {
|
|||
mousedown: selectSqlLineFromGutter,
|
||||
},
|
||||
}),
|
||||
currentStatementFrameHighlighter,
|
||||
currentStatementFrameExtension,
|
||||
highlightActiveLineGutter(),
|
||||
highlightSpecialChars(),
|
||||
history(),
|
||||
|
|
@ -5299,29 +5291,15 @@ defineExpose({
|
|||
color: rgb(216 180 254) !important;
|
||||
}
|
||||
|
||||
:deep(.cm-db-current-statement-line) {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
:deep(.cm-db-current-statement-line::after) {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
box-sizing: border-box;
|
||||
width: var(--dbx-current-statement-frame-width, 100%);
|
||||
border-right: 1px solid rgb(34 197 94 / 0.75);
|
||||
border-left: 1px solid rgb(34 197 94 / 0.75);
|
||||
:deep(.cm-db-currentStatementFrameLayer) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:deep(.cm-db-current-statement-line--first::after) {
|
||||
border-top: 1px solid rgb(34 197 94 / 0.75);
|
||||
}
|
||||
|
||||
:deep(.cm-db-current-statement-line--last::after) {
|
||||
border-bottom: 1px solid rgb(34 197 94 / 0.75);
|
||||
:deep(.cm-db-currentStatementFrame) {
|
||||
box-sizing: border-box;
|
||||
border: 1px solid rgb(34 197 94 / 0.75);
|
||||
border-radius: 2px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
:deep(.cm-run-statement-gutter) {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,172 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { Text } from "@codemirror/state";
|
||||
import { currentStatementFrameRect, FRAME_INSET_PX } from "@/lib/editor/codemirrorCurrentStatementFrameLayer";
|
||||
|
||||
interface CoordRect {
|
||||
left: number;
|
||||
right: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface LineBlock {
|
||||
from: number;
|
||||
length: number;
|
||||
top: number;
|
||||
bottom: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
interface ViewLike {
|
||||
coordsAtPos: (pos: number, side?: -1 | 1) => CoordRect | null;
|
||||
lineBlockAt: (pos: number) => LineBlock;
|
||||
defaultCharacterWidth: number;
|
||||
state: { doc: ReturnType<typeof Text.of> };
|
||||
scrollDOM: { scrollLeft: number; scrollTop: number; getBoundingClientRect(): DOMRect };
|
||||
scaleX: number;
|
||||
scaleY: number;
|
||||
}
|
||||
|
||||
const CHAR_WIDTH = 9;
|
||||
const LINE_HEIGHT = 20;
|
||||
const BASE_TOP = 112; // typical editor top on screen
|
||||
|
||||
function buildView(lines: string[], scrollTop = 0, coordsAtPosNull: ((pos: number, side: -1 | 1) => boolean) | null = null): ViewLike {
|
||||
const doc = Text.of(lines);
|
||||
const lineTop = (pos: number) => {
|
||||
const line = doc.lineAt(pos);
|
||||
return (line.number - 1) * LINE_HEIGHT;
|
||||
};
|
||||
return {
|
||||
defaultCharacterWidth: CHAR_WIDTH,
|
||||
lineBlockAt(pos: number): LineBlock {
|
||||
const top = lineTop(pos);
|
||||
const line = doc.lineAt(pos);
|
||||
return { from: line.from, length: line.length, top, bottom: top + LINE_HEIGHT, height: LINE_HEIGHT };
|
||||
},
|
||||
state: { doc },
|
||||
scaleX: 1,
|
||||
scaleY: 1,
|
||||
scrollDOM: {
|
||||
scrollLeft: 0,
|
||||
scrollTop,
|
||||
getBoundingClientRect: () => ({ left: 0, top: BASE_TOP, right: 1000, bottom: 1000, width: 1000, height: 1000 }) as DOMRect,
|
||||
},
|
||||
coordsAtPos(pos: number, side: -1 | 1 = 1): CoordRect | null {
|
||||
if (coordsAtPosNull?.(pos, side)) return null;
|
||||
const col = pos - doc.lineAt(pos).from;
|
||||
const left = col * CHAR_WIDTH;
|
||||
const top = lineTop(pos);
|
||||
// Screen coords = content pos - scrollTop + baseTop
|
||||
return side === -1
|
||||
? { left: left - CHAR_WIDTH, right: left, top: top - scrollTop + BASE_TOP, bottom: top + LINE_HEIGHT - scrollTop + BASE_TOP, height: LINE_HEIGHT }
|
||||
: { left, right: left + CHAR_WIDTH, top: top - scrollTop + BASE_TOP, bottom: top + LINE_HEIGHT - scrollTop + BASE_TOP, height: LINE_HEIGHT };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("currentStatementFrameRect", () => {
|
||||
it("returns null for an empty or invalid range", () => {
|
||||
const view = buildView(["SELECT 1"]);
|
||||
expect(currentStatementFrameRect(view, 3, 3)).toBeNull();
|
||||
expect(currentStatementFrameRect(view, 5, 2)).toBeNull();
|
||||
expect(currentStatementFrameRect(view, 0, 99)).toBeNull();
|
||||
expect(currentStatementFrameRect(view, 4, 8)).not.toBeNull();
|
||||
});
|
||||
|
||||
it("uses coordsAtPos for vertical bounds", () => {
|
||||
const view = buildView(["SELECT 1"]);
|
||||
const rect = currentStatementFrameRect(view, 0, 8);
|
||||
expect(rect).not.toBeNull();
|
||||
expect(rect!.left).toBe(0 - FRAME_INSET_PX);
|
||||
expect(rect!.width).toBe(8 * CHAR_WIDTH + FRAME_INSET_PX * 2);
|
||||
expect(rect!.top).toBe(0 - FRAME_INSET_PX);
|
||||
expect(rect!.height).toBe(LINE_HEIGHT + FRAME_INSET_PX * 2);
|
||||
});
|
||||
|
||||
it("spans a multi-line statement as one continuous rectangle", () => {
|
||||
const view = buildView(["SELECT a,", "b", "FROM t"]);
|
||||
const to = "SELECT a,\nb\nFROM t".length;
|
||||
const rect = currentStatementFrameRect(view, 0, to);
|
||||
expect(rect).not.toBeNull();
|
||||
expect(rect!.top).toBe(0 - FRAME_INSET_PX);
|
||||
expect(rect!.height).toBe(3 * LINE_HEIGHT + FRAME_INSET_PX * 2);
|
||||
expect(rect!.width).toBe(9 * CHAR_WIDTH + FRAME_INSET_PX * 2);
|
||||
});
|
||||
|
||||
it("frames a statement interrupted by a blank line", () => {
|
||||
const view = buildView(["SELECT a", "", "FROM t"]);
|
||||
const rect = currentStatementFrameRect(view, 0, view.state.doc.length);
|
||||
expect(rect).not.toBeNull();
|
||||
expect(rect!.top).toBe(0 - FRAME_INSET_PX);
|
||||
expect(rect!.height).toBe(3 * LINE_HEIGHT + FRAME_INSET_PX * 2);
|
||||
});
|
||||
|
||||
it("bottoms at the last statement line, not the next line", () => {
|
||||
const view = buildView(["SELECT 1;", "SELECT 2"]);
|
||||
const rect = currentStatementFrameRect(view, 0, 9);
|
||||
expect(rect).not.toBeNull();
|
||||
expect(rect!.height).toBe(LINE_HEIGHT + FRAME_INSET_PX * 2);
|
||||
});
|
||||
|
||||
it("produces the same frame regardless of scroll position", () => {
|
||||
const lines = ["-- header", "SELECT a,", "b,", "c,", "FROM t;"];
|
||||
const from = Text.of(lines).line(2).from;
|
||||
const to = Text.of(lines).line(5).to;
|
||||
|
||||
// No scroll
|
||||
const view1 = buildView(lines, 0);
|
||||
const rect1 = currentStatementFrameRect(view1, from, to);
|
||||
expect(rect1).not.toBeNull();
|
||||
expect(rect1!.top + FRAME_INSET_PX).toBe(LINE_HEIGHT);
|
||||
expect(rect1!.height).toBe(4 * LINE_HEIGHT + FRAME_INSET_PX * 2);
|
||||
|
||||
// Scrolled down 200px
|
||||
const view2 = buildView(lines, 200);
|
||||
const rect2 = currentStatementFrameRect(view2, from, to);
|
||||
expect(rect2).not.toBeNull();
|
||||
expect(rect2!.top).toBe(rect1!.top);
|
||||
expect(rect2!.height).toBe(rect1!.height);
|
||||
});
|
||||
|
||||
it("falls back to lineBlockAt when coordsAtPos returns null for off-viewport start", () => {
|
||||
// Statement spans lines 2-5. coordsAtPos returns null for the start (far off-screen above).
|
||||
const lines = ["-- header", "SELECT a,", "b,", "c,", "FROM t;"];
|
||||
const from = Text.of(lines).line(2).from;
|
||||
const to = Text.of(lines).line(5).to;
|
||||
|
||||
const view = buildView(lines, 200, (pos, side) => pos === from && side === 1);
|
||||
|
||||
const rect = currentStatementFrameRect(view, from, to);
|
||||
expect(rect).not.toBeNull();
|
||||
// Fallback: lineBlockAt(from).top = 1 * LINE_HEIGHT = 20
|
||||
expect(rect!.top + FRAME_INSET_PX).toBe(LINE_HEIGHT);
|
||||
// Bottom uses coordsAtPos: (4 * LINE_HEIGHT) - 200 + BASE_TOP - BASE_TOP + 200 = 4 * LINE_HEIGHT
|
||||
expect(rect!.height).toBe(4 * LINE_HEIGHT + FRAME_INSET_PX * 2);
|
||||
});
|
||||
|
||||
it("handles both start and end off-viewport (deep scroll)", () => {
|
||||
// 100 lines; statement spans lines 48-73. Scroll so both are off-screen.
|
||||
const lines: string[] = [];
|
||||
for (let i = 1; i <= 100; i++) {
|
||||
if (i === 48) lines.push("CREATE TABLE t (");
|
||||
else if (i >= 49 && i <= 72) lines.push(" col" + i + " INT,");
|
||||
else if (i === 73) lines.push(") ENGINE=InnoDB;");
|
||||
else lines.push("-- line " + i);
|
||||
}
|
||||
const from = Text.of(lines).line(48).from;
|
||||
const to = Text.of(lines).line(73).to;
|
||||
|
||||
// coordsAtPos returns null for both start and end (far off-screen)
|
||||
const view = buildView(lines, 2000, (pos, side) => (pos === from && side === 1) || (pos === to - 1 && side === -1));
|
||||
|
||||
const rect = currentStatementFrameRect(view, from, to);
|
||||
expect(rect).not.toBeNull();
|
||||
// top = lineBlockAt(48).top = 47 * LINE_HEIGHT
|
||||
expect(rect!.top + FRAME_INSET_PX).toBe(47 * LINE_HEIGHT);
|
||||
// bottom = lineBlockAt(73).bottom = 73 * LINE_HEIGHT
|
||||
const expectedHeight = (73 - 47) * LINE_HEIGHT + FRAME_INSET_PX * 2;
|
||||
expect(rect!.height).toBe(expectedHeight);
|
||||
});
|
||||
});
|
||||
|
|
@ -5,7 +5,8 @@ const queryEditorSource = readFileSync(new URL("../../../components/editor/Query
|
|||
|
||||
describe("QueryEditor bottom scroll space", () => {
|
||||
it("allows editable documents to scroll past the last line without changing read-only previews", () => {
|
||||
expect(queryEditorSource).toContain("scrollPastEnd, ViewPlugin");
|
||||
expect(queryEditorSource).toMatch(/scrollPastEnd,\s*ViewPlugin/);
|
||||
expect(queryEditorSource).toMatch(/layer,\s*RectangleMarker/);
|
||||
expect(queryEditorSource).toContain("props.readOnly ? [] : scrollPastEnd()");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { currentStatementFrameRangeTo, estimateInlineHintVisualColumns, isWideSqlChar, shouldRebuildCurrentStatementFrame, visualSqlColumns, visualSqlColumnsWithInlineHints } from "@/lib/sql/currentStatementFrame";
|
||||
import { currentStatementFrameRangeTo } from "@/lib/sql/currentStatementFrame";
|
||||
import type { SqlTextRange } from "@/lib/sql/sqlStatementRanges";
|
||||
|
||||
function frameDocument(sql: string) {
|
||||
|
|
@ -40,56 +40,3 @@ describe("currentStatementFrameRangeTo", () => {
|
|||
expect(currentStatementFrameRangeTo(frameDocument(sql), range)).toBe(range.to);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldRebuildCurrentStatementFrame", () => {
|
||||
it("reuses frame decorations for pure viewport updates", () => {
|
||||
expect(shouldRebuildCurrentStatementFrame({ docChanged: false, selectionSet: false, configurationChanged: false })).toBe(false);
|
||||
});
|
||||
|
||||
it("rebuilds after document, selection, or configuration updates", () => {
|
||||
expect(shouldRebuildCurrentStatementFrame({ docChanged: true, selectionSet: false, configurationChanged: false })).toBe(true);
|
||||
expect(shouldRebuildCurrentStatementFrame({ docChanged: false, selectionSet: true, configurationChanged: false })).toBe(true);
|
||||
expect(shouldRebuildCurrentStatementFrame({ docChanged: false, selectionSet: false, configurationChanged: true })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("visualSqlColumns", () => {
|
||||
it("counts ASCII as one column, tabs as four, and CJK/fullwidth characters as two", () => {
|
||||
expect(visualSqlColumns("A\t中B")).toBe(1 + 4 + 2 + 2);
|
||||
});
|
||||
|
||||
it("recognizes common wide SQL text characters", () => {
|
||||
expect(isWideSqlChar("中")).toBe(true);
|
||||
expect(isWideSqlChar("A")).toBe(true);
|
||||
expect(isWideSqlChar("A")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("visualSqlColumnsWithInlineHints", () => {
|
||||
it("adds estimated columns for insert-value hints on the line", () => {
|
||||
const text = "VALUES (12, 'a')";
|
||||
const lineFrom = 0;
|
||||
const lineTo = text.length;
|
||||
const withoutHints = visualSqlColumns(text);
|
||||
const withHints = visualSqlColumnsWithInlineHints(text, lineFrom, lineTo, [
|
||||
{ from: 8, column: "id" },
|
||||
{ from: 12, column: "name" },
|
||||
]);
|
||||
expect(withHints).toBe(withoutHints + estimateInlineHintVisualColumns("id") + estimateInlineHintVisualColumns("name"));
|
||||
});
|
||||
|
||||
it("ignores hints that belong to other lines", () => {
|
||||
const text = "VALUES (1)";
|
||||
expect(visualSqlColumnsWithInlineHints(text, 0, text.length, [{ from: 100, column: "id" }])).toBe(visualSqlColumns(text));
|
||||
});
|
||||
|
||||
it("dedupes hints that share the same document offset", () => {
|
||||
const text = "VALUES (1)";
|
||||
const once = visualSqlColumnsWithInlineHints(text, 0, text.length, [{ from: 8, column: "id" }]);
|
||||
const twice = visualSqlColumnsWithInlineHints(text, 0, text.length, [
|
||||
{ from: 8, column: "id" },
|
||||
{ from: 8, column: "id" },
|
||||
]);
|
||||
expect(twice).toBe(once);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
import type { EditorView, LayerMarker, ViewUpdate } from "@codemirror/view";
|
||||
import type { Extension } from "@codemirror/state";
|
||||
|
||||
export interface StatementFrameRequest {
|
||||
/** Document range covered by the frame; null hides the frame. */
|
||||
from: number;
|
||||
to: number;
|
||||
}
|
||||
|
||||
export type StatementFrameResolver = (view: EditorView) => StatementFrameRequest | null;
|
||||
|
||||
/** Breathing room between the measured text bounds and the frame border (pixels). */
|
||||
export const FRAME_INSET_PX = 2;
|
||||
|
||||
export interface FrameRect {
|
||||
left: number;
|
||||
top: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
type Viewish = Pick<EditorView, "coordsAtPos" | "state" | "scrollDOM" | "scaleX" | "lineBlockAt" | "defaultCharacterWidth">;
|
||||
|
||||
/**
|
||||
* Measure the pixel rectangle of one statement rendered in `view`.
|
||||
*
|
||||
* The frame is a single continuous rectangle: it starts at the first
|
||||
* statement character, spans every statement line (blank and comment lines
|
||||
* included), and ends at the widest line.
|
||||
*
|
||||
* **Vertical bounds**: use `lineBlockAt` (document-level coordinates,
|
||||
* always reliable regardless of viewport position).
|
||||
*
|
||||
* **Horizontal bounds**: use `coordsAtPos` for visible lines (exact on tabs,
|
||||
* CJK/fullwidth glyphs and non-monospaced fonts). For off-viewport lines,
|
||||
* estimate using an offset calibrated from the first visible line's
|
||||
* `coordsAtPos` result, so estimates account for editor padding, gutter
|
||||
* width, and zoom.
|
||||
*
|
||||
* All four sides get a symmetric `FRAME_INSET_PX` breathing room.
|
||||
*/
|
||||
export function currentStatementFrameRect(view: Viewish, from: number, to: number): FrameRect | null {
|
||||
const doc = view.state.doc;
|
||||
if (!doc || to < from || from > doc.length || to > doc.length || to === from) return null;
|
||||
|
||||
const startLine = doc.lineAt(from);
|
||||
const endLine = doc.lineAt(to);
|
||||
const base = view.scrollDOM.getBoundingClientRect();
|
||||
|
||||
// Vertical bounds: convert screen coords to content-layer coords.
|
||||
// Primary: coordsAtPos (CodeMirror extrapolates for off-viewport lines).
|
||||
// Fallback: lineBlockAt — returns content-layer coords directly (no
|
||||
// scrollTop adjustment needed; the layer scrolls with the content).
|
||||
|
||||
const startCoords = view.coordsAtPos(from, 1);
|
||||
const top = startCoords ? startCoords.top - base.top + view.scrollDOM.scrollTop : view.lineBlockAt(from).top;
|
||||
|
||||
const endCoords = view.coordsAtPos(Math.max(from, to - 1), -1);
|
||||
const bottom = endCoords ? endCoords.bottom - base.top + view.scrollDOM.scrollTop : view.lineBlockAt(to).bottom;
|
||||
|
||||
if (bottom - top <= 0) return null;
|
||||
|
||||
// Horizontal bounds: use coordsAtPos for visible lines. For off-viewport
|
||||
// lines, estimate using an offset calibrated from the first visible line.
|
||||
let left = Infinity;
|
||||
let right = -Infinity;
|
||||
const charWidth = view.defaultCharacterWidth;
|
||||
let calibratedOffset: number | null = null;
|
||||
|
||||
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) {
|
||||
const line = doc.line(lineNumber);
|
||||
const anchorFrom = lineNumber === startLine.number ? from : line.from;
|
||||
const anchorTo = Math.min(line.to, to);
|
||||
const lineLeft = view.coordsAtPos(anchorFrom, 1);
|
||||
const lineRight = view.coordsAtPos(anchorTo, -1);
|
||||
|
||||
if (lineLeft) {
|
||||
left = Math.min(left, lineLeft.left);
|
||||
right = Math.max(right, lineRight?.right ?? lineLeft.left);
|
||||
// Calibrate offset from the first visible line
|
||||
if (calibratedOffset === null && charWidth > 0) {
|
||||
const indent = line.text.match(/^\s*/)?.[0].length || 0;
|
||||
calibratedOffset = lineLeft.left - indent * charWidth;
|
||||
}
|
||||
} else if (calibratedOffset !== null && charWidth > 0) {
|
||||
// Estimate: use calibrated offset + (indent + text.length) * charWidth
|
||||
const indent = line.text.match(/^\s*/)?.[0].length || 0;
|
||||
const estimatedRight = calibratedOffset + (indent + line.text.length) * charWidth;
|
||||
right = Math.max(right, estimatedRight);
|
||||
}
|
||||
}
|
||||
|
||||
// If no lines were measurable (entire statement off-viewport), skip frame
|
||||
if (!isFinite(left) || !isFinite(right)) return null;
|
||||
|
||||
const leftOffset = base.left - view.scrollDOM.scrollLeft * view.scaleX;
|
||||
|
||||
return {
|
||||
left: left - leftOffset - FRAME_INSET_PX,
|
||||
top: top - FRAME_INSET_PX,
|
||||
width: right - left + FRAME_INSET_PX * 2,
|
||||
height: bottom - top + FRAME_INSET_PX * 2,
|
||||
};
|
||||
}
|
||||
|
||||
interface CurrentStatementFrameModule {
|
||||
layer: (config: { above: boolean; class?: string; markers(view: EditorView): readonly LayerMarker[]; update(update: ViewUpdate, layer: HTMLElement): boolean }) => Extension;
|
||||
RectangleMarker: new (className: string, left: number, top: number, width: number | null, height: number) => LayerMarker;
|
||||
}
|
||||
|
||||
/**
|
||||
* Draw one continuous green rectangle around the current executable
|
||||
* statement. `resolve` decides visibility and returns the statement range;
|
||||
* returning null hides the frame.
|
||||
*/
|
||||
export function currentStatementFrameLayer(viewModule: CurrentStatementFrameModule, resolve: StatementFrameResolver): Extension {
|
||||
return viewModule.layer({
|
||||
above: false,
|
||||
class: "cm-db-currentStatementFrameLayer",
|
||||
markers(view) {
|
||||
const request = resolve(view);
|
||||
if (!request || request.to < request.from) return [];
|
||||
const rect = currentStatementFrameRect(view, request.from, request.to);
|
||||
if (!rect) return [];
|
||||
return [new viewModule.RectangleMarker("cm-db-currentStatementFrame", rect.left, rect.top, rect.width, rect.height)];
|
||||
},
|
||||
update(update) {
|
||||
return update.docChanged || update.selectionSet || update.viewportChanged || update.geometryChanged || update.transactions.some((transaction) => transaction.reconfigured);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -5,61 +5,3 @@ export function currentStatementFrameRangeTo(doc: StatementDelimiterDocument, ra
|
|||
const delimiterPos = trailingStatementDelimiterPosition(doc, range.to);
|
||||
return delimiterPos === null ? range.to : delimiterPos + 1;
|
||||
}
|
||||
|
||||
export function shouldRebuildCurrentStatementFrame(update: { docChanged: boolean; selectionSet: boolean; configurationChanged: boolean }): boolean {
|
||||
return update.docChanged || update.selectionSet || update.configurationChanged;
|
||||
}
|
||||
|
||||
export function visualSqlColumns(text: string): number {
|
||||
let columns = 0;
|
||||
for (const ch of text) {
|
||||
if (ch === "\t") {
|
||||
columns += 4;
|
||||
} else if (isWideSqlChar(ch)) {
|
||||
columns += 2;
|
||||
} else {
|
||||
columns += 1;
|
||||
}
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
export function isWideSqlChar(ch: string): boolean {
|
||||
return /[\u1100-\u115f\u2329\u232a\u2e80-\u303e\u3040-\ua4cf\uac00-\ud7a3\uf900-\ufaff\ufe10-\ufe19\ufe30-\ufe6f\uff00-\uff60\uffe0-\uffe6]/u.test(ch);
|
||||
}
|
||||
|
||||
/** Approximate visual columns for a `.cm-insert-value-hint` widget (0.85em text + padding/margin). */
|
||||
export function estimateInlineHintVisualColumns(label: string): number {
|
||||
// Slightly generous vs CSS (0.85em font + 0.3em*2 padding + 0.35em margin) so the
|
||||
// statement frame does not clip past inlay hints.
|
||||
return Math.max(2, Math.ceil(label.length * 0.9 + 2.2));
|
||||
}
|
||||
|
||||
export interface InlineHintForFrameWidth {
|
||||
from: number;
|
||||
column: string;
|
||||
}
|
||||
|
||||
/** Document text columns plus inline widget hints that sit on `[lineFrom, lineTo)`. */
|
||||
export function visualSqlColumnsWithInlineHints(text: string, lineFrom: number, lineTo: number, hints: readonly InlineHintForFrameWidth[] = []): number {
|
||||
let columns = visualSqlColumns(text);
|
||||
const seen = new Set<number>();
|
||||
for (const hint of hints) {
|
||||
if (hint.from < lineFrom || hint.from >= lineTo) continue;
|
||||
if (seen.has(hint.from)) continue;
|
||||
seen.add(hint.from);
|
||||
columns += estimateInlineHintVisualColumns(hint.column);
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
|
||||
/** Measure rendered line width in CSS pixels, including inline widgets already in the DOM. */
|
||||
export function measureSqlLineWidthPx(view: { coordsAtPos: (pos: number, side?: -1 | 1) => { left: number; right: number; top: number; bottom: number } | null }, from: number, to: number): number | null {
|
||||
if (to < from) return null;
|
||||
const start = view.coordsAtPos(from, 1);
|
||||
const end = view.coordsAtPos(to, -1);
|
||||
if (!start || !end) return null;
|
||||
// Ignore cross-line measurements (wrapping / mismatched sides).
|
||||
if (Math.abs(start.top - end.top) > 2) return null;
|
||||
return Math.max(0, end.right - start.left);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue