Z ZPI 文档 v0.3.8
介绍页

源码

packages/ui/src/find.ts

typescript146 行查看原文
export const findTextBlockTags = ["p", "li", "pre", "h1", "h2", "h3", "h4", "h5", "h6", "tr"] as const;

export interface FindRequest {
  query: string;
  activeIndex: number;
  navigationId: number;
  targetRunId?: string;
  matchBefore?: string;
  matchAfter?: string;
}
export interface FindState {
  total: number;
  activeIndex: number;
}
export interface FindTarget {
  key: string;
  text: string;
  path?: string;
  runId?: string;
  /** Some content (streaming or custom diagrams) needs its mounted text instead of a static projection. */
  rendered?: boolean;
}
export interface FindMatch extends FindTarget {
  start: number;
  end: number;
}
export function normalizeFindQuery(query: string): string {
  return query.trim().toLocaleLowerCase();
}
function matchFindTarget(target: FindTarget, needle: string, folded: string) {
  const matches: FindMatch[] = [];
  if (!folded.includes(needle)) return matches;
  const starts: number[] = [],
    ends: number[] = [];
  // Lowercasing can change UTF-16 length (for example İ). Keep DOM offsets in the source text.
  // These offsets are temporary: only the folded text and current matches remain cached.
  if (folded.length !== target.text.length) {
    let offset = 0;
    for (const character of target.text) {
      const lower = character.toLocaleLowerCase();
      for (let i = 0; i < lower.length; i++) {
        starts.push(offset);
        ends.push(offset + character.length);
      }
      offset += character.length;
    }
  }
  let from = 0;
  while (from < folded.length) {
    const start = folded.indexOf(needle, from);
    if (start < 0) break;
    matches.push({
      ...target,
      start: starts[start] ?? start,
      end: ends[start + needle.length - 1] ?? start + needle.length,
    });
    from = start + needle.length;
  }
  return matches;
}
export function buildFindMatches(targets: readonly FindTarget[], query: string): FindMatch[] {
  const needle = normalizeFindQuery(query);
  return needle
    ? targets.flatMap((target) => matchFindTarget(target, needle, target.text.toLocaleLowerCase()))
    : [];
}

/** One current query per loaded target; stable history is not rescanned on streaming updates. */
export class FindIndex {
  private entries = new Map<
    string,
    { target: FindTarget; folded: string; query: string; matches: FindMatch[] }
  >();

  clear() {
    this.entries.clear();
  }

  search(targets: readonly FindTarget[], query: string): FindMatch[] {
    const needle = normalizeFindQuery(query);
    if (!needle) {
      this.clear();
      return [];
    }
    const next: typeof this.entries = new Map();
    const matches: FindMatch[] = [];
    for (const target of targets) {
      let entry = this.entries.get(target.key);
      if (
        !entry ||
        entry.target.text !== target.text ||
        entry.target.path !== target.path ||
        entry.target.runId !== target.runId ||
        entry.target.rendered !== target.rendered
      )
        entry = { target, folded: target.text.toLocaleLowerCase(), query: "", matches: [] };
      if (entry.query !== needle) {
        entry.matches = matchFindTarget(target, needle, entry.folded);
        entry.query = needle;
      }
      next.set(target.key, entry);
      for (const match of entry.matches) matches.push(match);
    }
    this.entries = next;
    return matches;
  }
}
export function findMatchKey(match: FindMatch): string {
  return `${match.key}:${match.start}:${match.end}`;
}
export function moveFindIndex(state: FindState, direction: "previous" | "next"): number {
  if (!state.total) return -1;
  return (state.activeIndex + (direction === "next" ? 1 : -1) + state.total) % state.total;
}
export function findKeyDirection(key: string, shift: boolean): "previous" | "next" | undefined {
  if (key === "ArrowUp" || (key === "Enter" && shift)) return "previous";
  if (key === "ArrowDown" || key === "Enter") return "next";
}

/** Resolve the selected search snippet after older turns finish loading. */
export function findSearchTargetIndex(matches: readonly FindMatch[], request: FindRequest): number {
  if (!request.targetRunId) return -1;
  const normalize = (text: string) =>
    text
      .replace(/[*_`~]/g, "")
      .replace(/\s+/g, " ")
      .toLocaleLowerCase();
  const before = normalize(request.matchBefore ?? "");
  const after = normalize(request.matchAfter ?? "");
  let best = -1;
  let bestScore = -1;
  matches.forEach((match, index) => {
    if (!match.key.startsWith(`${request.targetRunId}:`)) return;
    const left = normalize(match.text.slice(0, match.start));
    const right = normalize(match.text.slice(match.end));
    let score = 0;
    for (let i = 1; i <= before.length && i <= left.length && before.at(-i) === left.at(-i); i++) score++;
    for (let i = 0; i < after.length && i < right.length && after[i] === right[i]; i++) score++;
    if (score > bestScore) {
      best = index;
      bestScore = score;
    }
  });
  return best;
}