Files
wehub-resource-sync 2114b14ee0
Sync main into demo / sync (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:35:26 +08:00

613 lines
19 KiB
TypeScript

/**
* TextSelectionHandles
*
* Android-like selection handles for DOM selections (non-input).
* - Shows two draggable handles when there is an active DOM selection.
* - Dragging a handle adjusts the selection boundary using caretPositionFromPoint/caretRangeFromPoint.
*
* Notes:
* - This intentionally does NOT try to handle <input>/<textarea> selection handles
* (browsers handle those natively; in desktop they usually don't show).
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { TextSelectionService } from '../TextSelectionService';
import { SIMULATOR_CONFIG } from '../data';
const { zIndexKeyboard } = SIMULATOR_CONFIG.framework;
type Pt = { x: number; y: number };
type HandleSide = 'start' | 'end';
type Mode = 'dom' | 'input';
function getCaretPositionFromPoint(x: number, y: number): { node: Node; offset: number } | null {
const doc: any = document;
try {
if (typeof doc.caretPositionFromPoint === 'function') {
const pos = doc.caretPositionFromPoint(x, y);
if (pos && pos.offsetNode) return { node: pos.offsetNode, offset: pos.offset };
}
} catch {
// ignore
}
try {
if (typeof doc.caretRangeFromPoint === 'function') {
const r: Range | null = doc.caretRangeFromPoint(x, y);
if (r) return { node: r.startContainer, offset: r.startOffset };
}
} catch {
// ignore
}
return null;
}
function isDomSelectionUsable(sel: Selection | null): sel is Selection {
return !!sel && sel.rangeCount > 0 && !sel.isCollapsed;
}
function isTextInput(el: Element | null): el is HTMLInputElement | HTMLTextAreaElement {
return !!el && (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement);
}
function getInputSelection(el: HTMLInputElement | HTMLTextAreaElement): { start: number; end: number } | null {
const start = typeof el.selectionStart === 'number' ? el.selectionStart : null;
const end = typeof el.selectionEnd === 'number' ? el.selectionEnd : null;
if (start === null || end === null) return null;
if (start === end) return null;
return { start, end };
}
function makeCollapsedRange(node: Node, offset: number): Range | null {
try {
const r = document.createRange();
r.setStart(node, Math.max(0, offset));
r.collapse(true);
return r;
} catch {
return null;
}
}
function rectFromRangeBoundary(range: Range, side: HandleSide): DOMRect | null {
try {
const container = side === 'start' ? range.startContainer : range.endContainer;
const offset = side === 'start' ? range.startOffset : range.endOffset;
// Best effort for text node: pick a nearby character box.
if (container.nodeType === Node.TEXT_NODE) {
const text = container as Text;
const len = text.data.length;
if (len === 0) return null;
const idx = side === 'start'
? Math.min(len - 1, Math.max(0, offset))
: Math.min(len - 1, Math.max(0, offset - 1));
const r = document.createRange();
r.setStart(text, idx);
r.setEnd(text, Math.min(len, idx + 1));
const rects = r.getClientRects();
if (rects && rects.length > 0) return rects[0] as DOMRect;
const br = r.getBoundingClientRect();
if (br && br.width + br.height > 0) return br;
}
// Fallback: try a collapsed rect near the boundary.
const collapsed = makeCollapsedRange(container, offset);
if (collapsed) {
const rects = collapsed.getClientRects();
if (rects && rects.length > 0) return rects[0] as DOMRect;
const br = collapsed.getBoundingClientRect();
if (br && br.width + br.height > 0) return br;
}
// Last resort: bounding rect of the whole selection
const br = range.getBoundingClientRect();
if (br && br.width + br.height > 0) return br;
} catch {
// ignore
}
return null;
}
function clampToViewport(p: Pt): Pt {
const pad = 6;
return {
x: Math.max(pad, Math.min(window.innerWidth - pad, p.x)),
y: Math.max(pad, Math.min(window.innerHeight - pad, p.y)),
};
}
function selectionTextForClipboard(): string {
const sel = window.getSelection();
if (!sel) return '';
return (sel.toString() || '').trim();
}
function isOptInContainer(el: Element | null): boolean {
if (!el) return false;
return !!el.closest?.('[data-os-clipboard="true"]');
}
function isOptInDomSelection(sel: Selection): boolean {
if (!sel.rangeCount) return false;
try {
const range = sel.getRangeAt(0);
const common = range.commonAncestorContainer;
const commonEl = common instanceof Element ? common : common.parentElement;
return isOptInContainer(commonEl);
} catch {
return false;
}
}
function anchorForDomSelection(): { x: number; y: number; target: HTMLElement | null; text: string } | null {
const sel = window.getSelection();
if (!sel || sel.rangeCount === 0) return null;
// Default OFF: only show system handles/menu for non-editable text inside opt-in container.
if (!isOptInDomSelection(sel)) return null;
const text = (sel.toString() || '').trim();
if (!text) return null;
try {
const range = sel.getRangeAt(0);
const rect = range.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top - 12;
const common = range.commonAncestorContainer;
const commonEl = (common instanceof Element ? common : common.parentElement) as HTMLElement | null;
return { x, y, target: commonEl, text };
} catch {
return null;
}
}
function anchorForInputSelection(el: HTMLInputElement | HTMLTextAreaElement): { x: number; y: number; target: HTMLElement; text: string } | null {
const sel = getInputSelection(el);
if (!sel) return null;
const text = String(el.value ?? '').slice(sel.start, sel.end).trim();
if (!text) return null;
const pt = caretPointForInput(el, sel.end);
if (!pt) return null;
return { x: pt.x, y: pt.y - 18, target: el, text };
}
function maybeShowMenuFromSelection(): void {
if (!TextSelectionService.canAutoShowMenu()) return;
// Prefer input/textarea selection when focused.
const active = document.activeElement;
if (isTextInput(active)) {
const a = anchorForInputSelection(active);
if (a) {
TextSelectionService.showSelectionMenu(a.x, a.y, a.text, a.target);
return;
}
}
const a = anchorForDomSelection();
if (a) {
TextSelectionService.showSelectionMenu(a.x, a.y, a.text, a.target ?? undefined);
}
}
let __mirrorEl: HTMLDivElement | null = null;
function ensureMirrorEl(): HTMLDivElement {
if (__mirrorEl) return __mirrorEl;
const d = document.createElement('div');
d.setAttribute('data-no-clipboard', 'true');
d.style.position = 'fixed';
d.style.top = '0';
d.style.left = '-9999px';
d.style.visibility = 'hidden';
d.style.whiteSpace = 'pre-wrap';
d.style.wordWrap = 'break-word';
d.style.overflow = 'hidden';
d.style.pointerEvents = 'none';
document.body.appendChild(d);
__mirrorEl = d;
return d;
}
function copyInputStyles(src: HTMLInputElement | HTMLTextAreaElement, mirror: HTMLDivElement) {
const style = window.getComputedStyle(src);
// Key properties for caret positioning
const props = [
'boxSizing',
'fontFamily',
'fontSize',
'fontWeight',
'fontStyle',
'letterSpacing',
'lineHeight',
'textTransform',
'textIndent',
'textRendering',
'direction',
'paddingTop',
'paddingRight',
'paddingBottom',
'paddingLeft',
'borderTopWidth',
'borderRightWidth',
'borderBottomWidth',
'borderLeftWidth',
'tabSize',
'MozTabSize',
] as const;
for (const p of props) {
try {
(mirror.style as any)[p] = (style as any)[p];
} catch {
// ignore
}
}
mirror.style.whiteSpace = src instanceof HTMLTextAreaElement ? 'pre-wrap' : 'pre';
mirror.style.wordWrap = src instanceof HTMLTextAreaElement ? 'break-word' : 'normal';
mirror.style.width = `${src.clientWidth}px`;
}
function getLineHeightPx(el: HTMLInputElement | HTMLTextAreaElement): number {
const lh = parseFloat(window.getComputedStyle(el).lineHeight || '');
if (Number.isFinite(lh) && lh > 0) return lh;
const fs = parseFloat(window.getComputedStyle(el).fontSize || '');
return Number.isFinite(fs) && fs > 0 ? fs * 1.2 : 18;
}
function caretPointForInput(el: HTMLInputElement | HTMLTextAreaElement, index: number): Pt | null {
const value = String(el.value ?? '');
const i = Math.max(0, Math.min(value.length, Math.floor(index)));
const mirror = ensureMirrorEl();
copyInputStyles(el, mirror);
// Build mirror content
mirror.textContent = value.slice(0, i);
const marker = document.createElement('span');
// zero-width marker still gets a rect; use a visible fallback if needed
marker.textContent = '\u200b';
mirror.appendChild(marker);
const mirrorRect = mirror.getBoundingClientRect();
const markerRect = marker.getBoundingClientRect();
// Clean up marker (keep mirror node for reuse)
marker.remove();
const xInside = markerRect.left - mirrorRect.left;
const yInside = markerRect.top - mirrorRect.top;
const rect = el.getBoundingClientRect();
const x = rect.left + xInside - (el.scrollLeft || 0);
const yTop = rect.top + yInside - (el.scrollTop || 0);
const y = yTop + getLineHeightPx(el);
return clampToViewport({ x, y });
}
function indexAtPointForInput(el: HTMLInputElement | HTMLTextAreaElement, x: number, y: number): number {
const value = String(el.value ?? '');
const n = value.length;
if (n === 0) return 0;
const targetX = x;
const targetY = y;
const lineH = getLineHeightPx(el);
// 1) Find the first index whose caretY >= targetY (monotonic-ish)
let lo = 0;
let hi = n;
while (lo < hi) {
const mid = Math.floor((lo + hi) / 2);
const pt = caretPointForInput(el, mid);
if (!pt) return mid;
if (pt.y < targetY) lo = mid + 1;
else hi = mid;
}
// 2) Search a small window around lo for best (x,y) match on nearby lines
let best = lo;
let bestScore = Number.POSITIVE_INFINITY;
const start = Math.max(0, lo - 120);
const end = Math.min(n, lo + 120);
for (let i = start; i <= end; i += 1) {
const pt = caretPointForInput(el, i);
if (!pt) continue;
const dy = Math.abs(pt.y - targetY);
if (dy > lineH * 1.6) continue;
const dx = pt.x - targetX;
const score = dy * dy + dx * dx;
if (score < bestScore) {
bestScore = score;
best = i;
}
}
return Math.max(0, Math.min(n, best));
}
export const TextSelectionHandles: React.FC = () => {
const [visible, setVisible] = useState(false);
const [startPt, setStartPt] = useState<Pt | null>(null);
const [endPt, setEndPt] = useState<Pt | null>(null);
const [mode, setMode] = useState<Mode>('dom');
const activeInputRef = useRef<HTMLInputElement | HTMLTextAreaElement | null>(null);
const draggingRef = useRef<HandleSide | null>(null);
const rafRef = useRef<number | null>(null);
const handleStyle = useMemo(() => {
const size = 16;
const stemH = 14;
const stemW = 2;
return { size, stemH, stemW };
}, []);
const updateFromSelection = () => {
// Prefer input/textarea selection if present (e.g., Notes editor).
const active = document.activeElement;
if (isTextInput(active)) {
const sel = getInputSelection(active);
if (sel) {
const s = caretPointForInput(active, sel.start);
const e = caretPointForInput(active, sel.end);
if (s && e) {
activeInputRef.current = active;
setMode('input');
setVisible(true);
setStartPt(s);
setEndPt(e);
// Keep clipboard menu selectedText in sync with current selection (when menu is visible).
TextSelectionService.updateSelectedText(String(active.value ?? '').slice(sel.start, sel.end));
return;
}
}
}
activeInputRef.current = null;
const sel = window.getSelection();
if (!isDomSelectionUsable(sel)) {
setVisible(false);
setStartPt(null);
setEndPt(null);
setMode('dom');
TextSelectionService.updateSelectedText('');
return;
}
// Default OFF for non-editable areas: only show handles for opted-in containers.
if (!isOptInDomSelection(sel)) {
setVisible(false);
setStartPt(null);
setEndPt(null);
setMode('dom');
return;
}
const range = sel.getRangeAt(0);
const startRect = rectFromRangeBoundary(range, 'start');
const endRect = rectFromRangeBoundary(range, 'end');
if (!startRect || !endRect) {
setVisible(false);
return;
}
// Place handles at the bottom of boundary rects.
const s = clampToViewport({ x: startRect.left, y: startRect.bottom });
const e = clampToViewport({ x: endRect.right, y: endRect.bottom });
setVisible(true);
setStartPt(s);
setEndPt(e);
setMode('dom');
// Keep clipboard menu selectedText in sync with current selection.
TextSelectionService.updateSelectedText(selectionTextForClipboard());
};
useEffect(() => {
// Update on menu visibility changes and on selection changes.
// We don't strictly depend on menu visibility so that selection handles can appear
// when the user selects text inside a textarea by dragging.
const unsub = TextSelectionService.subscribe(() => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(updateFromSelection);
});
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
unsub();
};
}, []);
useEffect(() => {
// Always listen to selection changes; we'll decide visibility inside updateFromSelection.
const onSelChange = () => {
if (draggingRef.current) return; // dragging handles will drive updates
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(updateFromSelection);
};
document.addEventListener('selectionchange', onSelChange);
return () => document.removeEventListener('selectionchange', onSelChange);
}, []);
useEffect(() => {
// Show the menu after the user finishes selecting text.
// This matches Android-like behavior: selection action bar appears on release.
const onUp = () => {
if (draggingRef.current) return;
requestAnimationFrame(() => {
maybeShowMenuFromSelection();
});
};
document.addEventListener('pointerup', onUp, { passive: true });
document.addEventListener('mouseup', onUp, { passive: true });
document.addEventListener('touchend', onUp, { passive: true });
return () => {
document.removeEventListener('pointerup', onUp);
document.removeEventListener('mouseup', onUp);
document.removeEventListener('touchend', onUp);
};
}, []);
const applyDrag = (side: HandleSide, x: number, y: number) => {
if (mode === 'input' && activeInputRef.current) {
const el = activeInputRef.current;
const cur = getInputSelection(el);
if (!cur) return;
const idx = indexAtPointForInput(el, x, y);
let start = cur.start;
let end = cur.end;
if (side === 'start') start = idx;
else end = idx;
// Keep normalized order; if crossed, swap.
if (start > end) {
const tmp = start;
start = end;
end = tmp;
draggingRef.current = side === 'start' ? 'end' : 'start';
}
try {
el.focus();
el.setSelectionRange(start, end);
TextSelectionService.updateSelectedText(String(el.value ?? '').slice(start, end));
} catch {
// ignore
}
return;
}
const sel = window.getSelection();
if (!isDomSelectionUsable(sel)) return;
const base = sel.getRangeAt(0);
const pos = getCaretPositionFromPoint(x, y);
if (!pos) return;
const next = base.cloneRange();
try {
if (side === 'start') next.setStart(pos.node, pos.offset);
else next.setEnd(pos.node, pos.offset);
} catch {
return;
}
// If boundaries cross, swap (Android-like handle swap).
try {
const test = next.cloneRange();
test.collapse(true);
// compare start/end by constructing collapsed ranges
const s = makeCollapsedRange(next.startContainer, next.startOffset);
const e = makeCollapsedRange(next.endContainer, next.endOffset);
if (s && e) {
const cmp = s.compareBoundaryPoints(Range.START_TO_START, e);
if (cmp > 0) {
const swapped = base.cloneRange();
// swap to keep start <= end
swapped.setStart(pos.node, pos.offset);
swapped.setEnd(base.startContainer, base.startOffset);
sel.removeAllRanges();
sel.addRange(swapped);
return;
}
}
} catch {
// ignore
}
sel.removeAllRanges();
sel.addRange(next);
};
const attachDragListeners = (side: HandleSide, pointerId: number) => {
const onMove = (e: PointerEvent) => {
if (e.pointerId !== pointerId) return;
applyDrag(side, e.clientX, e.clientY);
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(updateFromSelection);
e.preventDefault();
};
const onUp = (e: PointerEvent) => {
if (e.pointerId !== pointerId) return;
draggingRef.current = null;
window.removeEventListener('pointermove', onMove);
window.removeEventListener('pointerup', onUp);
window.removeEventListener('pointercancel', onUp);
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = requestAnimationFrame(() => {
updateFromSelection();
maybeShowMenuFromSelection();
});
e.preventDefault();
};
window.addEventListener('pointermove', onMove, { passive: false });
window.addEventListener('pointerup', onUp, { passive: false });
window.addEventListener('pointercancel', onUp, { passive: false });
};
if (!visible || !startPt || !endPt) return null;
// Ensure handles are above most system overlays (and above the menu if they overlap).
const z = zIndexKeyboard + 110;
const common = {
position: 'fixed' as const,
zIndex: z,
touchAction: 'none' as const,
};
const Handle = ({ side, pt }: { side: HandleSide; pt: Pt }) => (
<div
data-no-clipboard="true"
data-keep-keyboard
style={{
...common,
left: pt.x,
top: pt.y,
transform: 'translate(-50%, 0)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
}}
>
{/* Stem - vertical line connecting selection to circle */}
<div
style={{
width: handleStyle.stemW,
height: handleStyle.stemH,
background: '#3b82f6',
}}
/>
{/* Draggable circle */}
<div
onPointerDown={(e) => {
e.stopPropagation();
e.preventDefault();
draggingRef.current = side;
try {
(e.currentTarget as any).setPointerCapture?.(e.pointerId);
} catch {
// ignore
}
attachDragListeners(side, e.pointerId);
}}
style={{
width: handleStyle.size,
height: handleStyle.size,
borderRadius: 999,
background: '#3b82f6',
boxShadow: '0 2px 8px rgba(0,0,0,0.3)',
cursor: 'pointer',
}}
/>
</div>
);
return (
<>
<Handle side="start" pt={startPt} />
<Handle side="end" pt={endPt} />
</>
);
};
export default TextSelectionHandles;