fix(editor): cover soft-wrapped statements with the current frame
Closes #6764
This commit is contained in:
1 parent
6aee39e884
commit
751894d224
2 files changed
+132
-24
No files matched your search
@@ -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<typeof Text.of> };
|
||||
@@ -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();
|
||||
|
||||
@@ -19,7 +19,22 @@ export interface FrameRect {
|
||||
height: number;
|
||||
}
|
||||
|
||||
type Viewish = Pick<EditorView, "coordsAtPos" | "state" | "scrollDOM" | "scaleX" | "lineBlockAt" | "defaultCharacterWidth">;
|
||||
type Viewish = Pick<EditorView, "coordsAtPos" | "scaleX" | "defaultCharacterWidth" | "defaultLineHeight"> & {
|
||||
state: Pick<EditorView["state"], "doc">;
|
||||
lineBlockAt: (pos: number) => Pick<ReturnType<EditorView["lineBlockAt"]>, "top" | "bottom" | "height">;
|
||||
scrollDOM: Pick<HTMLElement, "scrollLeft" | "scrollTop" | "getBoundingClientRect">;
|
||||
contentDOM: Pick<HTMLElement, "getBoundingClientRect">;
|
||||
};
|
||||
|
||||
type PositionRect = NonNullable<ReturnType<EditorView["coordsAtPos"]>>;
|
||||
|
||||
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<EditorView, "coordsAtPos" | "state" | "scrollDOM" | "scaleX"
|
||||
* 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.
|
||||
* CJK/fullwidth glyphs and non-monospaced fonts). A soft-wrapped logical line
|
||||
* includes the content viewport bounds because intermediate visual rows reach
|
||||
* the wrap edge even when the final row is short. For off-viewport lines,
|
||||
* estimate using an offset calibrated from the first visible line start.
|
||||
* This keeps measurement O(logical lines) with a constant number of geometry
|
||||
* probes per line, independent of statement length and visual-row count.
|
||||
*
|
||||
* All four sides get a symmetric `FRAME_INSET_PX` breathing room.
|
||||
*/
|
||||
@@ -55,7 +72,7 @@ export function currentStatementFrameRect(view: Viewish, from: number, to: numbe
|
||||
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 endCoords = view.coordsAtPos(to, -1);
|
||||
const bottom = endCoords ? endCoords.bottom - base.top + view.scrollDOM.scrollTop : view.lineBlockAt(to).bottom;
|
||||
|
||||
if (bottom - top <= 0) return null;
|
||||
@@ -66,6 +83,7 @@ export function currentStatementFrameRect(view: Viewish, from: number, to: numbe
|
||||
let right = -Infinity;
|
||||
const charWidth = view.defaultCharacterWidth;
|
||||
let calibratedOffset: number | null = null;
|
||||
let contentBounds: DOMRect | null = null;
|
||||
|
||||
for (let lineNumber = startLine.number; lineNumber <= endLine.number; lineNumber += 1) {
|
||||
const line = doc.line(lineNumber);
|
||||
@@ -77,20 +95,27 @@ export function currentStatementFrameRect(view: Viewish, from: number, to: numbe
|
||||
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;
|
||||
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;
|
||||
|
||||
Reference in new issue
Block a user