671 lines
24 KiB
TypeScript
671 lines
24 KiB
TypeScript
export interface Frame {
|
||
top: number;
|
||
left: number;
|
||
}
|
||
|
||
export interface Rect {
|
||
top: number;
|
||
right: number;
|
||
bottom: number;
|
||
left: number;
|
||
}
|
||
|
||
export interface Point {
|
||
x: number;
|
||
y: number;
|
||
}
|
||
|
||
export type PositionDir = 'up' | 'down' | 'left' | 'right';
|
||
|
||
export interface Position {
|
||
point: Point;
|
||
dir?: PositionDir;
|
||
}
|
||
|
||
export interface TextSelection {
|
||
key: string;
|
||
text: string;
|
||
page: number;
|
||
range: Range;
|
||
index: number;
|
||
cfi?: string;
|
||
href?: string;
|
||
annotated?: boolean;
|
||
rect?: Rect;
|
||
// Native Android selection handles were suppressed for this selection
|
||
// (Blink hyphen bounds bug, issue #1553) — the app draws its own handles.
|
||
handlesSuppressed?: boolean;
|
||
}
|
||
|
||
const frameRect = (frame: Frame, rect?: Rect, sx = 1, sy = 1) => {
|
||
if (!rect) return { left: 0, right: 0, top: 0, bottom: 0 };
|
||
const left = sx * rect.left + frame.left;
|
||
const right = sx * rect.right + frame.left;
|
||
const top = sy * rect.top + frame.top;
|
||
const bottom = sy * rect.bottom + frame.top;
|
||
return { left, right, top, bottom };
|
||
};
|
||
|
||
const pointIsInView = ({ x, y }: Point) =>
|
||
x > 0 && y > 0 && x < window.innerWidth && y < window.innerHeight;
|
||
|
||
const getIframeElement = (nodeElement: Range | Element): HTMLIFrameElement | null => {
|
||
let node: Node | null;
|
||
if (nodeElement && typeof nodeElement === 'object' && 'tagName' in nodeElement) {
|
||
node = nodeElement as Element;
|
||
} else if (nodeElement && typeof nodeElement === 'object' && 'collapse' in nodeElement) {
|
||
node = nodeElement.commonAncestorContainer;
|
||
} else {
|
||
node = nodeElement;
|
||
}
|
||
while (node) {
|
||
if (node.nodeType === Node.DOCUMENT_NODE) {
|
||
const doc = node as Document;
|
||
if (doc.defaultView && doc.defaultView.frameElement) {
|
||
return doc.defaultView.frameElement as HTMLIFrameElement;
|
||
}
|
||
}
|
||
node = node.parentNode;
|
||
}
|
||
|
||
return null;
|
||
};
|
||
|
||
const constrainPointWithinRect = (point: Point, rect: Rect, padding: number) => {
|
||
return {
|
||
x: Math.max(padding, Math.min(point.x, rect.right - rect.left - padding)),
|
||
y: Math.max(padding, Math.min(point.y, rect.bottom - rect.top - padding)),
|
||
};
|
||
};
|
||
|
||
export const isPointInRect = (point: Point, rect: Rect, padding: number = 1): boolean => {
|
||
return (
|
||
point.x >= rect.left + padding &&
|
||
point.x <= rect.right - padding &&
|
||
point.y >= rect.top + padding &&
|
||
point.y <= rect.bottom - padding
|
||
);
|
||
};
|
||
|
||
/**
|
||
* Resolve the bounding rect of a {@link Range} in the OUTER webview's
|
||
* viewport coordinate system (CSS pixels, top-down).
|
||
*
|
||
* Foliate renders book pages inside an iframe with a CSS transform
|
||
* (its column-pagination layout uses non-identity `matrix(...)` to
|
||
* shift columns). A naive `range.getBoundingClientRect()` returns
|
||
* coordinates in the iframe's local viewport, which won't line up
|
||
* with anything outside the iframe. This helper applies the iframe's
|
||
* transform scale and offset, mirroring the math in {@link getPosition}.
|
||
*
|
||
* Returns `null` when the range is detached (no iframe ancestor) or
|
||
* has no client rects (collapsed / off-screen).
|
||
*/
|
||
export const getRangeRectInWebview = (range: Range): Rect | null => {
|
||
const rect = range.getBoundingClientRect();
|
||
if (rect.width === 0 && rect.height === 0) return null;
|
||
const frameElement = getIframeElement(range);
|
||
// No iframe ancestor — range lives directly in the host document
|
||
// (e.g. fixed-layout PDF). Pass through the rect as-is.
|
||
if (!frameElement) {
|
||
return { top: rect.top, right: rect.right, bottom: rect.bottom, left: rect.left };
|
||
}
|
||
const transform = getComputedStyle(frameElement).transform;
|
||
const match = transform.match(/matrix\((.+)\)/);
|
||
const [sx, , , sy] = match?.[1]?.split(/\s*,\s*/)?.map((x) => parseFloat(x)) ?? [];
|
||
const scaleX = Number.isFinite(sx) ? sx! : 1;
|
||
const scaleY = Number.isFinite(sy) ? sy! : 1;
|
||
const frame = frameElement.getBoundingClientRect();
|
||
return {
|
||
top: scaleY * rect.top + frame.top,
|
||
bottom: scaleY * rect.bottom + frame.top,
|
||
left: scaleX * rect.left + frame.left,
|
||
right: scaleX * rect.right + frame.left,
|
||
};
|
||
};
|
||
|
||
/**
|
||
* Sample the visual style (font size / family / color) of the text
|
||
* underneath a {@link Range}. Used by the macOS system-dictionary
|
||
* bridge so the inline HUD label matches the original paragraph's
|
||
* typography — `-[NSView showDefinitionForAttributedString:atPoint:]`
|
||
* re-draws the word using whatever attributes we hand in, and a plain
|
||
* unattributed string falls back to AppKit's small system font.
|
||
*
|
||
* The font-size is scaled by the iframe's vertical transform so the
|
||
* value is in **outer webview** CSS pixels (matching what AppKit
|
||
* receives via the contentView, which itself reports its bounds in
|
||
* CSS pixels on standard Tauri/macOS).
|
||
*
|
||
* Returns `null` when the range has no element parent we can sample.
|
||
*/
|
||
export interface RangeTextStyle {
|
||
fontSize: number;
|
||
fontFamily: string;
|
||
color: string;
|
||
}
|
||
|
||
export const getRangeTextStyleInWebview = (range: Range): RangeTextStyle | null => {
|
||
const node: Node | null =
|
||
range.startContainer.nodeType === Node.ELEMENT_NODE
|
||
? range.startContainer
|
||
: range.startContainer.parentElement;
|
||
if (!node || node.nodeType !== Node.ELEMENT_NODE) return null;
|
||
const element = node as Element;
|
||
const style = element.ownerDocument?.defaultView?.getComputedStyle(element);
|
||
if (!style) return null;
|
||
|
||
const frameElement = getIframeElement(range);
|
||
let scaleY = 1;
|
||
if (frameElement) {
|
||
const transform = getComputedStyle(frameElement).transform;
|
||
const match = transform.match(/matrix\((.+)\)/);
|
||
const parts = match?.[1]?.split(/\s*,\s*/)?.map((x) => parseFloat(x));
|
||
const sy = parts?.[3];
|
||
if (Number.isFinite(sy)) scaleY = sy!;
|
||
}
|
||
|
||
// Cross-check the declared font-size against the range's actual
|
||
// visual height. In typical EPUB layout the inline box is roughly
|
||
// `font-size × line-height` tall (≈1.2× by default), so a declared
|
||
// font-size noticeably *larger* than the rendered height means the
|
||
// value isn't a real CSS pixel measurement we can hand to NSFont —
|
||
// pdf.js's text layer is the canonical offender: each glyph span
|
||
// can carry an intrinsic `font-size` that reflects the document's
|
||
// unit-em size before `transform: scale(...)` shrinks it back to
|
||
// page-coordinate pixels, leaving `getComputedStyle(...).fontSize`
|
||
// many times bigger than the on-screen glyph. Without compensation
|
||
// the macOS HUD lays out a giant attributed string and the yellow
|
||
// highlight rectangle behind it engulfs neighbouring paragraphs.
|
||
//
|
||
// Fix: when the declared size exceeds the inline box height by
|
||
// more than 30 %, treat the inline box height as the source of
|
||
// truth and back out a plausible font-size. 0.85 is the typical
|
||
// ratio of cap-height-ish font-size to a 1.2 line-height box; it
|
||
// matches normal EPUB body text (the common case) within a few
|
||
// percent and converges PDF text layers onto something AppKit can
|
||
// render at a sensible scale. Below the threshold (the common
|
||
// EPUB case) we leave the declared value alone.
|
||
const declaredFontSize = (parseFloat(style.fontSize) || 0) * scaleY;
|
||
let fontSize = declaredFontSize;
|
||
const renderedHeight = range.getBoundingClientRect().height;
|
||
if (renderedHeight > 0 && declaredFontSize > renderedHeight * 1.3) {
|
||
fontSize = renderedHeight * 0.85;
|
||
}
|
||
|
||
return {
|
||
fontSize,
|
||
fontFamily: style.fontFamily,
|
||
color: style.color,
|
||
};
|
||
};
|
||
|
||
export const isPointerInsideSelection = (selection: Selection, ev: PointerEvent) => {
|
||
if (selection.rangeCount === 0) return false;
|
||
const range = selection.getRangeAt(0);
|
||
const rects = range.getClientRects();
|
||
const padding = 50;
|
||
for (let i = 0; i < rects.length; i++) {
|
||
const rect = rects[i]!;
|
||
if (
|
||
ev.clientX >= rect.left - padding &&
|
||
ev.clientX <= rect.right + padding &&
|
||
ev.clientY >= rect.top - padding &&
|
||
ev.clientY <= rect.bottom + padding
|
||
) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
};
|
||
|
||
export const getPosition = (
|
||
targetElement: Range | Element | TextSelection,
|
||
rect: Rect,
|
||
paddingPx: number,
|
||
isVertical: boolean = false,
|
||
) => {
|
||
const { range: target, rect: targetRect } =
|
||
targetElement && 'range' in targetElement
|
||
? targetElement
|
||
: { range: targetElement, rect: undefined };
|
||
const frameElement = getIframeElement(target);
|
||
const transform = frameElement ? getComputedStyle(frameElement).transform : '';
|
||
const match = transform.match(/matrix\((.+)\)/);
|
||
const [sx, , , sy] = match?.[1]?.split(/\s*,\s*/)?.map((x) => parseFloat(x)) ?? [];
|
||
|
||
const frame = frameElement?.getBoundingClientRect() ?? { top: 0, left: 0 };
|
||
let padding = { top: 0, right: 0, bottom: 0, left: 0 };
|
||
if ('nodeType' in target && target.nodeType === 1) {
|
||
const computedStyle = window.getComputedStyle(target);
|
||
padding = {
|
||
top: parseInt(computedStyle.paddingTop, 10) || 0,
|
||
right: parseInt(computedStyle.paddingRight, 10) || 0,
|
||
bottom: parseInt(computedStyle.paddingBottom, 10) || 0,
|
||
left: parseInt(computedStyle.paddingLeft, 10) || 0,
|
||
};
|
||
}
|
||
const rects = Array.from(target.getClientRects()).map((rect) => {
|
||
return {
|
||
top: rect.top + padding.top,
|
||
right: rect.right - padding.right,
|
||
bottom: rect.bottom - padding.bottom,
|
||
left: rect.left + padding.left,
|
||
};
|
||
});
|
||
const first = targetRect
|
||
? frameRect(frame, targetRect, sx, sy)
|
||
: frameRect(frame, rects[0], sx, sy);
|
||
const last = targetRect
|
||
? frameRect(frame, targetRect, sx, sy)
|
||
: frameRect(frame, rects.at(-1), sx, sy);
|
||
|
||
if (isVertical) {
|
||
const leftSpace = first.left - rect.left;
|
||
const rightSpace = rect.right - first.right;
|
||
const dir = leftSpace > rightSpace ? 'left' : 'right';
|
||
const position = {
|
||
point: constrainPointWithinRect(
|
||
{
|
||
x: dir === 'left' ? first.left - rect.left - 6 : first.right - rect.left + 6,
|
||
y: (first.top + first.bottom) / 2 - rect.top,
|
||
},
|
||
rect,
|
||
paddingPx,
|
||
),
|
||
dir,
|
||
} as Position;
|
||
const inView = pointIsInView(position.point);
|
||
return inView ? position : ({ point: { x: 0, y: 0 }, dir } as Position);
|
||
}
|
||
|
||
const start = {
|
||
point: constrainPointWithinRect(
|
||
{ x: (first.left + first.right) / 2 - rect.left, y: first.top - rect.top - 12 },
|
||
rect,
|
||
paddingPx,
|
||
),
|
||
dir: 'up',
|
||
} as Position;
|
||
const end = {
|
||
point: constrainPointWithinRect(
|
||
{ x: (last.left + last.right) / 2 - rect.left, y: last.bottom - rect.top + 6 },
|
||
rect,
|
||
paddingPx,
|
||
),
|
||
dir: 'down',
|
||
} as Position;
|
||
// Decide which selection end is on-screen by testing its UNCLAMPED line-rect
|
||
// midpoint against the READING FRAME (`rect`) — not the window. A cross-page
|
||
// selection's off-screen start maps to a negative/sidebar x that is still
|
||
// inside the window, so a window-based check would wrongly read it "in view"
|
||
// and pin the popup off the visible page (#1354).
|
||
const midX = (r: Rect) => (r.left + r.right) / 2;
|
||
const midY = (r: Rect) => (r.top + r.bottom) / 2;
|
||
const inFrame = (px: number, py: number) =>
|
||
px > rect.left && px < rect.right && py > rect.top && py < rect.bottom;
|
||
const startInView = inFrame(midX(first), midY(first));
|
||
const endInView = inFrame(midX(last), midY(last));
|
||
if (!startInView && !endInView) {
|
||
// Multi-page selection: both ends are off the visible page, but the middle
|
||
// may cross it. Anchor to the last on-screen line so the popup tracks the
|
||
// visible part of the selection.
|
||
const v = rects
|
||
.map((r) => frameRect(frame, r, sx, sy))
|
||
.filter((r) => inFrame(midX(r), midY(r)))
|
||
.at(-1);
|
||
if (v) {
|
||
return {
|
||
point: constrainPointWithinRect(
|
||
{ x: midX(v) - rect.left, y: v.bottom - rect.top + 6 },
|
||
rect,
|
||
paddingPx,
|
||
),
|
||
dir: 'down',
|
||
} as Position;
|
||
}
|
||
// Otherwise fall through and anchor to an end so the popup still shows
|
||
// (the constrained points are always within the frame, never {0,0}).
|
||
}
|
||
if (!startInView) return end;
|
||
if (!endInView) return start;
|
||
return start.point.y > window.innerHeight - end.point.y ? start : end;
|
||
};
|
||
|
||
// The popup will be positioned based on the triangle position and the direction
|
||
// up: above the triangle
|
||
// down: below the triangle
|
||
// left: to the left of the triangle
|
||
// right: to the right of the triangle
|
||
export const getPopupPosition = (
|
||
position: Position,
|
||
boundingReact: Rect,
|
||
popupWidthPx: number,
|
||
popupHeightPx: number,
|
||
popupPaddingPx: number,
|
||
) => {
|
||
const popupPoint = { x: 0, y: 0 };
|
||
if (position.dir === 'up') {
|
||
popupPoint.x = position.point.x - popupWidthPx / 2;
|
||
popupPoint.y = position.point.y - popupHeightPx;
|
||
} else if (position.dir === 'down') {
|
||
popupPoint.x = position.point.x - popupWidthPx / 2;
|
||
popupPoint.y = position.point.y + 6;
|
||
} else if (position.dir === 'left') {
|
||
popupPoint.x = position.point.x - popupWidthPx;
|
||
popupPoint.y = position.point.y - popupHeightPx / 2;
|
||
} else if (position.dir === 'right') {
|
||
popupPoint.x = position.point.x + 6;
|
||
popupPoint.y = position.point.y - popupHeightPx / 2;
|
||
}
|
||
|
||
if (popupPoint.x < popupPaddingPx) {
|
||
popupPoint.x = popupPaddingPx;
|
||
}
|
||
if (popupPoint.y < popupPaddingPx) {
|
||
popupPoint.y = popupPaddingPx;
|
||
}
|
||
if (popupPoint.x + popupWidthPx > boundingReact.right - boundingReact.left - popupPaddingPx) {
|
||
popupPoint.x = boundingReact.right - boundingReact.left - popupPaddingPx - popupWidthPx;
|
||
}
|
||
if (popupPoint.y + popupHeightPx > boundingReact.bottom - boundingReact.top - popupPaddingPx) {
|
||
popupPoint.y = boundingReact.bottom - boundingReact.top - popupPaddingPx - popupHeightPx;
|
||
}
|
||
|
||
return { point: popupPoint, dir: position.dir } as Position;
|
||
};
|
||
|
||
export const snapRangeToWords = (range: Range): void => {
|
||
if (typeof Intl === 'undefined' || !Intl.Segmenter) return;
|
||
|
||
const isPunctuation = (ch: string) => /^\p{P}|\p{S}$/u.test(ch);
|
||
|
||
const snapStartToWordBoundary = () => {
|
||
const node = range.startContainer;
|
||
if (node.nodeType !== Node.TEXT_NODE) return;
|
||
const text = node.textContent ?? '';
|
||
const offset = range.startOffset;
|
||
if (offset === 0 || offset >= text.length) return;
|
||
|
||
const charAtOffset = text[offset] ?? '';
|
||
if (isPunctuation(charAtOffset)) return;
|
||
|
||
const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
|
||
for (const seg of segmenter.segment(text)) {
|
||
if (seg.isWordLike && seg.index < offset && seg.index + seg.segment.length > offset) {
|
||
range.setStart(node, seg.index);
|
||
break;
|
||
}
|
||
}
|
||
};
|
||
|
||
const snapEndToWordBoundary = () => {
|
||
const node = range.endContainer;
|
||
if (node.nodeType !== Node.TEXT_NODE) return;
|
||
const text = node.textContent ?? '';
|
||
const offset = range.endOffset;
|
||
if (offset === 0 || offset >= text.length) return;
|
||
|
||
const charBeforeOffset = text[offset - 1] ?? '';
|
||
if (isPunctuation(charBeforeOffset)) return;
|
||
|
||
const segmenter = new Intl.Segmenter(undefined, { granularity: 'word' });
|
||
for (const seg of segmenter.segment(text)) {
|
||
if (seg.isWordLike && seg.index < offset && seg.index + seg.segment.length > offset) {
|
||
range.setEnd(node, seg.index + seg.segment.length);
|
||
break;
|
||
}
|
||
}
|
||
};
|
||
|
||
snapStartToWordBoundary();
|
||
snapEndToWordBoundary();
|
||
};
|
||
|
||
// --- Android hyphenation selection-bounds bug (issue #1553) -----------------
|
||
//
|
||
// Blink's `LayoutSelection::ComputePaintingSelectionStateForCursor` compares
|
||
// the selection's paragraph text-content offsets against each fragment's
|
||
// `TextOffset()`. Auto/soft-hyphen fragments are layout-generated text whose
|
||
// offsets are self-relative ({0,1}), so a touch selection starting at the
|
||
// first character of a paragraph marks EVERY hyphen fragment in it as a
|
||
// selection start: the native start handle is painted on the paragraph's last
|
||
// hyphen and drag gestures re-anchor the selection base there. The helpers
|
||
// below detect that condition so the app can repair the range and suppress
|
||
// the broken native handles (touch handles are the only consumer of the bogus
|
||
// bounds, so mouse/desktop selections are unaffected).
|
||
|
||
const isInlineDisplay = (el: Element): boolean => {
|
||
const display = el.ownerDocument.defaultView?.getComputedStyle(el).display ?? '';
|
||
return display === 'inline' || display.startsWith('ruby');
|
||
};
|
||
|
||
// The element establishing the inline formatting context the range starts in.
|
||
const getBlockAncestor = (node: Node): Element | null => {
|
||
let el: Element | null =
|
||
node.nodeType === Node.ELEMENT_NODE ? (node as Element) : node.parentElement;
|
||
while (el && isInlineDisplay(el)) {
|
||
el = el.parentElement;
|
||
}
|
||
return el;
|
||
};
|
||
|
||
// Whether the range starts at the first character of its paragraph's inline
|
||
// content (leading collapsed whitespace does not count: it never reaches the
|
||
// paragraph's laid-out text, so the selection still maps to offset 0).
|
||
export const isRangeStartAtBlockStart = (range: Range): boolean => {
|
||
const block = getBlockAncestor(range.startContainer);
|
||
if (!block) return false;
|
||
const probe = (block.ownerDocument ?? document).createRange();
|
||
try {
|
||
probe.selectNodeContents(block);
|
||
probe.setEnd(range.startContainer, range.startOffset);
|
||
} catch {
|
||
return false;
|
||
}
|
||
return probe.toString().trim().length === 0;
|
||
};
|
||
|
||
// A generated hyphen is the only way a single text node produces two adjacent
|
||
// boxes on the same line where the trailing one is sub-glyph narrow.
|
||
const HYPHEN_MAX_EM = 0.6;
|
||
const HYPHEN_ADJACENCY_PX = 2;
|
||
|
||
interface RectLike {
|
||
left: number;
|
||
top: number;
|
||
width: number;
|
||
height: number;
|
||
}
|
||
|
||
export const hasTrailingHyphenRectPattern = (
|
||
rects: RectLike[],
|
||
emPx: number,
|
||
vertical: boolean,
|
||
): boolean => {
|
||
for (let i = 1; i < rects.length; i++) {
|
||
const a = rects[i - 1]!;
|
||
const b = rects[i]!;
|
||
const sameLine = vertical
|
||
? Math.abs(a.left - b.left) < Math.max(a.width, b.width) / 2
|
||
: Math.abs(a.top - b.top) < Math.max(a.height, b.height) / 2;
|
||
if (!sameLine) continue;
|
||
const adjacent = vertical
|
||
? Math.abs(b.top - (a.top + a.height)) <= HYPHEN_ADJACENCY_PX
|
||
: Math.abs(b.left - (a.left + a.width)) <= HYPHEN_ADJACENCY_PX ||
|
||
Math.abs(a.left - (b.left + b.width)) <= HYPHEN_ADJACENCY_PX;
|
||
if (!adjacent) continue;
|
||
const size = vertical ? b.height : b.width;
|
||
if (size > 0 && size <= emPx * HYPHEN_MAX_EM) return true;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
const blockHasGeneratedHyphens = (block: Element, vertical: boolean): boolean => {
|
||
const doc = block.ownerDocument;
|
||
const win = doc.defaultView;
|
||
if (!win) return false;
|
||
const emPx = parseFloat(win.getComputedStyle(block).fontSize) || 16;
|
||
const walker = doc.createTreeWalker(block, NodeFilter.SHOW_TEXT);
|
||
const probe = doc.createRange();
|
||
let node: Node | null;
|
||
while ((node = walker.nextNode())) {
|
||
if (!(node as Text).data.trim()) continue;
|
||
let rects: RectLike[];
|
||
try {
|
||
probe.selectNodeContents(node);
|
||
rects = Array.from(probe.getClientRects());
|
||
} catch {
|
||
return false;
|
||
}
|
||
if (hasTrailingHyphenRectPattern(rects, emPx, vertical)) return true;
|
||
}
|
||
return false;
|
||
};
|
||
|
||
// Whether painting this selection hits the Blink generated-hyphen bounds bug:
|
||
// it starts at the first character of a paragraph that renders hyphens.
|
||
export const isHyphenHandleBugProneRange = (range: Range, vertical = false): boolean => {
|
||
const block = getBlockAncestor(range.startContainer);
|
||
if (!block) return false;
|
||
const win = block.ownerDocument.defaultView;
|
||
if (!win) return false;
|
||
const style = win.getComputedStyle(block);
|
||
const mayHyphenate =
|
||
style.getPropertyValue('hyphens') === 'auto' ||
|
||
style.getPropertyValue('-webkit-hyphens') === 'auto' ||
|
||
(block.textContent ?? '').includes('');
|
||
if (!mayHyphenate) return false;
|
||
if (!isRangeStartAtBlockStart(range)) return false;
|
||
return blockHasGeneratedHyphens(block, vertical);
|
||
};
|
||
|
||
// Rebuild a selection range between a known-good anchor and the caret at a
|
||
// point (in `doc` viewport coordinates) — used to restore the range a
|
||
// corrupted long-press drag was meant to produce: anchored at the
|
||
// gesture-initial position, ending where the finger was. Snapped to word
|
||
// boundaries like the native word-granularity drag.
|
||
export const rangeFromAnchorToPoint = (
|
||
doc: Document,
|
||
anchorNode: Node,
|
||
anchorOffset: number,
|
||
x: number,
|
||
y: number,
|
||
): Range | null => {
|
||
let pointNode: Node | null = null;
|
||
let pointOffset = 0;
|
||
if (doc.caretPositionFromPoint) {
|
||
const pos = doc.caretPositionFromPoint(x, y);
|
||
if (pos) {
|
||
pointNode = pos.offsetNode;
|
||
pointOffset = pos.offset;
|
||
}
|
||
} else if (doc.caretRangeFromPoint) {
|
||
const range = doc.caretRangeFromPoint(x, y);
|
||
if (range) {
|
||
pointNode = range.startContainer;
|
||
pointOffset = range.startOffset;
|
||
}
|
||
}
|
||
if (!pointNode) return null;
|
||
const range = doc.createRange();
|
||
try {
|
||
const anchorPoint = doc.createRange();
|
||
anchorPoint.setStart(anchorNode, anchorOffset);
|
||
anchorPoint.collapse(true);
|
||
const caretPoint = doc.createRange();
|
||
caretPoint.setStart(pointNode, pointOffset);
|
||
caretPoint.collapse(true);
|
||
if (anchorPoint.compareBoundaryPoints(Range.START_TO_START, caretPoint) <= 0) {
|
||
range.setStart(anchorNode, anchorOffset);
|
||
range.setEnd(pointNode, pointOffset);
|
||
} else {
|
||
range.setStart(pointNode, pointOffset);
|
||
range.setEnd(anchorNode, anchorOffset);
|
||
}
|
||
} catch {
|
||
return null;
|
||
}
|
||
if (range.collapsed) return null;
|
||
snapRangeToWords(range);
|
||
return range;
|
||
};
|
||
|
||
// During a long-press drag the bug re-anchors the selection base at the last
|
||
// hyphen, dropping the word the gesture started on. If the gesture-initial
|
||
// anchor fell out of the final range, rebuild [initial anchor → focus] (the
|
||
// focus is where the finger actually went) and snap it to word boundaries
|
||
// like the native word-granularity drag would.
|
||
export const repairJumpedSelectionRange = (
|
||
sel: Selection,
|
||
initialNode: Node,
|
||
initialOffset: number,
|
||
): Range | null => {
|
||
if (sel.rangeCount === 0) return null;
|
||
const range = sel.getRangeAt(0);
|
||
const { focusNode, focusOffset } = sel;
|
||
const doc = range.startContainer.ownerDocument;
|
||
if (!focusNode || !doc) return null;
|
||
try {
|
||
if (range.comparePoint(initialNode, initialOffset) === 0) return null;
|
||
} catch {
|
||
return null;
|
||
}
|
||
const repaired = doc.createRange();
|
||
try {
|
||
const initialPoint = doc.createRange();
|
||
initialPoint.setStart(initialNode, initialOffset);
|
||
initialPoint.collapse(true);
|
||
const focusPoint = doc.createRange();
|
||
focusPoint.setStart(focusNode, focusOffset);
|
||
focusPoint.collapse(true);
|
||
if (initialPoint.compareBoundaryPoints(Range.START_TO_START, focusPoint) <= 0) {
|
||
repaired.setStart(initialNode, initialOffset);
|
||
repaired.setEnd(focusNode, focusOffset);
|
||
} else {
|
||
repaired.setStart(focusNode, focusOffset);
|
||
repaired.setEnd(initialNode, initialOffset);
|
||
}
|
||
} catch {
|
||
return null;
|
||
}
|
||
if (repaired.collapsed) return null;
|
||
snapRangeToWords(repaired);
|
||
return repaired;
|
||
};
|
||
|
||
export const getTextFromRange = (range: Range, rejectTags: string[] = []): string => {
|
||
const clonedRange = range.cloneRange();
|
||
const fragment = clonedRange.cloneContents();
|
||
const walker = document.createTreeWalker(
|
||
fragment,
|
||
NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT,
|
||
{
|
||
acceptNode: (node) => {
|
||
const parent = node.parentElement;
|
||
if (rejectTags.includes(parent?.tagName.toLowerCase() || '')) {
|
||
return NodeFilter.FILTER_REJECT;
|
||
}
|
||
return NodeFilter.FILTER_ACCEPT;
|
||
},
|
||
},
|
||
);
|
||
|
||
// pdf.js inserts <br role="presentation"> between text spans at line endings
|
||
// (see TextLayer#appendText in pdfjs). Without this, multi-line PDF
|
||
// selections collapse adjacent line-final and line-initial words into a
|
||
// single token (e.g. "lastfirst"). Treat <br> as a newline, matching how
|
||
// Selection.toString() handles line breaks in the browser.
|
||
let text = '';
|
||
let node: Node | null;
|
||
while ((node = walker.nextNode())) {
|
||
if (node.nodeType === Node.TEXT_NODE) {
|
||
text += (node as Text).nodeValue ?? '';
|
||
} else if ((node as Element).tagName === 'BR') {
|
||
text += '\n';
|
||
}
|
||
}
|
||
|
||
return text;
|
||
};
|