import type { Layout, Range, Rect } from "./types.js"; const sourceLines = new WeakMap(); 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); }