All files / src/excellent caret-utils.ts

86.12% Statements 149/173
77.55% Branches 38/49
90.9% Functions 10/11
86.12% Lines 149/173

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 17492x 92x 92x 92x 92x 92x 92x 92x 92x 250x 250x 250x 250x     92x 92x                             92x 92x 174x 174x 174x 174x 174x 174x 174x 174x 376x 174x 122x 174x 52x 52x     52x 174x 174x 202x 376x 25x 25x 25x 177x 376x     177x 266x 202x 202x 25x 376x 174x 174x 174x 174x 92x 92x 125x 125x 125x 125x 125x 125x 125x 380x 166x 125x 125x 41x 41x 41x 214x 380x     214x 365x 255x 255x 255x 41x 380x 125x 125x 125x 92x 92x 92x 92x 92x 92x 166x 166x 163x 124x 159x 39x 39x     39x 39x 39x 163x 166x 166x 92x 92x 92x 174x 174x 169x 169x 169x 169x 169x 169x 169x 92x 92x 92x 5x 5x 5x 5x 5x 92x 92x 92x 17x 17x 17x 17x 17x 17x 17x 17x 17x 17x 92x 92x 92x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x 54x  
// ---------------------------------------------------------------------------
// Cursor management utilities for contenteditable
// Newlines are represented as \n characters inside <span class="tok-newline">
// elements, so they're handled as regular text by cursor utilities.
// Browser-added <br> artifacts are ignored (treated as zero-length).
// ---------------------------------------------------------------------------
 
/** Gets the Selection object, handling shadow DOM. */
export function getSelectionFromRoot(element: HTMLElement): Selection | null {
  const root = element.getRootNode() as ShadowRoot;
  if ((root as any).getSelection) {
    return (root as any).getSelection();
  }
  return window.getSelection();
}
 
/** Returns the plain-text length of a DOM node. Ignores browser <br> artifacts. */
function nodeTextLength(node: Node): number {
  if (node.nodeType === Node.TEXT_NODE) {
    return node.textContent.length;
  }
  // Ignore browser-added <br> artifacts
  if (node.nodeName === 'BR') {
    return 0;
  }
  let len = 0;
  for (const child of Array.from(node.childNodes)) {
    len += nodeTextLength(child);
  }
  return len;
}
 
/** Converts a DOM selection position (container + offset) to a plain-text offset. */
function domPositionToTextOffset(
  root: Node,
  targetContainer: Node,
  targetOffset: number
): number {
  let total = 0;
 
  const walk = (node: Node): boolean => {
    if (node === targetContainer) {
      if (node.nodeType === Node.TEXT_NODE) {
        total += targetOffset;
      } else {
        // offset is a child index
        for (let i = 0; i < targetOffset && i < node.childNodes.length; i++) {
          total += nodeTextLength(node.childNodes[i]);
        }
      }
      return true; // found
    }
 
    if (node.nodeType === Node.TEXT_NODE) {
      total += node.textContent.length;
      return false;
    }
    // Ignore browser-added <br> artifacts
    if (node.nodeName === 'BR') {
      return false;
    }
 
    for (const child of Array.from(node.childNodes)) {
      if (walk(child)) return true;
    }
    return false;
  };
 
  walk(root);
  return total;
}
 
/** Converts a plain-text offset to a DOM position (node + offset). */
function textOffsetToDomPosition(
  root: Node,
  targetOffset: number
): { node: Node; offset: number } | null {
  let remaining = targetOffset;
 
  const walk = (node: Node): { node: Node; offset: number } | null => {
    if (node.nodeType === Node.TEXT_NODE) {
      if (remaining <= node.textContent.length) {
        return { node, offset: remaining };
      }
      remaining -= node.textContent.length;
      return null;
    }
    // Ignore browser-added <br> artifacts
    if (node.nodeName === 'BR') {
      return null;
    }
 
    for (const child of Array.from(node.childNodes)) {
      const result = walk(child);
      if (result) return result;
    }
    return null;
  };
 
  return walk(root);
}
 
/**
 * Extracts plain text from the contenteditable DOM by walking our span structure.
 * Ignores browser-added <br> artifacts. Our newlines are \n chars inside spans.
 */
export function getTextFromEditableDiv(element: HTMLElement): string {
  let text = '';
  for (const child of Array.from(element.childNodes)) {
    if (child.nodeType === Node.TEXT_NODE) {
      text += child.textContent;
    } else if (child.nodeType === Node.ELEMENT_NODE) {
      // Skip browser-added <br> artifacts
      if (child.nodeName === 'BR') {
        continue;
      }
      // Recurse into spans and other elements
      text += getTextFromEditableDiv(child as HTMLElement);
    }
  }
  return text;
}
 
/** Gets the caret (selection start) as a plain-text offset. */
export function getCaretOffset(element: HTMLElement): number {
  const selection = getSelectionFromRoot(element);
  if (!selection || selection.rangeCount === 0) return 0;
  const range = selection.getRangeAt(0);
  return domPositionToTextOffset(
    element,
    range.startContainer,
    range.startOffset
  );
}
 
/** Gets the selection end as a plain-text offset. */
export function getCaretEndOffset(element: HTMLElement): number {
  const selection = getSelectionFromRoot(element);
  if (!selection || selection.rangeCount === 0) return 0;
  const range = selection.getRangeAt(0);
  return domPositionToTextOffset(element, range.endContainer, range.endOffset);
}
 
/** Sets the caret to a plain-text offset. */
export function setCaretOffset(element: HTMLElement, offset: number): void {
  const pos = textOffsetToDomPosition(element, offset);
  if (!pos) return;
  const selection = getSelectionFromRoot(element);
  if (!selection) return;
  const range = document.createRange();
  range.setStart(pos.node, pos.offset);
  range.collapse(true);
  selection.removeAllRanges();
  selection.addRange(range);
}
 
/** Sets a selection range by plain-text offsets. */
export function setCaretRange(
  element: HTMLElement,
  start: number,
  end: number
): void {
  const startPos = textOffsetToDomPosition(element, start);
  const endPos = textOffsetToDomPosition(element, end);
  if (!startPos || !endPos) return;
  const selection = getSelectionFromRoot(element);
  if (!selection) return;
  const range = document.createRange();
  range.setStart(startPos.node, startPos.offset);
  range.setEnd(endPos.node, endPos.offset);
  selection.removeAllRanges();
  selection.addRange(range);
}