diff --git a/apps/desktop/src/lib/__tests__/editor/currentStatementFrameLayer.spec.ts b/apps/desktop/src/lib/__tests__/editor/currentStatementFrameLayer.spec.ts index 22c4eae7ae..1b912f8d80 100644 --- a/apps/desktop/src/lib/__tests__/editor/currentStatementFrameLayer.spec.ts +++ b/apps/desktop/src/lib/__tests__/editor/currentStatementFrameLayer.spec.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it, vi } from "vitest"; import { Text } from "@codemirror/state"; import { currentStatementFrameRect, FRAME_INSET_PX } from "@/lib/editor/codemirrorCurrentStatementFrameLayer"; @@ -20,6 +20,8 @@ interface LineBlock { interface ViewLike { coordsAtPos: (pos: number, side?: -1 | 1) => CoordRect | null; + contentDOM: { getBoundingClientRect(): DOMRect }; + defaultLineHeight: number; lineBlockAt: (pos: number) => LineBlock; defaultCharacterWidth: number; state: { doc: ReturnType }; @@ -32,18 +34,37 @@ 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 { +interface BuildViewOptions { + wrapAtColumns?: number; + viewportColumns?: number; +} + +function buildView(lines: string[], scrollTop = 0, coordsAtPosNull: ((pos: number, side: -1 | 1) => boolean) | null = null, options: BuildViewOptions = {}): ViewLike { const doc = Text.of(lines); + const wrapAtColumns = options.wrapAtColumns; + const visualRows = lines.map((line) => (wrapAtColumns ? Math.max(1, Math.ceil(line.length / wrapAtColumns)) : 1)); + const lineTops: number[] = []; + let nextLineTop = 0; + for (const rows of visualRows) { + lineTops.push(nextLineTop); + nextLineTop += rows * LINE_HEIGHT; + } + const viewportWidth = options.viewportColumns ? options.viewportColumns * CHAR_WIDTH : 1000; const lineTop = (pos: number) => { const line = doc.lineAt(pos); - return (line.number - 1) * LINE_HEIGHT; + return lineTops[line.number - 1]; }; return { + contentDOM: { + getBoundingClientRect: () => ({ left: 0, top: BASE_TOP, right: viewportWidth, bottom: 1000, width: viewportWidth, height: 1000 }) as DOMRect, + }, defaultCharacterWidth: CHAR_WIDTH, + defaultLineHeight: LINE_HEIGHT, 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 }; + const height = visualRows[line.number - 1] * LINE_HEIGHT; + return { from: line.from, length: line.length, top, bottom: top + height, height }; }, state: { doc }, scaleX: 1, @@ -51,17 +72,19 @@ function buildView(lines: string[], scrollTop = 0, coordsAtPosNull: ((pos: numbe scrollDOM: { scrollLeft: 0, scrollTop, - getBoundingClientRect: () => ({ left: 0, top: BASE_TOP, right: 1000, bottom: 1000, width: 1000, height: 1000 }) as DOMRect, + getBoundingClientRect: () => ({ left: 0, top: BASE_TOP, right: viewportWidth, bottom: 1000, width: viewportWidth, 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 line = doc.lineAt(pos); + const offset = pos - line.from; + const characterOffset = side === -1 ? Math.max(0, offset - 1) : Math.min(offset, Math.max(0, line.length - 1)); + const visualRow = wrapAtColumns ? Math.floor(characterOffset / wrapAtColumns) : 0; + const col = wrapAtColumns ? characterOffset % wrapAtColumns : characterOffset; const left = col * CHAR_WIDTH; - const top = lineTop(pos); + const top = lineTop(pos) + visualRow * LINE_HEIGHT; // 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 }; + return { left, right: left + CHAR_WIDTH, top: top - scrollTop + BASE_TOP, bottom: top + LINE_HEIGHT - scrollTop + BASE_TOP, height: LINE_HEIGHT }; }, }; } @@ -95,6 +118,55 @@ describe("currentStatementFrameRect", () => { expect(rect!.width).toBe(9 * CHAR_WIDTH + FRAME_INSET_PX * 2); }); + it("covers every visual row of one soft-wrapped logical line", () => { + const sql = `SELECT ${"x".repeat(20)}`; + const view = buildView([sql], 0, null, { wrapAtColumns: 10, viewportColumns: 10 }); + + const rect = currentStatementFrameRect(view, 0, sql.length); + + expect(rect).not.toBeNull(); + expect(rect!.width).toBe(10 * CHAR_WIDTH + FRAME_INSET_PX * 2); + expect(rect!.height).toBe(3 * LINE_HEIGHT + FRAME_INSET_PX * 2); + }); + + it("does not widen a short statement that stays on one visual row of a wrapped logical line", () => { + const statement = "SELECT 1;"; + const view = buildView([`${statement} ${"x".repeat(20)}`], 0, null, { wrapAtColumns: 10, viewportColumns: 10 }); + + const rect = currentStatementFrameRect(view, 0, statement.length); + + expect(rect).not.toBeNull(); + expect(rect!.width).toBe(statement.length * CHAR_WIDTH + FRAME_INSET_PX * 2); + expect(rect!.height).toBe(LINE_HEIGHT + FRAME_INSET_PX * 2); + }); + + it("uses the visual viewport width when one logical line in a multi-line statement wraps", () => { + const lines = ["SELECT 1", ` ${"x".repeat(23)}`, "FROM t"]; + const view = buildView(lines, 0, null, { wrapAtColumns: 10, viewportColumns: 10 }); + + const rect = currentStatementFrameRect(view, 0, view.state.doc.length); + + expect(rect).not.toBeNull(); + expect(rect!.width).toBe(10 * CHAR_WIDTH + FRAME_INSET_PX * 2); + expect(rect!.height).toBe(5 * LINE_HEIGHT + FRAME_INSET_PX * 2); + }); + + it("keeps soft-wrapped frame probes constant for a very long logical line", () => { + const shortView = buildView(["x".repeat(201)], 0, null, { wrapAtColumns: 100, viewportColumns: 100 }); + const longSql = "x".repeat(1_000_001); + const longView = buildView([longSql], 0, null, { wrapAtColumns: 100, viewportColumns: 100 }); + const shortCoords = vi.spyOn(shortView, "coordsAtPos"); + const longCoords = vi.spyOn(longView, "coordsAtPos"); + + currentStatementFrameRect(shortView, 0, shortView.state.doc.length); + const rect = currentStatementFrameRect(longView, 0, longSql.length); + + expect(rect?.width).toBe(100 * CHAR_WIDTH + FRAME_INSET_PX * 2); + expect(rect?.height).toBe(Math.ceil(longSql.length / 100) * LINE_HEIGHT + FRAME_INSET_PX * 2); + expect(longCoords).toHaveBeenCalledTimes(shortCoords.mock.calls.length); + expect(longCoords.mock.calls.length).toBeLessThanOrEqual(6); + }); + 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); @@ -146,6 +218,17 @@ describe("currentStatementFrameRect", () => { expect(rect!.height).toBe(4 * LINE_HEIGHT + FRAME_INSET_PX * 2); }); + it("keeps a wrapped line full-width when its coordinates are off-viewport", () => { + const sql = `SELECT ${"x".repeat(20)}`; + const view = buildView([sql], 200, () => true, { wrapAtColumns: 10, viewportColumns: 10 }); + + const rect = currentStatementFrameRect(view, 0, sql.length); + + expect(rect).not.toBeNull(); + expect(rect!.width).toBe(10 * CHAR_WIDTH + FRAME_INSET_PX * 2); + expect(rect!.height).toBe(3 * 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[] = []; @@ -159,7 +242,7 @@ describe("currentStatementFrameRect", () => { 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 view = buildView(lines, 2000, (pos, side) => (pos === from && side === 1) || (pos === to && side === -1)); const rect = currentStatementFrameRect(view, from, to); expect(rect).not.toBeNull(); diff --git a/apps/desktop/src/lib/editor/codemirrorCurrentStatementFrameLayer.ts b/apps/desktop/src/lib/editor/codemirrorCurrentStatementFrameLayer.ts index ae00a69ac4..7335c3431a 100644 --- a/apps/desktop/src/lib/editor/codemirrorCurrentStatementFrameLayer.ts +++ b/apps/desktop/src/lib/editor/codemirrorCurrentStatementFrameLayer.ts @@ -19,7 +19,22 @@ export interface FrameRect { height: number; } -type Viewish = Pick; +type Viewish = Pick & { + state: Pick; + lineBlockAt: (pos: number) => Pick, "top" | "bottom" | "height">; + scrollDOM: Pick; + contentDOM: Pick; +}; + +type PositionRect = NonNullable>; + +function spansVisualRows(start: PositionRect, end: PositionRect): boolean { + const startMid = (start.top + start.bottom) / 2; + const endMid = (end.top + end.bottom) / 2; + const startHeight = start.bottom - start.top; + const endHeight = end.bottom - end.top; + return Math.abs(startMid - endMid) > Math.max(2, Math.min(startHeight, endHeight) / 2); +} /** * Measure the pixel rectangle of one statement rendered in `view`. @@ -32,10 +47,12 @@ type Viewish = Pick 0) { - const indent = line.text.match(/^\s*/)?.[0].length || 0; - calibratedOffset = lineLeft.left - indent * charWidth; + const lineStart = anchorFrom === line.from ? lineLeft : view.coordsAtPos(line.from, 1); + calibratedOffset = lineStart?.left ?? lineLeft.left - (anchorFrom - line.from) * 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; + const estimatedRight = calibratedOffset + line.length * charWidth; right = Math.max(right, estimatedRight); } + + const fullLogicalLine = anchorFrom === line.from && anchorTo === line.to; + const wrappedFromCoordinates = !!lineLeft && !!lineRight && spansVisualRows(lineLeft, lineRight); + const wrappedFromLineBlock = (!lineLeft || !lineRight) && fullLogicalLine && view.lineBlockAt(anchorFrom).height > view.defaultLineHeight * 1.5; + if (wrappedFromCoordinates || wrappedFromLineBlock) { + contentBounds ??= view.contentDOM.getBoundingClientRect(); + left = Math.min(left, contentBounds.left); + right = Math.max(right, contentBounds.right); + } } - // If no lines were measurable (entire statement off-viewport), skip frame + // If no endpoints were measurable and no wrapped block supplied viewport + // bounds, skip the frame. if (!isFinite(left) || !isFinite(right)) return null; const leftOffset = base.left - view.scrollDOM.scrollLeft * view.scaleX;