diff --git a/apps/desktop/src/components/editor/QueryEditor.vue b/apps/desktop/src/components/editor/QueryEditor.vue index 0075e95ea8..281e75b4e4 100644 --- a/apps/desktop/src/components/editor/QueryEditor.vue +++ b/apps/desktop/src/components/editor/QueryEditor.vue @@ -114,7 +114,7 @@ import { EDITOR_FONT_FAMILY_CSS_VAR, EDITOR_FONT_SIZE_CSS_VAR, editorDiagnosticC import { createStatementGutterMarkerDom, shouldShowStatementGutter } from "@/lib/editor/codemirrorStatementGutter"; import { createQueryEditorSqlShortcutDomHandler, isCharacterProducingShortcut } from "@/lib/editor/queryEditorSqlShortcut"; import { createQueryEditorReplaceShortcutBindings, createQueryEditorReplaceShortcutHandler, createQueryEditorSearchKeymap } from "@/lib/editor/queryEditorSearchKeymap"; -import { buildQueryEditorLineNumbersExtension } from "@/lib/editor/queryEditorLineNumbers"; +import { buildQueryEditorLineNumbersExtension, createQueryEditorLineNumberAlignmentExtension } from "@/lib/editor/queryEditorLineNumbers"; import { searchKeymapWithoutModD } from "@/lib/editor/codemirrorSearchKeymap"; import { defaultKeymapForGlobalShortcuts } from "@/lib/editor/codemirrorDefaultKeymap"; import { appendSqlCompletionSpace } from "@/lib/editor/sqlCompletionInsertion"; @@ -5301,6 +5301,7 @@ onMounted(async () => { }), runGutterComp.of(runStatementGutterExtension()), lineNumbersComp.of(lineNumbersExtension(initialSettings.showLineNumbers)), + createQueryEditorLineNumberAlignmentExtension(ViewPlugin), currentStatementFrameExtension, highlightActiveLineGutter(), highlightSpecialChars(), diff --git a/apps/desktop/src/lib/__tests__/editorThemes.spec.ts b/apps/desktop/src/lib/__tests__/editorThemes.spec.ts index 64986dc5e3..c7fbe4344c 100644 --- a/apps/desktop/src/lib/__tests__/editorThemes.spec.ts +++ b/apps/desktop/src/lib/__tests__/editorThemes.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { buildSqlCompletionThemeRules, editorDiagnosticColors, editorThemeAppearanceFor, resolveCustomThemeBackgrounds, resolveEditorTheme } from "@/lib/editor/editorThemes"; +import { buildEditorFontThemeRules, buildSqlCompletionThemeRules, editorDiagnosticColors, editorThemeAppearanceFor, resolveCustomThemeBackgrounds, resolveEditorTheme } from "@/lib/editor/editorThemes"; import { DEFAULT_APP_CUSTOM_UI_COLORS, wcagContrastRatio, type AppThemePalette } from "@/lib/app/appTheme"; import type { EditorTheme } from "@/stores/settingsStore"; @@ -163,3 +163,16 @@ describe("SQL completion theme", () => { expect(rules[".cm-completionDetail"]).toMatchObject({ flex: "1 1 0", minWidth: "0", textOverflow: "ellipsis" }); }); }); + +describe("editor gutters", () => { + it("anchors line numbers to the first visual row of wrapped lines", () => { + const rules = buildEditorFontThemeRules(); + + expect(rules[".cm-lineNumbers .cm-gutterElement"]).toMatchObject({ + alignItems: "center", + display: "flex", + justifyContent: "flex-end", + }); + expect(rules[".cm-lineNumbers .cm-gutterElement.cm-db-wrapped-line-number"]).toMatchObject({ alignItems: "flex-start" }); + }); +}); diff --git a/apps/desktop/src/lib/editor/__tests__/queryEditorLineNumbers.spec.ts b/apps/desktop/src/lib/editor/__tests__/queryEditorLineNumbers.spec.ts index 9e38893b0e..d1de217ab8 100644 --- a/apps/desktop/src/lib/editor/__tests__/queryEditorLineNumbers.spec.ts +++ b/apps/desktop/src/lib/editor/__tests__/queryEditorLineNumbers.spec.ts @@ -2,7 +2,7 @@ import { Compartment, EditorState } from "@codemirror/state"; import { EditorView, GutterMarker, gutter, lineNumbers } from "@codemirror/view"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { buildQueryEditorLineNumbersExtension } from "@/lib/editor/queryEditorLineNumbers"; +import { buildQueryEditorLineNumbersExtension, isWrappedLineNumberGutter } from "@/lib/editor/queryEditorLineNumbers"; class StatementRunMarker extends GutterMarker {} @@ -41,6 +41,12 @@ afterEach(() => { }); describe("query editor line numbers", () => { + it("only treats gutters taller than one visual row as wrapped", () => { + expect(isWrappedLineNumberGutter(20.8, 20.8)).toBe(false); + expect(isWrappedLineNumberGutter(21.6, 20.8)).toBe(false); + expect(isWrappedLineNumberGutter(42, 20.8)).toBe(true); + }); + it("keeps line selection behavior when enabled", () => { const { selectLine, view } = createEditor(true); const lineNumber = view.dom.querySelector(".cm-lineNumbers .cm-gutterElement"); diff --git a/apps/desktop/src/lib/editor/editorThemes.ts b/apps/desktop/src/lib/editor/editorThemes.ts index e50500d61e..3eef1e11e8 100644 --- a/apps/desktop/src/lib/editor/editorThemes.ts +++ b/apps/desktop/src/lib/editor/editorThemes.ts @@ -818,6 +818,9 @@ export function buildEditorFontThemeRules(opts?: { fixedHeight?: boolean; scroll paddingRight: "8px", userSelect: "none", }, + ".cm-lineNumbers .cm-gutterElement.cm-db-wrapped-line-number": { + alignItems: "flex-start", + }, ".cm-run-statement-gutter": { minWidth: "28px", }, diff --git a/apps/desktop/src/lib/editor/queryEditorLineNumbers.ts b/apps/desktop/src/lib/editor/queryEditorLineNumbers.ts index d12b427bf6..e57030e6d7 100644 --- a/apps/desktop/src/lib/editor/queryEditorLineNumbers.ts +++ b/apps/desktop/src/lib/editor/queryEditorLineNumbers.ts @@ -4,6 +4,50 @@ import type { lineNumbers } from "@codemirror/view"; type LineNumbersFactory = typeof lineNumbers; type LineNumbersConfig = NonNullable[0]>; +export const WRAPPED_LINE_NUMBER_GUTTER_CLASS = "cm-db-wrapped-line-number"; + +export function isWrappedLineNumberGutter(gutterHeight: number, lineHeight: number): boolean { + return gutterHeight > lineHeight + 1; +} + export function buildQueryEditorLineNumbersExtension(factory: LineNumbersFactory | null, enabled: boolean, config: LineNumbersConfig): Extension { return enabled && factory ? factory(config) : []; } + +export function createQueryEditorLineNumberAlignmentExtension(ViewPlugin: typeof import("@codemirror/view").ViewPlugin): Extension { + return ViewPlugin.fromClass( + class { + private measureScheduled = false; + + constructor(view: import("@codemirror/view").EditorView) { + this.scheduleMeasure(view); + } + + update(update: import("@codemirror/view").ViewUpdate) { + if (update.docChanged || update.geometryChanged || update.viewportChanged) this.scheduleMeasure(update.view); + } + + docViewUpdate(view: import("@codemirror/view").EditorView) { + this.scheduleMeasure(view); + } + + private scheduleMeasure(view: import("@codemirror/view").EditorView) { + if (this.measureScheduled) return; + this.measureScheduled = true; + view.requestMeasure({ + read: (measuredView) => { + const lineHeight = measuredView.defaultLineHeight; + return [...measuredView.dom.querySelectorAll(".cm-lineNumbers .cm-gutterElement")].map((element) => ({ + element, + wrapped: isWrappedLineNumberGutter(element.getBoundingClientRect().height, lineHeight), + })); + }, + write: (measurements) => { + this.measureScheduled = false; + for (const { element, wrapped } of measurements) element.classList.toggle(WRAPPED_LINE_NUMBER_GUTTER_CLASS, wrapped); + }, + }); + } + }, + ); +}