Files
openchamber/packages/ui/src/lib/selectionOverlayRects.ts
T
Bohdan Triapitsyn eb03ffaa80 feat(files): comment on selection in read-only markdown previews
Selecting text in a rendered markdown preview shows a Comment pill;
attaching stores a file-quote context draft carrying the file path, the
selected fragment (not whole lines), the user's comment, and a
best-effort source line range resolved by anchoring the fragment's
first and last lines in the raw content — a partially located fragment
gets no range rather than a misleading one. The fragment stays
highlighted while the comment input is open, using the selection
overlay rects shared with chat quote comments, and the preview's
native selection color now matches chat messages. file-quote flows
through the same context contract: composer chip previews, the message
context card, and the metadata round-trip.
2026-08-24 01:21:35 +03:00

58 lines
2.7 KiB
TypeScript

/**
* Overlay rectangles for a captured text selection.
*
* While a comment input owns focus the native selection is gone, so the
* quoted fragment is repainted with these rects (styled by
* `.oc-chat-comment-rect`). Raw Range.getClientRects() mixes block-container
* boxes with text boxes and the translucent overlaps paint double-dark bands,
* so rects are taken from the text nodes only and merged into one strip per
* visual line, each stretched to its element's line-height the way the native
* selection paints a line box.
*/
export const collectSelectionOverlayRects = (range: Range): DOMRect[] => {
const textRects: DOMRect[] = [];
const pushNodeRects = (node: Text) => {
const nodeRange = document.createRange();
nodeRange.selectNodeContents(node);
if (node === range.startContainer) nodeRange.setStart(node, range.startOffset);
if (node === range.endContainer) nodeRange.setEnd(node, range.endOffset);
const lineHeight = node.parentElement
? Number.parseFloat(window.getComputedStyle(node.parentElement).lineHeight)
: Number.NaN;
for (const rect of nodeRange.getClientRects()) {
if (rect.width <= 0 || rect.height <= 0) continue;
if (Number.isFinite(lineHeight) && lineHeight > rect.height) {
const expand = (lineHeight - rect.height) / 2;
textRects.push(new DOMRect(rect.left, rect.top - expand, rect.width, lineHeight));
} else {
textRects.push(rect);
}
}
};
const root = range.commonAncestorContainer;
if (root instanceof Text) {
pushNodeRects(root);
} else {
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (node instanceof Text && range.intersectsNode(node)) pushNodeRects(node);
}
}
const lines: Array<{ left: number; right: number; top: number; bottom: number }> = [];
for (const rect of textRects) {
const line = lines.find((candidate) => (
Math.abs(candidate.top - rect.top) < 6 && Math.abs(candidate.bottom - rect.bottom) < 6
));
if (line) {
line.left = Math.min(line.left, rect.left);
line.right = Math.max(line.right, rect.right);
line.top = Math.min(line.top, rect.top);
line.bottom = Math.max(line.bottom, rect.bottom);
} else {
lines.push({ left: rect.left, right: rect.right, top: rect.top, bottom: rect.bottom });
}
}
return lines.map((line) => new DOMRect(line.left, line.top, line.right - line.left, line.bottom - line.top));
};