172 lines
6.3 KiB
TypeScript
172 lines
6.3 KiB
TypeScript
import { layer, RectangleMarker, type EditorView } from "@codemirror/view";
|
|
|
|
const MIN_EMPTY_LINE_WIDTH = 14;
|
|
const END_OF_LINE_PADDING = 3;
|
|
const CONTIGUOUS_LINE_GAP = 1.5;
|
|
const CORNER_COVERAGE_GAP = 1;
|
|
|
|
type TrimmedSelectionRect = {
|
|
left: number;
|
|
top: number;
|
|
width: number;
|
|
height: number;
|
|
};
|
|
|
|
type PositionRect = ReturnType<EditorView["coordsAtPos"]>;
|
|
|
|
function visualLineVerticalBounds(view: EditorView, start: NonNullable<PositionRect>, end: NonNullable<PositionRect>): { top: number; bottom: number } {
|
|
const top = Math.min(start.top, end.top);
|
|
const bottom = Math.max(start.bottom, end.bottom);
|
|
const height = Math.max(bottom - top, view.defaultLineHeight);
|
|
const center = (top + bottom) / 2;
|
|
return {
|
|
top: center - height / 2,
|
|
bottom: center + height / 2,
|
|
};
|
|
}
|
|
|
|
function layerBase(view: EditorView) {
|
|
const rect = view.scrollDOM.getBoundingClientRect();
|
|
return {
|
|
left: rect.left - view.scrollDOM.scrollLeft * view.scaleX,
|
|
top: rect.top - view.scrollDOM.scrollTop * view.scaleY,
|
|
};
|
|
}
|
|
|
|
function markerForLineRange(view: EditorView, from: number, to: number, lineFrom: number, lineTo: number, includesLineBreak: boolean, base: { left: number; top: number }): TrimmedSelectionRect | null {
|
|
const start = view.coordsAtPos(from, 1);
|
|
const end = from < to ? view.coordsAtPos(to, -1) : start;
|
|
const lineStart = view.coordsAtPos(lineFrom, 1) ?? start;
|
|
const lineEnd = view.coordsAtPos(lineTo, -1) ?? end;
|
|
if (!start || !end || !lineStart || !lineEnd) return null;
|
|
|
|
const left = from < to ? Math.min(start.left, end.left) : lineStart.left;
|
|
let right = from < to ? Math.max(start.right, end.right) : left + MIN_EMPTY_LINE_WIDTH;
|
|
if (includesLineBreak && to >= lineTo) {
|
|
right = Math.max(right, lineEnd.right + END_OF_LINE_PADDING);
|
|
}
|
|
|
|
const { top, bottom } = visualLineVerticalBounds(view, start, end);
|
|
|
|
return {
|
|
left: left - base.left,
|
|
top: top - base.top,
|
|
width: Math.max(1, right - left),
|
|
height: Math.max(1, bottom - top),
|
|
};
|
|
}
|
|
|
|
function sameVisualLine(a: NonNullable<PositionRect>, b: NonNullable<PositionRect>): boolean {
|
|
const aMid = (a.top + a.bottom) / 2;
|
|
const bMid = (b.top + b.bottom) / 2;
|
|
return Math.abs(aMid - bMid) <= Math.max(2, Math.min(a.bottom - a.top, b.bottom - b.top) / 2);
|
|
}
|
|
|
|
function lastPositionOnVisualLine(view: EditorView, from: number, to: number, start: NonNullable<PositionRect>): number {
|
|
let low = from + 1;
|
|
let high = to;
|
|
let best = from;
|
|
|
|
while (low <= high) {
|
|
const mid = Math.floor((low + high) / 2);
|
|
const coords = view.coordsAtPos(mid, -1) ?? view.coordsAtPos(mid, 1);
|
|
if (coords && sameVisualLine(start, coords)) {
|
|
best = mid;
|
|
low = mid + 1;
|
|
} else {
|
|
high = mid - 1;
|
|
}
|
|
}
|
|
|
|
return best > from ? best : Math.min(to, from + 1);
|
|
}
|
|
|
|
function markerRectsForLineRange(view: EditorView, from: number, to: number, lineFrom: number, lineTo: number, includesLineBreak: boolean, base: { left: number; top: number }): TrimmedSelectionRect[] {
|
|
const start = view.coordsAtPos(from, 1);
|
|
const end = from < to ? view.coordsAtPos(to, -1) : start;
|
|
if (!start || !end) return [];
|
|
if (from >= to || sameVisualLine(start, end)) {
|
|
const rect = markerForLineRange(view, from, to, lineFrom, lineTo, includesLineBreak, base);
|
|
return rect ? [rect] : [];
|
|
}
|
|
|
|
const rects: TrimmedSelectionRect[] = [];
|
|
let segmentFrom = from;
|
|
while (segmentFrom < to) {
|
|
const segmentStart = view.coordsAtPos(segmentFrom, 1);
|
|
if (!segmentStart) break;
|
|
const segmentTo = lastPositionOnVisualLine(view, segmentFrom, to, segmentStart);
|
|
const rect = markerForLineRange(view, segmentFrom, segmentTo, lineFrom, lineTo, includesLineBreak && segmentTo >= to, base);
|
|
if (rect) rects.push(rect);
|
|
if (segmentTo <= segmentFrom) break;
|
|
segmentFrom = segmentTo;
|
|
}
|
|
return rects;
|
|
}
|
|
|
|
function coversX(rect: TrimmedSelectionRect | undefined, x: number): boolean {
|
|
if (!rect) return false;
|
|
return rect.left <= x + CORNER_COVERAGE_GAP && rect.left + rect.width >= x - CORNER_COVERAGE_GAP;
|
|
}
|
|
|
|
function markerClass(rects: TrimmedSelectionRect[], index: number): string {
|
|
const prev = rects[index - 1];
|
|
const current = rects[index];
|
|
const next = rects[index + 1];
|
|
const touchesPrev = !!prev && Math.abs(prev.top + prev.height - current.top) <= CONTIGUOUS_LINE_GAP;
|
|
const touchesNext = !!next && Math.abs(current.top + current.height - next.top) <= CONTIGUOUS_LINE_GAP;
|
|
const left = current.left;
|
|
const right = current.left + current.width;
|
|
|
|
const classes = ["cm-trimmedSelection"];
|
|
if (!touchesPrev || !coversX(prev, left)) classes.push("cm-trimmedSelection-topLeft");
|
|
if (!touchesPrev || !coversX(prev, right)) classes.push("cm-trimmedSelection-topRight");
|
|
if (!touchesNext || !coversX(next, left)) classes.push("cm-trimmedSelection-bottomLeft");
|
|
if (!touchesNext || !coversX(next, right)) classes.push("cm-trimmedSelection-bottomRight");
|
|
|
|
return classes.join(" ");
|
|
}
|
|
|
|
export function trimmedSelectionLayer() {
|
|
return layer({
|
|
above: false,
|
|
class: "cm-trimmedSelectionLayer",
|
|
markers(view) {
|
|
const markers: InstanceType<typeof RectangleMarker>[] = [];
|
|
const base = layerBase(view);
|
|
|
|
for (const range of view.state.selection.ranges) {
|
|
if (range.empty) continue;
|
|
const rects: TrimmedSelectionRect[] = [];
|
|
|
|
for (const visible of view.visibleRanges) {
|
|
let pos = Math.max(range.from, visible.from, view.viewport.from);
|
|
const endPos = Math.min(range.to, visible.to, view.viewport.to);
|
|
|
|
while (pos < endPos) {
|
|
const line = view.state.doc.lineAt(pos);
|
|
const from = Math.max(pos, line.from);
|
|
const to = Math.min(endPos, line.to);
|
|
const includesLineBreak = endPos > line.to && range.to > line.to;
|
|
rects.push(...markerRectsForLineRange(view, from, to, line.from, line.to, includesLineBreak, base));
|
|
|
|
const next = line.to + 1;
|
|
if (next <= pos) break;
|
|
pos = next;
|
|
}
|
|
}
|
|
|
|
rects.sort((a, b) => a.top - b.top || a.left - b.left);
|
|
rects.forEach((rect, index) => {
|
|
markers.push(new RectangleMarker(markerClass(rects, index), rect.left, rect.top, rect.width, rect.height));
|
|
});
|
|
}
|
|
|
|
return markers;
|
|
},
|
|
update(update) {
|
|
return update.docChanged || update.selectionSet || update.viewportChanged || update.geometryChanged;
|
|
},
|
|
});
|
|
}
|