controllers/PdfTextLayerController.js

import { getPdfPageLinkAnnotations, getPdfPageTextContent } from "../loading/pdfLoader.js";

const STYLE_ID = "riffle-pdf-text-layer-style";

function injectTextLayerStyle(documentRef) {
  if (!documentRef || documentRef.getElementById(STYLE_ID)) return;
  const style = documentRef.createElement("style");
  style.id = STYLE_ID;
  style.textContent = `
.riffle-pdf-text-layer {
  position: absolute;
  inset: auto;
  z-index: 1;
  overflow: hidden;
  cursor: text;
  pointer-events: auto;
  user-select: none;
  -webkit-user-select: none;
  transform-origin: 0 0;
  touch-action: none;
}
.riffle-pdf-text-layer[hidden] {
  display: none;
}
.riffle-pdf-text-layer span {
  display: inline-block;
  position: absolute;
  color: transparent;
  cursor: text;
  pointer-events: none;
  line-height: 1;
  white-space: pre;
  user-select: none;
  -webkit-user-select: none;
  transform-origin: 0 0;
}
.riffle-pdf-text-layer.text-visible span {
  color: CanvasText;
}
.riffle-pdf-text-layer span.custom-selected {
  background: linear-gradient(
    to right,
    transparent 0%,
    transparent var(--riffle-selection-left, 0%),
    rgba(82, 142, 255, 0.32) var(--riffle-selection-left, 0%),
    rgba(82, 142, 255, 0.32) var(--riffle-selection-right, 100%),
    transparent var(--riffle-selection-right, 100%),
    transparent 100%
  );
}
.riffle-pdf-link {
  position: absolute;
  display: block;
  cursor: pointer;
  pointer-events: auto;
  background: transparent;
}
.riffle-pdf-link:hover {
  background: rgba(82, 142, 255, 0.12);
}
`;
  documentRef.head.appendChild(style);
}

function multiplyTransform(m1, m2) {
  return [
    m1[0] * m2[0] + m1[2] * m2[1],
    m1[1] * m2[0] + m1[3] * m2[1],
    m1[0] * m2[2] + m1[2] * m2[3],
    m1[1] * m2[2] + m1[3] * m2[3],
    m1[0] * m2[4] + m1[2] * m2[5] + m1[4],
    m1[1] * m2[4] + m1[3] * m2[5] + m1[5],
  ];
}

function getDisplayRect(canvas) {
  const computedStyle = canvas.ownerDocument?.defaultView?.getComputedStyle(canvas);
  const cssWidth = canvas.clientWidth || parseFloat(canvas.style.width) || parseFloat(computedStyle?.width) || canvas.width || 1;
  const cssHeight = canvas.clientHeight || parseFloat(canvas.style.height) || parseFloat(computedStyle?.height) || canvas.height || 1;
  const intrinsicWidth = Math.max(1, canvas.width || cssWidth);
  const intrinsicHeight = Math.max(1, canvas.height || cssHeight);
  const objectFit = computedStyle?.objectFit || canvas.style.objectFit || "fill";
  if (objectFit === "contain") {
    const scale = Math.min(cssWidth / intrinsicWidth, cssHeight / intrinsicHeight);
    const width = intrinsicWidth * scale;
    const height = intrinsicHeight * scale;
    return {
      x: scale,
      y: scale,
      left: (cssWidth - width) / 2,
      top: (cssHeight - height) / 2,
      cssWidth: width,
      cssHeight: height,
    };
  }
  return {
    x: cssWidth / intrinsicWidth,
    y: cssHeight / intrinsicHeight,
    left: 0,
    top: 0,
    cssWidth,
    cssHeight,
  };
}

function isPdfPage(page) {
  return page?.metadata?.source?.type === "pdf" || page?.source?.type === "pdf";
}

function getPdfSource(page) {
  return page?.metadata?.source ?? page?.source ?? null;
}

function getOcrTextContent(page) {
  return page?.metadata?.ocrTextContent ?? page?.ocrTextContent ?? null;
}

function hasTextLayer(page) {
  return !!getOcrTextContent(page) || isPdfPage(page);
}

function rectsIntersect(a, b) {
  return a.left < b.right && a.right > b.left && a.top < b.bottom && a.bottom > b.top;
}

function normalizeRect(a, b) {
  return {
    left: Math.min(a.x, b.x),
    top: Math.min(a.y, b.y),
    right: Math.max(a.x, b.x),
    bottom: Math.max(a.y, b.y),
  };
}

function clamp(value, min, max) {
  return Math.max(min, Math.min(max, value));
}

function compareCarets(a, b) {
  if (a.order !== b.order) return a.order - b.order;
  return a.index - b.index;
}

function transformPoint(transform, x, y) {
  return {
    x: transform[0] * x + transform[2] * y + transform[4],
    y: transform[1] * x + transform[3] * y + transform[5],
  };
}

/**
 * DOM text overlay for pages with embedded PDF text or external OCR text.
 *
 * This is deliberately active only on settled spreads. During animation the
 * canvas is the source of truth, and trying to morph real DOM text with the
 * page turn would make selection unreliable.
 */
export class PdfTextLayerController {
  constructor(viewer) {
    this.viewer = viewer;
    this.layer = null;
    this.renderToken = 0;
    this.resizeObserver = null;
    this.mutationObserver = null;
    this.dragStart = null;
    this.updateDeferred = false;
    this.selectionOptions = { add: false, subtract: false };
    this.selectedSpans = [];
    this.selectedRanges = [];
    this.selectedText = "";
    this.boundUpdate = () => this.update();
    this.boundHide = () => this.hide();
    this.boundPointerDown = event => this.#onPointerDown(event);
    this.boundPointerMove = event => this.#onPointerMove(event);
    this.boundPointerUp = event => this.#onPointerUp(event);
    this.boundCopy = event => this.#onCopy(event);
    this.boundContextMenu = event => this.#onContextMenu(event);
    this.boundLinkClick = event => this.#onLinkClick(event);

    viewer.on("sourcechange", this.boundUpdate);
    viewer.on("geometrychange", this.boundUpdate);
    viewer.on("spreadchange", this.boundUpdate);
    viewer.on("pageready", this.boundUpdate);
    viewer.on("animationstart", this.boundHide);
    viewer.on("animationend", this.boundUpdate);

    if (typeof ResizeObserver !== "undefined") {
      this.resizeObserver = new ResizeObserver(this.boundUpdate);
      this.resizeObserver.observe(viewer.spreadCanvas);
    }
    if (typeof MutationObserver !== "undefined") {
      this.mutationObserver = new MutationObserver(this.boundUpdate);
      this.mutationObserver.observe(viewer.spreadCanvas, {
        attributes: true,
        attributeFilter: ["class", "style"],
      });
    }
  }

  hide() {
    this.renderToken += 1;
    if (this.layer) {
      this.layer.hidden = true;
      this.layer.replaceChildren();
    }
    this.#clearSelection();
  }

  setSelectionOptions(options = {}) {
    this.selectionOptions = {
      ...this.selectionOptions,
      ...options,
    };
  }

  update() {
    if (this.dragStart) {
      this.updateDeferred = true;
      return;
    }
    const { spreadCanvas } = this.viewer;
    if (!spreadCanvas?.ownerDocument || !spreadCanvas.parentElement) return;
    this.#ensureLayer();
    this.#positionLayer();

    if (this.viewer.isAnimating || !this.viewer.book?.pages?.length) {
      this.hide();
      return;
    }

    const geometry = this.viewer.getSpreadGeometry?.();
    const sideStates = geometry?.sideStates;
    if (!sideStates) {
      this.hide();
      return;
    }

    const sides = ["left", "right"]
      .map(sideName => sideStates[sideName])
      .filter(sideState => sideState?.page && sideState?.drawnRect && hasTextLayer(sideState.page));
    if (!sides.length) {
      this.hide();
      return;
    }

    const token = ++this.renderToken;
    this.#render(sides, token).catch(error => {
      if (token === this.renderToken) {
        console.warn("[Riffle] Could not render text layer:", error);
        this.hide();
      }
    });
  }

  async #render(sides, token) {
    const fragments = [];
    const order = { value: 0 };
    for (const sideState of sides) {
      const ocrTextContent = getOcrTextContent(sideState.page);
      if (ocrTextContent) {
        fragments.push(this.#buildPageFragment(sideState, ocrTextContent, order));
        continue;
      }
      const source = getPdfSource(sideState.page);
      if (!source?.pdfDoc || !source.pageNum) continue;
      const [textContent, linkAnnotations] = await Promise.all([
        getPdfPageTextContent(source.pdfDoc, source.pageNum, { priority: true }),
        getPdfPageLinkAnnotations(source.pdfDoc, source.pageNum, { priority: true }),
      ]);
      if (token !== this.renderToken) return;
      fragments.push(this.#buildPageFragment(sideState, textContent, order));
      fragments.push(this.#buildLinkFragment(sideState, linkAnnotations));
    }
    if (token !== this.renderToken || !this.layer) return;
    const selectionSnapshot = this.#snapshotSelection();
    this.layer.replaceChildren(...fragments);
    this.layer.hidden = fragments.length === 0;
    this.#positionLayer();
    this.#fitTextRuns();
    this.#restoreSelectionSnapshot(selectionSnapshot);
    this.layer.ownerDocument.fonts?.ready?.then(() => {
      if (token === this.renderToken) {
        this.#fitTextRuns();
        this.#restoreSelectionSnapshot(selectionSnapshot);
      }
    });
  }

  #buildPageFragment(sideState, textContent, order) {
    const fragment = this.viewer.spreadCanvas.ownerDocument.createDocumentFragment();
    const viewportTransform = textContent?.transform || [1, 0, 0, -1, 0, textContent?.height || 0];
    const viewportWidth = Math.max(1, textContent?.width || sideState.drawnRect.sw || 1);
    const viewportHeight = Math.max(1, textContent?.height || sideState.drawnRect.sh || 1);
    const scaleX = sideState.drawnRect.w / viewportWidth;
    const scaleY = sideState.drawnRect.h / viewportHeight;
    const styles = textContent?.styles || {};

    for (const item of textContent?.items || []) {
      const style = styles[item.fontName] || null;
      const tx = multiplyTransform(viewportTransform, item.transform);
      const fontHeight = Math.max(1, Math.hypot(tx[2], tx[3]));
      const angle = Math.atan2(tx[1], tx[0]);
      const left = sideState.drawnRect.x + tx[4] * scaleX;
      const top = sideState.drawnRect.y + (tx[5] - fontHeight) * scaleY;
      const fontSize = fontHeight * scaleY;
      const targetWidth = Math.max(0, item.width * scaleX);
      const span = this.viewer.spreadCanvas.ownerDocument.createElement("span");
      span.textContent = item.str;
      span.dir = item.dir || "ltr";
      span.style.left = `${left}px`;
      span.style.top = `${top}px`;
      span.style.fontSize = `${fontSize}px`;
      span.style.height = `${fontSize}px`;
      if (style?.fontFamily) span.style.fontFamily = style.fontFamily;
      const baseScaleX = Math.max(0.01, scaleX / Math.max(scaleY, 0.01));
      span.dataset.angle = String(angle);
      span.dataset.baseScaleX = String(baseScaleX);
      span.dataset.targetWidth = String(targetWidth);
      span.dataset.order = String(order.value);
      order.value += 1;
      span.style.transform = `rotate(${angle}rad) scaleX(${baseScaleX})`;
      fragment.appendChild(span);
    }

    return fragment;
  }

  #buildLinkFragment(sideState, annotations) {
    const fragment = this.viewer.spreadCanvas.ownerDocument.createDocumentFragment();
    const viewportTransform = annotations?.transform || [1, 0, 0, -1, 0, annotations?.height || 0];
    const viewportWidth = Math.max(1, annotations?.width || sideState.drawnRect.sw || 1);
    const viewportHeight = Math.max(1, annotations?.height || sideState.drawnRect.sh || 1);
    const scaleX = sideState.drawnRect.w / viewportWidth;
    const scaleY = sideState.drawnRect.h / viewportHeight;

    for (const link of annotations?.links || []) {
      const [x1, y1, x2, y2] = link.rect || [];
      if (![x1, y1, x2, y2].every(Number.isFinite)) continue;
      const p1 = transformPoint(viewportTransform, x1, y1);
      const p2 = transformPoint(viewportTransform, x2, y2);
      const left = sideState.drawnRect.x + Math.min(p1.x, p2.x) * scaleX;
      const top = sideState.drawnRect.y + Math.min(p1.y, p2.y) * scaleY;
      const width = Math.abs(p2.x - p1.x) * scaleX;
      const height = Math.abs(p2.y - p1.y) * scaleY;
      if (width <= 0 || height <= 0) continue;

      const anchor = this.viewer.spreadCanvas.ownerDocument.createElement("a");
      anchor.className = "riffle-pdf-link";
      anchor.style.left = `${left}px`;
      anchor.style.top = `${top}px`;
      anchor.style.width = `${width}px`;
      anchor.style.height = `${height}px`;
      anchor.setAttribute("aria-label", link.title || link.url || "PDF link");
      if (link.url) {
        anchor.href = link.url;
        anchor.target = "_blank";
        anchor.rel = "noopener noreferrer";
      } else if (link.destPageNum) {
        anchor.href = "#";
        anchor.dataset.destPageNum = String(link.destPageNum);
      } else {
        continue;
      }
      fragment.appendChild(anchor);
    }

    return fragment;
  }

  #fitTextRuns() {
    if (!this.layer || this.layer.hidden) return;
    for (const span of this.layer.querySelectorAll("span")) {
      const targetWidth = Number(span.dataset.targetWidth) || 0;
      const baseScaleX = Number(span.dataset.baseScaleX) || 1;
      const angle = Number(span.dataset.angle) || 0;
      const naturalWidth = span.offsetWidth || 0;
      const fittedScaleX = targetWidth > 0 && naturalWidth > 0
        ? baseScaleX * targetWidth / naturalWidth
        : baseScaleX;
      span.style.transform = `rotate(${angle}rad) scaleX(${Math.max(0.01, fittedScaleX)})`;
    }
  }

  #ensureLayer() {
    if (this.layer) return;
    const { spreadCanvas } = this.viewer;
    const documentRef = spreadCanvas.ownerDocument;
    injectTextLayerStyle(documentRef);
    if (documentRef.defaultView?.getComputedStyle(spreadCanvas.parentElement).position === "static") {
      spreadCanvas.parentElement.style.position = "relative";
    }
    this.layer = documentRef.createElement("div");
    this.layer.className = "riffle-pdf-text-layer";
    this.layer.hidden = true;
    this.layer.addEventListener("pointerdown", this.boundPointerDown);
    this.layer.addEventListener("pointermove", this.boundPointerMove);
    this.layer.addEventListener("pointerup", this.boundPointerUp);
    this.layer.addEventListener("pointercancel", this.boundPointerUp);
    this.layer.addEventListener("contextmenu", this.boundContextMenu);
    this.layer.addEventListener("click", this.boundLinkClick);
    documentRef.addEventListener("contextmenu", this.boundContextMenu, true);
    documentRef.addEventListener("copy", this.boundCopy);
    spreadCanvas.parentElement.appendChild(this.layer);
  }

  #onPointerDown(event) {
    if (!this.layer || this.layer.hidden || (event.button !== 0 && event.button !== 2)) return;
    if (event.button === 0 && event.target.closest?.(".riffle-pdf-link")) return;
    event.preventDefault();
    this.layer.ownerDocument.getSelection?.()?.removeAllRanges();
    this.renderToken += 1;
    const caret = this.#getCaretAtPoint(event.clientX, event.clientY);
    this.dragStart = {
      x: event.clientX,
      y: event.clientY,
      pointerId: event.pointerId,
      caret,
      mode: event.button === 2 ? "rect" : "stream",
      add: !!this.selectionOptions.add,
      subtract: !!this.selectionOptions.subtract,
      baseRanges: (this.selectionOptions.add || this.selectionOptions.subtract) ? this.selectedRanges.slice() : [],
    };
    this.layer.setPointerCapture?.(event.pointerId);
    if (!this.dragStart.add && !this.dragStart.subtract) this.#clearSelection();
  }

  #onPointerMove(event) {
    if (!this.dragStart || event.pointerId !== this.dragStart.pointerId) return;
    event.preventDefault();
    const selectionRect = normalizeRect(this.dragStart, { x: event.clientX, y: event.clientY });
    const options = {
      add: this.dragStart.add,
      subtract: this.dragStart.subtract,
      baseRanges: this.dragStart.baseRanges,
    };
    if (this.dragStart.mode === "rect") {
      this.#selectWithinRect(selectionRect, options);
      return;
    }
    this.#selectBetweenCarets(
      this.dragStart.caret,
      this.#getCaretAtPoint(event.clientX, event.clientY),
      options,
    );
  }

  #onPointerUp(event) {
    if (!this.dragStart || event.pointerId !== this.dragStart.pointerId) return;
    event.preventDefault();
    this.layer?.releasePointerCapture?.(event.pointerId);
    this.dragStart = null;
    if (this.updateDeferred) {
      this.updateDeferred = false;
      if (!this.selectedRanges.length) this.update();
    }
  }

  #onContextMenu(event) {
    if (!this.layer || this.layer.hidden) return;
    const rect = this.layer.getBoundingClientRect();
    if (
      event.clientX < rect.left
      || event.clientX > rect.right
      || event.clientY < rect.top
      || event.clientY > rect.bottom
    ) return;
    event.preventDefault();
    event.stopPropagation();
  }

  #onLinkClick(event) {
    const link = event.target.closest?.(".riffle-pdf-link");
    if (!link || !this.layer?.contains(link)) return;
    const destPageNum = Number(link.dataset.destPageNum) || 0;
    if (!destPageNum) return;
    event.preventDefault();
    const sourcePageIndex = destPageNum - 1;
    const spreadIndex = this.viewer.book.spreadIndexForSourcePage(sourcePageIndex);
    const pageIndex = this.viewer.book.sourcePageIndexToPageIndex(sourcePageIndex);
    if (spreadIndex >= 0) this.viewer.navigateTo(spreadIndex, pageIndex);
  }

  #getOrderedSpans() {
    if (!this.layer) return [];
    return Array.from(this.layer.querySelectorAll("span"))
      .sort((a, b) => (Number(a.dataset.order) || 0) - (Number(b.dataset.order) || 0));
  }

  #getCaretAtPoint(x, y) {
    const spans = this.#getOrderedSpans();
    if (!spans.length) return null;

    let best = null;
    for (const span of spans) {
      const rect = span.getBoundingClientRect();
      if (rect.width <= 0 || rect.height <= 0) continue;
      const ySlack = Math.max(2, rect.height * 0.45);
      const withinY = y >= rect.top - ySlack && y <= rect.bottom + ySlack;
      const yDistance = y < rect.top ? rect.top - y : y > rect.bottom ? y - rect.bottom : 0;
      const xDistance = x < rect.left ? rect.left - x : x > rect.right ? x - rect.right : 0;
      const score = (withinY ? 0 : 100000) + yDistance * 32 + xDistance;
      if (!best || score < best.score) best = { span, rect, score };
    }

    if (!best) return null;
    const chars = Array.from(best.span.textContent || "");
    const fraction = best.rect.width > 0
      ? clamp((x - best.rect.left) / best.rect.width, 0, 1)
      : 0;
    return {
      span: best.span,
      order: Number(best.span.dataset.order) || 0,
      index: clamp(Math.round(fraction * chars.length), 0, chars.length),
    };
  }

  #selectBetweenCarets(anchor, focus, options = {}, selectionRect) {
    if (!this.layer || !anchor || !focus) return;
    const [start, end] = compareCarets(anchor, focus) <= 0
      ? [anchor, focus]
      : [focus, anchor];
    const selectedRanges = [];
    for (const span of this.#getOrderedSpans()) {
      const selection = this.#getSpanSelection(span, start, end, selectionRect);
      if (selection) selectedRanges.push(selection);
    }
    this.#setSelectedRanges(selectedRanges, options);
  }

  #selectWithinRect(selectionRect, options = {}) {
    if (!this.layer) return;
    const selectedRanges = [];
    for (const span of this.#getOrderedSpans()) {
      const selection = this.#getSpanSelectionForRect(span, selectionRect);
      if (selection) selectedRanges.push(selection);
    }
    this.#setSelectedRanges(selectedRanges, options);
  }

  #setSelectedRanges(ranges, options = {}) {
    const selectedRanges = options.subtract
      ? this.#subtractRanges(options.baseRanges || [], ranges)
      : options.add
        ? this.#mergeRanges([...(options.baseRanges || []), ...ranges])
        : this.#mergeRanges(ranges);
    this.#applySelectedRanges(selectedRanges);
  }

  #applySelectedRanges(selectedRanges) {
    for (const span of this.#getOrderedSpans()) {
      const selection = selectedRanges.find(range => range.span === span) || null;
      span.classList.toggle("custom-selected", !!selection);
      if (selection) {
        span.style.setProperty("--riffle-selection-left", `${selection.leftPercent}%`);
        span.style.setProperty("--riffle-selection-right", `${selection.rightPercent}%`);
      } else {
        span.style.removeProperty("--riffle-selection-left");
        span.style.removeProperty("--riffle-selection-right");
      }
    }
    this.selectedSpans = selectedRanges.map(range => range.span);
    this.selectedRanges = selectedRanges;
    this.selectedText = this.#buildSelectedText(selectedRanges);
  }

  #mergeRanges(ranges) {
    const bySpan = new Map();
    for (const range of ranges) {
      const chars = Array.from(range.span.textContent || "");
      if (!chars.length) continue;
      const current = bySpan.get(range.span);
      const startIndex = range.startIndex ?? clamp(Math.floor((range.leftPercent / 100) * chars.length), 0, chars.length);
      const endIndex = range.endIndex ?? clamp(Math.ceil((range.rightPercent / 100) * chars.length), 0, chars.length);
      if (!current) {
        bySpan.set(range.span, { startIndex, endIndex, rect: range.rect || range.span.getBoundingClientRect() });
        continue;
      }
      current.startIndex = Math.min(current.startIndex, startIndex);
      current.endIndex = Math.max(current.endIndex, endIndex);
    }

    return Array.from(bySpan.entries())
      .map(([span, range]) => {
        const chars = Array.from(span.textContent || "");
        return {
          span,
          text: chars.slice(range.startIndex, range.endIndex).join(""),
          rect: range.rect,
          startIndex: range.startIndex,
          endIndex: range.endIndex,
          leftPercent: chars.length ? (range.startIndex / chars.length) * 100 : 0,
          rightPercent: chars.length ? (range.endIndex / chars.length) * 100 : 0,
        };
      })
      .sort((a, b) => (Number(a.span.dataset.order) || 0) - (Number(b.span.dataset.order) || 0));
  }

  #subtractRanges(baseRanges, subtractRanges) {
    const subtractBySpan = new Map();
    for (const range of subtractRanges) {
      const spanRanges = subtractBySpan.get(range.span) || [];
      spanRanges.push(this.#getRangeIndices(range));
      subtractBySpan.set(range.span, spanRanges);
    }

    const remainingRanges = [];
    for (const baseRange of baseRanges) {
      const span = baseRange.span;
      const chars = Array.from(span.textContent || "");
      if (!chars.length) continue;

      let segments = [this.#getRangeIndices(baseRange)];
      for (const cut of subtractBySpan.get(span) || []) {
        const nextSegments = [];
        for (const segment of segments) {
          if (cut.endIndex <= segment.startIndex || cut.startIndex >= segment.endIndex) {
            nextSegments.push(segment);
            continue;
          }
          if (cut.startIndex > segment.startIndex) {
            nextSegments.push({ startIndex: segment.startIndex, endIndex: cut.startIndex });
          }
          if (cut.endIndex < segment.endIndex) {
            nextSegments.push({ startIndex: cut.endIndex, endIndex: segment.endIndex });
          }
        }
        segments = nextSegments;
      }

      for (const segment of segments) {
        if (segment.endIndex <= segment.startIndex) continue;
        remainingRanges.push({
          span,
          text: chars.slice(segment.startIndex, segment.endIndex).join(""),
          rect: span.getBoundingClientRect(),
          startIndex: segment.startIndex,
          endIndex: segment.endIndex,
          leftPercent: (segment.startIndex / chars.length) * 100,
          rightPercent: (segment.endIndex / chars.length) * 100,
        });
      }
    }

    return remainingRanges
      .sort((a, b) => (Number(a.span.dataset.order) || 0) - (Number(b.span.dataset.order) || 0));
  }

  #getRangeIndices(range) {
    const chars = Array.from(range.span.textContent || "");
    return {
      startIndex: range.startIndex ?? clamp(Math.floor((range.leftPercent / 100) * chars.length), 0, chars.length),
      endIndex: range.endIndex ?? clamp(Math.ceil((range.rightPercent / 100) * chars.length), 0, chars.length),
    };
  }

  #getSpanSelection(span, start, end, selectionRect) {
    const rect = span.getBoundingClientRect();
    if (rect.width <= 0 || rect.height <= 0) return null;
    if (selectionRect && !rectsIntersect(rect, selectionRect)) return null;

    const chars = Array.from(span.textContent || "");
    if (!chars.length) return null;

    const order = Number(span.dataset.order) || 0;
    if (order < start.order || order > end.order) return null;
    const startIndex = order === start.order ? start.index : 0;
    const endIndex = order === end.order ? end.index : chars.length;
    if (endIndex <= startIndex) return null;
    const leftFraction = startIndex / chars.length;
    const rightFraction = endIndex / chars.length;

    return {
      span,
      text: chars.slice(startIndex, endIndex).join(""),
      rect,
      startIndex,
      endIndex,
      leftPercent: leftFraction * 100,
      rightPercent: rightFraction * 100,
    };
  }

  #getSpanSelectionForRect(span, selectionRect) {
    const rect = span.getBoundingClientRect();
    if (rect.width <= 0 || rect.height <= 0 || !rectsIntersect(rect, selectionRect)) return null;

    const chars = Array.from(span.textContent || "");
    if (!chars.length) return null;

    const leftFraction = clamp((selectionRect.left - rect.left) / rect.width, 0, 1);
    const rightFraction = clamp((selectionRect.right - rect.left) / rect.width, 0, 1);
    if (rightFraction <= leftFraction) return null;

    const startIndex = clamp(Math.floor(leftFraction * chars.length), 0, chars.length);
    const endIndex = clamp(Math.ceil(rightFraction * chars.length), 0, chars.length);
    if (endIndex <= startIndex) return null;

    return {
      span,
      text: chars.slice(startIndex, endIndex).join(""),
      rect,
      startIndex,
      endIndex,
      leftPercent: leftFraction * 100,
      rightPercent: rightFraction * 100,
    };
  }

  #clearSelection() {
    for (const span of this.selectedSpans) {
      span.classList.remove("custom-selected");
      span.style.removeProperty("--riffle-selection-left");
      span.style.removeProperty("--riffle-selection-right");
    }
    this.selectedSpans = [];
    this.selectedRanges = [];
    this.selectedText = "";
  }

  #snapshotSelection() {
    return this.selectedRanges.map(range => ({
      order: Number(range.span.dataset.order) || 0,
      startIndex: range.startIndex ?? 0,
      endIndex: range.endIndex ?? Array.from(range.span.textContent || "").length,
    }));
  }

  #restoreSelectionSnapshot(snapshot) {
    if (!snapshot.length) {
      this.#clearSelection();
      return;
    }

    const spansByOrder = new Map(this.#getOrderedSpans().map(span => [Number(span.dataset.order) || 0, span]));
    const restoredRanges = [];
    for (const item of snapshot) {
      const span = spansByOrder.get(item.order);
      if (!span) continue;
      const chars = Array.from(span.textContent || "");
      if (!chars.length) continue;
      const startIndex = clamp(item.startIndex, 0, chars.length);
      const endIndex = clamp(item.endIndex, 0, chars.length);
      if (endIndex <= startIndex) continue;
      restoredRanges.push({
        span,
        text: chars.slice(startIndex, endIndex).join(""),
        rect: span.getBoundingClientRect(),
        startIndex,
        endIndex,
        leftPercent: (startIndex / chars.length) * 100,
        rightPercent: (endIndex / chars.length) * 100,
      });
    }
    this.#applySelectedRanges(restoredRanges);
  }

  #buildSelectedText(ranges) {
    let text = "";
    let previousRect = null;
    for (const range of ranges) {
      const rect = range.rect;
      if (previousRect && Math.abs(rect.top - previousRect.top) > Math.max(6, previousRect.height * 0.7)) {
        text = text.replace(/[ \t]+$/u, "");
        if (text && !text.endsWith("\n")) text += "\n";
      }
      text += range.text;
      previousRect = rect;
    }
    return text;
  }

  #onCopy(event) {
    if (!this.selectedText) return;
    event.clipboardData?.setData("text/plain", this.selectedText);
    event.preventDefault();
  }

  #positionLayer() {
    if (!this.layer) return;
    const { spreadCanvas } = this.viewer;
    const computedStyle = spreadCanvas.ownerDocument.defaultView?.getComputedStyle(spreadCanvas);
    const canvasHidden = computedStyle?.display === "none";
    const displayRect = getDisplayRect(spreadCanvas);
    this.layer.classList.toggle("text-visible", canvasHidden);

    if (canvasHidden) {
      this.layer.style.position = "relative";
      this.layer.style.left = "";
      this.layer.style.top = "";
      this.layer.style.width = `${Math.max(1, spreadCanvas.width)}px`;
      this.layer.style.height = `${Math.max(1, spreadCanvas.height)}px`;
      this.layer.style.transform = `scale(${displayRect.x}, ${displayRect.y})`;
      return;
    }

    this.layer.style.position = "absolute";
    this.layer.style.left = `${spreadCanvas.offsetLeft + displayRect.left}px`;
    this.layer.style.top = `${spreadCanvas.offsetTop + displayRect.top}px`;
    this.layer.style.width = `${Math.max(1, spreadCanvas.width)}px`;
    this.layer.style.height = `${Math.max(1, spreadCanvas.height)}px`;
    this.layer.style.transform = `scale(${displayRect.x}, ${displayRect.y})`;
  }
}