4 changed files with 220 additions and 48 deletions
@ -0,0 +1,57 @@ |
|||
import { describe, it, expect, beforeEach } from "vitest"; |
|||
import { getCaretCharOffsetFromPoint, setCaretAtCharOffset } from "@/utils/caretOffset"; |
|||
|
|||
function makeRoot(html: string): HTMLElement { |
|||
const root = document.createElement("div"); |
|||
root.innerHTML = html; |
|||
document.body.appendChild(root); |
|||
return root; |
|||
} |
|||
|
|||
function caretCharOffset(root: HTMLElement): number { |
|||
const selection = window.getSelection(); |
|||
if (!selection || selection.rangeCount === 0) return -1; |
|||
const range = selection.getRangeAt(0).cloneRange(); |
|||
const measure = document.createRange(); |
|||
measure.setStart(root, 0); |
|||
measure.setEnd(range.startContainer, range.startOffset); |
|||
return measure.toString().length; |
|||
} |
|||
|
|||
describe("setCaretAtCharOffset", () => { |
|||
beforeEach(() => { |
|||
document.body.innerHTML = ""; |
|||
}); |
|||
|
|||
it("places the caret inside a single text node", () => { |
|||
const root = makeRoot("hello world"); |
|||
setCaretAtCharOffset(root, 6); |
|||
expect(caretCharOffset(root)).toBe(6); |
|||
}); |
|||
|
|||
it("maps an offset across multiple/nested text nodes", () => { |
|||
const root = makeRoot("<p>ab</p><p><strong>cd</strong>ef</p>"); |
|||
// Text content is "abcdef"; offset 4 sits just before "e".
|
|||
setCaretAtCharOffset(root, 4); |
|||
expect(caretCharOffset(root)).toBe(4); |
|||
}); |
|||
|
|||
it("clamps to the end when the offset exceeds the content length", () => { |
|||
const root = makeRoot("<p>abc</p>"); |
|||
setCaretAtCharOffset(root, 999); |
|||
expect(caretCharOffset(root)).toBe(3); |
|||
}); |
|||
|
|||
it("falls back to the start when there is no text", () => { |
|||
const root = makeRoot(""); |
|||
setCaretAtCharOffset(root, 5); |
|||
expect(caretCharOffset(root)).toBe(0); |
|||
}); |
|||
}); |
|||
|
|||
describe("getCaretCharOffsetFromPoint", () => { |
|||
it("returns null when the browser exposes no caret-from-point API (jsdom)", () => { |
|||
const root = makeRoot("hello"); |
|||
expect(getCaretCharOffsetFromPoint(0, 0, root)).toBeNull(); |
|||
}); |
|||
}); |
|||
@ -0,0 +1,91 @@ |
|||
/** |
|||
* Translate a pointer position into a character offset within an element, and |
|||
* place the caret back at a character offset. Used so double-clicking a smart |
|||
* text node drops the caret where the user clicked: the offset is computed on |
|||
* the still-visible content before the zoom animation moves things, then |
|||
* re-applied once the element becomes editable (same element, so the offset |
|||
* maps cleanly). |
|||
* |
|||
* Offsets are counted as concatenated text-node content (UTF-16 code units). |
|||
*/ |
|||
|
|||
type CaretPoint = { node: Node; offset: number }; |
|||
|
|||
function caretPointFromPoint(x: number, y: number): CaretPoint | null { |
|||
const doc = document as Document & { |
|||
caretRangeFromPoint?: (x: number, y: number) => Range | null; |
|||
caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null; |
|||
}; |
|||
|
|||
// WebKit / Blink
|
|||
if (typeof doc.caretRangeFromPoint === "function") { |
|||
const range = doc.caretRangeFromPoint(x, y); |
|||
if (!range) return null; |
|||
return { node: range.startContainer, offset: range.startOffset }; |
|||
} |
|||
|
|||
// Firefox / spec
|
|||
if (typeof doc.caretPositionFromPoint === "function") { |
|||
const position = doc.caretPositionFromPoint(x, y); |
|||
if (!position) return null; |
|||
return { node: position.offsetNode, offset: position.offset }; |
|||
} |
|||
|
|||
return null; |
|||
} |
|||
|
|||
/** |
|||
* Character offset within `root` for the caret at viewport point (x, y), or |
|||
* null when the point is outside `root` or the browser exposes no |
|||
* caret-from-point API (e.g. jsdom). |
|||
*/ |
|||
export function getCaretCharOffsetFromPoint(x: number, y: number, root: HTMLElement): number | null { |
|||
const point = caretPointFromPoint(x, y); |
|||
if (!point || !root.contains(point.node)) return null; |
|||
|
|||
const range = document.createRange(); |
|||
range.setStart(root, 0); |
|||
range.setEnd(point.node, point.offset); |
|||
return range.toString().length; |
|||
} |
|||
|
|||
/** |
|||
* Place the caret at `offset` characters into `root` (clamped to the end when |
|||
* the content is shorter). No-op when there is no active selection. |
|||
*/ |
|||
export function setCaretAtCharOffset(root: HTMLElement, offset: number): void { |
|||
const selection = window.getSelection(); |
|||
if (!selection) return; |
|||
|
|||
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); |
|||
let remaining = Math.max(0, offset); |
|||
let lastText: Text | null = null; |
|||
|
|||
for (let node = walker.nextNode() as Text | null; node; node = walker.nextNode() as Text | null) { |
|||
const length = node.data.length; |
|||
if (remaining <= length) { |
|||
placeCaret(selection, node, remaining); |
|||
return; |
|||
} |
|||
remaining -= length; |
|||
lastText = node; |
|||
} |
|||
|
|||
if (lastText) { |
|||
placeCaret(selection, lastText, lastText.data.length); |
|||
} else { |
|||
const range = document.createRange(); |
|||
range.selectNodeContents(root); |
|||
range.collapse(false); |
|||
selection.removeAllRanges(); |
|||
selection.addRange(range); |
|||
} |
|||
} |
|||
|
|||
function placeCaret(selection: Selection, node: Text, offset: number): void { |
|||
const range = document.createRange(); |
|||
range.setStart(node, Math.min(offset, node.data.length)); |
|||
range.collapse(true); |
|||
selection.removeAllRanges(); |
|||
selection.addRange(range); |
|||
} |
|||
Loading…
Reference in new issue