282 lines · 10.7 KB · TypeScript
RawDownload
import { ASSISTANT_CITATION_CONTEXT_LENGTH, type AssistantCitation } from "@t3tools/contracts"; export type AssistantTextSelector = {  readonly text: string;  readonly start: number;  readonly end: number;  readonly prefix: string;  readonly suffix: string;}; /** Live DOM state for an open comment, never part of a saved citation. */export type AssistantCitationSourceAnchor = {  source: HTMLElement;  range: Range;  viewport: HTMLElement;}; export function findAssistantCitationSourceAnchor(  document: Document,  citation: AssistantCitation,): AssistantCitationSourceAnchor | null {  const source = [    ...document.querySelectorAll<HTMLElement>("[data-assistant-citation-source]"),  ].find(    (element) =>      element.dataset.assistantCitationSource === citation.messageId &&      element.dataset.assistantCitationEnvironment === citation.environmentId &&      element.dataset.assistantCitationThread === citation.threadId,  );  const viewport = source?.closest<HTMLElement>("[data-assistant-citation-viewport]");  if (!source || !viewport) return null;  const range = resolveAssistantCitationRange(source, citation);  return range ? { source, range, viewport } : null;} const CONTROL_SELECTOR = "button, input, textarea, select, [role=button], [contenteditable]";const EXCLUDED_SELECTOR = `${CONTROL_SELECTOR}, [hidden], [aria-hidden=true], script, style, template, noscript, svg`;const BLOCK_SELECTOR =  "address, article, aside, blockquote, dd, div, dl, dt, figcaption, figure, footer, h1, h2, h3, h4, h5, h6, header, hr, li, main, nav, ol, p, pre, section, table, td, th, tr, ul"; function normalizeWhitespace(text: string): string {  return text.replace(/\s+/g, " ");} function splitsSurrogatePair(text: string, offset: number): boolean {  const before = text.charCodeAt(offset - 1);  const after = text.charCodeAt(offset);  return before >= 0xd800 && before <= 0xdbff && after >= 0xdc00 && after <= 0xdfff;} /** Keeps the exact captured text while storing normalized UTF-16 positions and context. */export function createAssistantTextSelector(  text: string,  rawStart: number,  rawEnd: number,): AssistantTextSelector | null {  const quote = text.slice(rawStart, rawEnd);  if (quote.trim().length === 0) return null;   const normalized = normalizeWhitespace(text);  let start = normalizeWhitespace(text.slice(0, rawStart)).length;  // A selection starting inside a whitespace run includes its normalized space.  if (rawStart > 0 && /\s/.test(text[rawStart - 1]!) && /\s/.test(text[rawStart]!)) {    start -= 1;  }  const end = normalizeWhitespace(text.slice(0, rawEnd)).length;  let prefixStart = Math.max(0, start - ASSISTANT_CITATION_CONTEXT_LENGTH);  let suffixEnd = Math.min(normalized.length, end + ASSISTANT_CITATION_CONTEXT_LENGTH);  // A split pair becomes a replacement character when the context enters a URL.  if (splitsSurrogatePair(normalized, prefixStart)) prefixStart += 1;  if (splitsSurrogatePair(normalized, suffixEnd)) suffixEnd -= 1;  return {    text: quote,    start,    end,    prefix: normalized.slice(prefixStart, start),    suffix: normalized.slice(end, suffixEnd),  };} /** * Matches case-sensitive text after collapsing each JS whitespace run to one * space, without trimming. All positions and context lengths use UTF-16 units * in that normalized stream, not markdown offsets or Unicode code points. * `selector.text` can retain the original line breaks and code indentation. * Repeated quotes require one match for all supplied context. Even matching * saved offsets cannot break a context tie, since those offsets may have drifted. */export function findAssistantCitationText(  text: string,  selector: AssistantTextSelector,): { start: number; end: number } | null {  const normalized = normalizeWhitespace(text);  const quote = normalizeWhitespace(selector.text);  if (quote.trim().length === 0) return null;   const prefix = normalizeWhitespace(selector.prefix);  const suffix = normalizeWhitespace(selector.suffix);  const matchesContext = (start: number, end: number) =>    normalized.slice(Math.max(0, start - prefix.length), start) === prefix &&    normalized.slice(end, end + suffix.length) === suffix;   let match =    Number.isSafeInteger(selector.start) &&    Number.isSafeInteger(selector.end) &&    selector.start >= 0 &&    selector.end - selector.start === quote.length &&    normalized.slice(selector.start, selector.end) === quote &&    matchesContext(selector.start, selector.end)      ? { start: selector.start, end: selector.end }      : null;  let onlyQuote: { start: number; end: number } | null = null;  let quoteCount = 0;   for (    let start = normalized.indexOf(quote);    start !== -1;    start = normalized.indexOf(quote, start + 1)  ) {    const end = start + quote.length;    quoteCount += 1;    onlyQuote = { start, end };    if (!matchesContext(start, end)) continue;    if (match !== null && match.start !== start) return null;    match = { start, end };  }   return match ?? (quoteCount === 1 ? onlyQuote : null);} type TextChunk = { node: Text; start: number; end: number }; /** * Uses DOM text order, with a line break between HTML blocks and at <br>. * Inline markup, including code and links, contributes its displayed text. * Controls and subtrees marked hidden/aria-hidden do not contribute. No layout * reads, CSS-generated content, or soft-wrap line breaks enter the stream, so * reflow cannot move it. */function readAssistantText(root: HTMLElement) {  const parts: string[] = [];  const chunks: TextChunk[] = [];  let length = 0;  let separator = false;   const visit = (node: Node) => {    if (node.nodeType === 3) {      const text = node as Text;      if (text.length === 0) return;      if (separator && length > 0) {        parts.push("\n");        length += 1;      }      separator = false;      chunks.push({ node: text, start: length, end: length + text.length });      parts.push(text.data);      length += text.length;      return;    }    if (node.nodeType !== 1) return;    const element = node as Element;    if (element.matches(EXCLUDED_SELECTOR)) return;    const block = element.matches(BLOCK_SELECTOR);    if (block || element.tagName === "BR") separator = true;    for (const child of element.childNodes) visit(child);    if (block) separator = true;  };   visit(root);  return { text: parts.join(""), chunks };} function excludedAncestor(node: Node): Element | null {  const element = node.nodeType === 1 ? (node as Element) : node.parentElement;  return element?.closest(EXCLUDED_SELECTOR) ?? null;} function isUsableRange(root: HTMLElement, range: Range): boolean {  if (    range.collapsed ||    !root.contains(range.startContainer) ||    !root.contains(range.endContainer) ||    excludedAncestor(range.startContainer) !== null ||    excludedAncestor(range.endContainer) !== null  ) {    return false;  }  // Interior controls are allowed; readAssistantText omits them from the stream.  return true;} function selectedTextBoundary(range: Range, node: Node, last: boolean): Text | null {  if (!range.intersectsNode(node)) return null;  if (node.nodeType === 3) {    const text = node as Text;    const start = node === range.startContainer ? range.startOffset : 0;    const end = node === range.endContainer ? range.endOffset : text.length;    return start < end ? text : null;  }  for (    let child = last ? node.lastChild : node.firstChild;    child !== null;    child = last ? child.previousSibling : child.nextSibling  ) {    const boundary = selectedTextBoundary(range, child, last);    if (boundary !== null) return boundary;  }  return null;} /** Captures the ordered native range, including selections dragged backwards. */export function captureAssistantTextSelection(  viewport: HTMLElement,  selection: Selection | null,): { source: HTMLElement; selector: AssistantTextSelector; range: Range } | null {  if (selection === null || selection.isCollapsed || selection.rangeCount !== 1) return null;  const range = selection.getRangeAt(0).cloneRange();  const first = selectedTextBoundary(range, range.commonAncestorContainer, false);  const last = selectedTextBoundary(range, range.commonAncestorContainer, true);  if (first === null || last === null) return null;  const source = first.parentElement?.closest<HTMLElement>("[data-assistant-citation-source]");  if (!source || !viewport.contains(source)) return null;   // Paragraph selection can end at the next block's offset 0 or a parent  // boundary. Validate the text actually selected, not that empty endpoint.  range.setStart(first, first === range.startContainer ? range.startOffset : 0);  range.setEnd(last, last === range.endContainer ? range.endOffset : last.length);  if (!isUsableRange(source, range)) return null;   const stream = readAssistantText(source);  let rawStart: number | null = null;  let rawEnd = 0;  for (const chunk of stream.chunks) {    if (!range.intersectsNode(chunk.node)) continue;    const start = range.startContainer === chunk.node ? range.startOffset : 0;    const end = range.endContainer === chunk.node ? range.endOffset : chunk.node.length;    if (start === end) continue;    rawStart ??= chunk.start + start;    rawEnd = chunk.start + end;  }  if (rawStart === null) return null;  const selector = createAssistantTextSelector(stream.text, rawStart, rawEnd);  return selector === null ? null : { source, selector, range };} function rawTextOffset(text: string, normalizedOffset: number): number {  let offset = 0;  for (const match of text.matchAll(/\s+|\S+/g)) {    const whitespace = /\s/.test(match[0][0]!);    const length = whitespace ? 1 : match[0].length;    if (normalizedOffset <= offset + length) {      return (        match.index +        (whitespace && normalizedOffset > offset ? match[0].length : normalizedOffset - offset)      );    }    offset += length;  }  return text.length;} /** Resolves against the current DOM without changing the user's selection. */export function resolveAssistantCitationRange(  root: HTMLElement,  selector: AssistantTextSelector,): Range | null {  if (excludedAncestor(root) !== null) return null;  const stream = readAssistantText(root);  const match = findAssistantCitationText(stream.text, selector);  if (match === null) return null;   const start = rawTextOffset(stream.text, match.start);  const end = rawTextOffset(stream.text, match.end);  const first = stream.chunks.find((chunk) => chunk.end > start);  const last = stream.chunks.findLast((chunk) => chunk.start < end);  if (first === undefined || last === undefined) return null;   const range = root.ownerDocument.createRange();  range.setStart(first.node, Math.max(0, start - first.start));  range.setEnd(last.node, Math.min(last.node.length, end - last.start));  return isUsableRange(root, range) ? range : null;}