119 lines
4.4 KiB
TypeScript
119 lines
4.4 KiB
TypeScript
import type { Layout, Range, Rect } from "./types.js";
|
|||
|
|
const sourceLines = new WeakMap<Layout, Layout["lines"]>();
|
||
|
|
type SourceRect = Rect & Range;
|
||
|
|
function rangeRows(layout: Layout, range: Range): SourceRect[] {
|
||
|
|
// Layout source offsets are monotonic, including empty/blank lines. Search
|
||
|
|
// the first candidate row rather than scanning a long document on every hover.
|
||
|
|
let populated = sourceLines.get(layout);
|
||
|
|
if (!populated) {
|
||
|
|
populated = layout.lines.filter((line) => line.glyphs.length);
|
||
|
|
sourceLines.set(layout, populated);
|
||
|
|
}
|
||
|
|
let low = 0,
|
||
|
|
high = populated.length;
|
||
|
|
while (low < high) {
|
||
|
|
const middle = (low + high) >>> 1;
|
||
|
|
if (populated[middle].glyphs.at(-1)!.end <= range.start) low = middle + 1;
|
||
|
|
else high = middle;
|
||
|
|
}
|
||
|
|
const rects: SourceRect[] = [];
|
||
|
|
for (let index = low; index < populated.length; index++) {
|
||
|
|
const line = populated[index];
|
||
|
|
if (line.glyphs[0].start >= range.end) break;
|
||
|
|
const members = line.glyphs.filter((glyph) => glyph.start < range.end && glyph.end > range.start);
|
||
|
|
if (!members.length || members.every((glyph) => /^\s*$/.test(glyph.text))) continue;
|
||
|
|
rects.push({
|
||
|
|
start: members[0].start,
|
||
|
|
end: members.at(-1)!.end,
|
||
|
|
x: members[0].x,
|
||
|
|
y: Math.min(...members.map((glyph) => glyph.y)),
|
||
|
|
width: members.at(-1)!.x + members.at(-1)!.width - members[0].x,
|
||
|
|
height: Math.max(...members.map((glyph) => glyph.height)),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
return rects;
|
||
|
|
}
|
||
|
|
export function rangeRects(layout: Layout, range: Range): Rect[] {
|
||
|
|
return rangeRows(layout, range).map(({ start, end, ...rect }) => rect);
|
||
|
|
}
|
||
|
|
type Point = { x: number; y: number };
|
||
|
|
function rounded(points: Point[], radius: number) {
|
||
|
|
const corners = points.map((point, index) => {
|
||
|
|
const before = points[(index + points.length - 1) % points.length],
|
||
|
|
after = points[(index + 1) % points.length];
|
||
|
|
const incoming = Math.hypot(point.x - before.x, point.y - before.y),
|
||
|
|
outgoing = Math.hypot(after.x - point.x, after.y - point.y);
|
||
|
|
const distance = Math.min(radius, incoming / 2, outgoing / 2);
|
||
|
|
return {
|
||
|
|
point,
|
||
|
|
before: {
|
||
|
|
x: point.x + ((before.x - point.x) * distance) / incoming,
|
||
|
|
y: point.y + ((before.y - point.y) * distance) / incoming,
|
||
|
|
},
|
||
|
|
after: {
|
||
|
|
x: point.x + ((after.x - point.x) * distance) / outgoing,
|
||
|
|
y: point.y + ((after.y - point.y) * distance) / outgoing,
|
||
|
|
},
|
||
|
|
};
|
||
|
|
});
|
||
|
|
return (
|
||
|
|
corners
|
||
|
|
.map(
|
||
|
|
({ point, before, after }, index) =>
|
||
|
|
`${index ? "L" : "M"}${before.x},${before.y}Q${point.x},${point.y} ${after.x},${after.y}`,
|
||
|
|
)
|
||
|
|
.join("") + "Z"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
/** A broad band: only the first and last rows follow source edges. Paragraph
|
||
|
|
* gaps and intermediate short rows stay inside one quiet, full-width outline. */
|
||
|
|
export function rangeOutline(layout: Layout, range: Range, padding = 3): string {
|
||
|
|
const rows = rangeRows(layout, range);
|
||
|
|
if (!rows.length) return "";
|
||
|
|
const first = rows[0],
|
||
|
|
last = rows.at(-1)!;
|
||
|
|
const left = 1,
|
||
|
|
right = layout.width - 1;
|
||
|
|
const firstLeft = Math.max(left, first.x - padding);
|
||
|
|
const lastRight = Math.min(right, last.x + last.width + padding);
|
||
|
|
const top = Math.max(1, first.y - padding),
|
||
|
|
bottom = last.y + last.height + padding;
|
||
|
|
let points: Point[];
|
||
|
|
if (rows.length === 1) {
|
||
|
|
points = [
|
||
|
|
{ x: firstLeft, y: top },
|
||
|
|
{ x: lastRight, y: top },
|
||
|
|
{ x: lastRight, y: bottom },
|
||
|
|
{ x: firstLeft, y: bottom },
|
||
|
|
];
|
||
|
|
} else {
|
||
|
|
const firstJoin = first.y + first.height + padding;
|
||
|
|
const lastJoin = last.y - padding;
|
||
|
|
// Very tightly stacked rows get a plain box rather than a crossing contour.
|
||
|
|
points =
|
||
|
|
firstJoin >= lastJoin
|
||
|
|
? [
|
||
|
|
{ x: left, y: top },
|
||
|
|
{ x: right, y: top },
|
||
|
|
{ x: right, y: bottom },
|
||
|
|
{ x: left, y: bottom },
|
||
|
|
]
|
||
|
|
: [
|
||
|
|
{ x: firstLeft, y: top },
|
||
|
|
{ x: right, y: top },
|
||
|
|
{ x: right, y: lastJoin },
|
||
|
|
{ x: lastRight, y: lastJoin },
|
||
|
|
{ x: lastRight, y: bottom },
|
||
|
|
{ x: left, y: bottom },
|
||
|
|
{ x: left, y: firstJoin },
|
||
|
|
{ x: firstLeft, y: firstJoin },
|
||
|
|
];
|
||
|
|
}
|
||
|
|
points = points.filter((p, i) => {
|
||
|
|
const before = points[(i + points.length - 1) % points.length],
|
||
|
|
after = points[(i + 1) % points.length];
|
||
|
|
return !((before.x === p.x && p.x === after.x) || (before.y === p.y && p.y === after.y));
|
||
|
|
});
|
||
|
|
return rounded(points, 4);
|
||
|
|
}
|