Files
pdf/frontend/src/viewer/SelectionLayer.tsx
T

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>
);
};