213 lines
7.4 KiB
TypeScript
213 lines
7.4 KiB
TypeScript
import React, { useState, useRef, useMemo, useEffect, useCallback } from 'react';
|
|
import type { Point, Rect } from '../lib/coordinateMapping';
|
|
import type { Glyph } from '../lib/gatewayService';
|
|
import { TextSelectionModel, type CaretRange } from '../lib/textSelection';
|
|
|
|
type Granularity = 'caret' | 'word' | 'line';
|
|
|
|
interface SelectionLayerProps {
|
|
pageIndex: number;
|
|
width: number;
|
|
height: number;
|
|
zoom: number;
|
|
glyphs: Glyph[];
|
|
mode?: 'select' | 'highlight';
|
|
onTextSelected?: (text: string, bbox: Rect, lines: Rect[]) => void;
|
|
onSelectionChange?: (sel: { text: string, bbox: Rect, lines: Rect[] } | null) => void;
|
|
}
|
|
|
|
const SEL_START_EVT = 'pdf-selection-start';
|
|
|
|
export const SelectionLayer: React.FC<SelectionLayerProps> = ({
|
|
pageIndex,
|
|
width,
|
|
height,
|
|
zoom,
|
|
glyphs,
|
|
mode = 'select',
|
|
onTextSelected,
|
|
onSelectionChange,
|
|
}) => {
|
|
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
const model = useMemo(() => new TextSelectionModel(glyphs), [glyphs]);
|
|
|
|
const [sel, setSel] = useState<CaretRange | null>(null);
|
|
const selRef = useRef<CaretRange | null>(null);
|
|
selRef.current = sel;
|
|
|
|
const drag = useRef<{ granularity: Granularity; base: CaretRange } | null>(null);
|
|
|
|
const [box, setBox] = useState<Rect | null>(null);
|
|
const boxStart = useRef<Point | null>(null);
|
|
const hasGlyphs = model.length > 0;
|
|
|
|
const toPage = (e: React.MouseEvent): Point => {
|
|
const r = containerRef.current!.getBoundingClientRect();
|
|
return { x: (e.clientX - r.left) / zoom, y: (e.clientY - r.top) / zoom };
|
|
};
|
|
|
|
const extend = useCallback(
|
|
(base: CaretRange, granularity: Granularity, p: Point): CaretRange => {
|
|
if (granularity === 'caret') return { start: base.start, end: model.caretAt(p.x, p.y) };
|
|
const cur = granularity === 'word' ? model.wordRangeAt(p.x, p.y) : model.lineRangeAt(p.x, p.y);
|
|
if (!cur) return base;
|
|
const c = model.caretAt(p.x, p.y);
|
|
if (c >= base.end) return { start: base.start, end: cur.end };
|
|
if (c <= base.start) return { start: base.end, end: cur.start };
|
|
return { start: base.start, end: base.end };
|
|
},
|
|
[model],
|
|
);
|
|
|
|
const clear = useCallback(() => {
|
|
setSel(null);
|
|
setBox(null);
|
|
drag.current = null;
|
|
boxStart.current = null;
|
|
onSelectionChange?.(null);
|
|
}, [onSelectionChange]);
|
|
|
|
useEffect(() => {
|
|
const onOther = (e: Event) => {
|
|
if ((e as CustomEvent<number>).detail !== pageIndex) clear();
|
|
};
|
|
window.addEventListener(SEL_START_EVT, onOther as EventListener);
|
|
return () => window.removeEventListener(SEL_START_EVT, onOther as EventListener);
|
|
}, [pageIndex, clear]);
|
|
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
const cur = selRef.current;
|
|
const has = !!cur && cur.start !== cur.end;
|
|
const mod = e.ctrlKey || e.metaKey;
|
|
if (mod && e.key.toLowerCase() === 'a' && has) {
|
|
e.preventDefault();
|
|
setSel(model.selectAll());
|
|
} else if (mod && e.key.toLowerCase() === 'c' && has && mode === 'select') {
|
|
const text = model.textOfRange(cur!);
|
|
const u = model.unionRect(cur!);
|
|
const lines = model.rectsOfRange(cur!).map((q) => ({ x: q.x * zoom, y: q.y * zoom, width: q.w * zoom, height: q.h * zoom }));
|
|
if (text && u) onTextSelected?.(text, { x: u.x * zoom, y: u.y * zoom, width: u.w * zoom, height: u.h * zoom }, lines);
|
|
} else if (e.key === 'Escape' && has) {
|
|
clear();
|
|
}
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, [model, mode, zoom, onTextSelected, clear]);
|
|
|
|
const handleMouseDown = (e: React.MouseEvent) => {
|
|
if (!containerRef.current || e.button !== 0) return;
|
|
e.preventDefault();
|
|
window.dispatchEvent(new CustomEvent<number>(SEL_START_EVT, { detail: pageIndex }));
|
|
|
|
if (!hasGlyphs) {
|
|
const r = containerRef.current.getBoundingClientRect();
|
|
const px = { x: e.clientX - r.left, y: e.clientY - r.top };
|
|
boxStart.current = px;
|
|
setBox({ x: px.x, y: px.y, width: 0, height: 0 });
|
|
return;
|
|
}
|
|
|
|
const p = toPage(e);
|
|
let base: CaretRange;
|
|
let granularity: Granularity;
|
|
if (e.shiftKey && selRef.current) {
|
|
base = { start: selRef.current.start, end: selRef.current.start };
|
|
granularity = 'caret';
|
|
setSel({ start: base.start, end: model.caretAt(p.x, p.y) });
|
|
} else if (e.detail >= 3) {
|
|
base = model.lineRangeAt(p.x, p.y) ?? { start: model.caretAt(p.x, p.y), end: model.caretAt(p.x, p.y) };
|
|
granularity = 'line';
|
|
setSel(base);
|
|
} else if (e.detail === 2) {
|
|
base = model.wordRangeAt(p.x, p.y) ?? { start: model.caretAt(p.x, p.y), end: model.caretAt(p.x, p.y) };
|
|
granularity = 'word';
|
|
setSel(base);
|
|
} else {
|
|
const c = model.caretAt(p.x, p.y);
|
|
base = { start: c, end: c };
|
|
granularity = 'caret';
|
|
setSel(base);
|
|
}
|
|
drag.current = { granularity, base };
|
|
};
|
|
|
|
const handleMouseMove = (e: React.MouseEvent) => {
|
|
if (!containerRef.current) return;
|
|
if (!hasGlyphs) {
|
|
if (!boxStart.current) return;
|
|
const r = containerRef.current.getBoundingClientRect();
|
|
const px = { x: e.clientX - r.left, y: e.clientY - r.top };
|
|
setBox({
|
|
x: Math.min(boxStart.current.x, px.x),
|
|
y: Math.min(boxStart.current.y, px.y),
|
|
width: Math.abs(boxStart.current.x - px.x),
|
|
height: Math.abs(boxStart.current.y - px.y),
|
|
});
|
|
return;
|
|
}
|
|
if (!drag.current) return;
|
|
const p = toPage(e);
|
|
setSel(extend(drag.current.base, drag.current.granularity, p));
|
|
};
|
|
|
|
const handleMouseUp = () => {
|
|
if (!hasGlyphs) {
|
|
if (box && box.width > 3 && box.height > 3) onTextSelected?.('', box, [box]);
|
|
setBox(null);
|
|
boxStart.current = null;
|
|
return;
|
|
}
|
|
drag.current = null;
|
|
const cur = selRef.current;
|
|
if (!cur || cur.start === cur.end) {
|
|
setSel(null);
|
|
onSelectionChange?.(null);
|
|
return;
|
|
}
|
|
|
|
const text = model.textOfRange(cur);
|
|
const u = model.unionRect(cur);
|
|
const lines = model.rectsOfRange(cur).map((q) => ({ x: q.x * zoom, y: q.y * zoom, width: q.w * zoom, height: q.h * zoom }));
|
|
|
|
if (mode === 'highlight') {
|
|
if (u) onTextSelected?.(text, { x: u.x * zoom, y: u.y * zoom, width: u.w * zoom, height: u.h * zoom }, lines);
|
|
setSel(null);
|
|
onSelectionChange?.(null);
|
|
} else if (mode === 'select') {
|
|
if (u) onSelectionChange?.({ text, bbox: { x: u.x * zoom, y: u.y * zoom, width: u.w * zoom, height: u.h * zoom }, lines });
|
|
}
|
|
};
|
|
|
|
const rects = sel ? model.rectsOfRange(sel) : [];
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="absolute top-0 left-0 z-20 cursor-text select-none"
|
|
style={{ width: `${width}px`, height: `${height}px` }}
|
|
onMouseDown={handleMouseDown}
|
|
onMouseMove={handleMouseMove}
|
|
onMouseUp={handleMouseUp}
|
|
onMouseLeave={() => {
|
|
if (drag.current || boxStart.current) handleMouseUp();
|
|
}}
|
|
>
|
|
{rects.map((q, i) => (
|
|
<div
|
|
key={i}
|
|
className="absolute bg-[rgba(37,99,235,0.30)] pointer-events-none"
|
|
style={{ left: q.x * zoom, top: q.y * zoom, width: q.w * zoom, height: q.h * zoom }}
|
|
/>
|
|
))}
|
|
{box && (
|
|
<div
|
|
className="absolute bg-[rgba(37,99,235,0.22)] pointer-events-none rounded-[1px]"
|
|
style={{ left: box.x, top: box.y, width: box.width, height: box.height }}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|