159 lines
7.0 KiB
TypeScript
159 lines
7.0 KiB
TypeScript
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" | "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`.
|
|
*
|
|
* 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). 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.
|
|
*/
|
|
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(to, -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;
|
|
let contentBounds: DOMRect | 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);
|
|
if (calibratedOffset === null && charWidth > 0) {
|
|
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) {
|
|
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 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;
|
|
|
|
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({
|
|
// Keep the outline above line decorations so opaque active-line colors
|
|
// from editor themes cannot cover the frame border.
|
|
above: true,
|
|
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);
|
|
},
|
|
});
|
|
}
|