Files
readest/apps/readest-app/src/utils/sel.ts
T
loveheaven 05da6bdf43 feat(dictionary): add system dictionary provider for macOS, iOS, and Android (#4219)
Hand selected words off to the platform's native dictionary surface
when the user opts into the new "System Dictionary" entry under
Settings → Languages → Dictionaries. The setting is exclusive: enabling
it disables all other providers (and vice versa) so the in-app lookup
button either always opens the popup or always invokes the OS — no
mixed states.

Per platform:
- macOS: AppKit's -[NSView showDefinitionForAttributedString:atPoint:]
  via a top-level Tauri command in src-tauri/src/macos/system_dictionary.rs.
  Anchored at the selection's bottom-center (CSS pixels mapped into
  NSView coords), so the inline Lookup HUD appears just below the
  highlighted text without raising Dictionary.app to the foreground.
- iOS: UIReferenceLibraryViewController presented as a half-detent
  pageSheet on iPhone (medium → large drag-to-expand) and as a
  formSheet on iPad. Implemented in the native-bridge plugin.
- Android: ACTION_PROCESS_TEXT intent with EXTRA_PROCESS_TEXT_READONLY,
  dispatched without createChooser so users get the standard system
  disambiguation dialog with "Just once / Always" buttons. Reports
  unavailable=true when no app handles the intent so the TS layer can
  silently skip rather than open an empty chooser.

Web/Linux/Windows hide the row entirely. The provider is a sentinel —
the registry filters it out of the popup tab list (it has no in-popup
UI) and the annotator's handleDictionary checks isSystemDictionaryEnabled
to dispatch directly to the native bridge before opening the in-app
DictionaryPopup.
2026-05-19 07:03:52 +02:00

400 lines
13 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;
}
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!;
}
const fontSizePx = parseFloat(style.fontSize) || 0;
return {
fontSize: fontSizePx * scaleY,
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;
const startInView = pointIsInView(start.point);
const endInView = pointIsInView(end.point);
if (!startInView && !endInView) return { point: { x: 0, y: 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();
};
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;
};