484 lines
20 KiB
TypeScript
484 lines
20 KiB
TypeScript
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
import { layoutText } from "./layout.js";
|
|
import { canvasMeasure } from "./measure.js";
|
|
import { drawText } from "./draw.js";
|
|
import { contextTarget, copyText, hitTest, moveSelection, selectionRange, type Selection } from "./selection.js";
|
|
import { wordAt } from "./text.js";
|
|
import { textInteractionAt } from "./interaction.js";
|
|
import { readingPages, pageAt, readingWindow, type ReadingWindow } from "./pages.js";
|
|
import { Tooltip, type TooltipContent } from "./Tooltip.js";
|
|
import { rangeRects } from "./outline.js";
|
|
import { rangeOutline } from "./outline.js";
|
|
import { intersects, type Annotation, type Layout, type Range, type Rect } from "./types.js";
|
|
import type { PageSize } from "./page-size.js";
|
|
import { coalesceProcessing, type ProcessingRegion } from "./processing.js";
|
|
|
|
export type CanvasContextTarget = { range: Range; kind: "word" | "selection"; anchor: Rect };
|
|
|
|
export function Surface({
|
|
text,
|
|
annotations,
|
|
textColor = "#252923",
|
|
accentColor = "#39745b",
|
|
fontSize = 23,
|
|
fontFamily = '"Noto Serif CJK SC", "Songti SC", Georgia, serif',
|
|
visible,
|
|
onFocus,
|
|
onHover,
|
|
onWindow,
|
|
mode = "pages",
|
|
position = 0,
|
|
selection,
|
|
onSelection,
|
|
hover = null,
|
|
emphasis = null,
|
|
focus = null,
|
|
tooltips = [],
|
|
onDismissTooltip,
|
|
processing = [],
|
|
pageSize,
|
|
onSize,
|
|
onContextMenu,
|
|
interactive = true,
|
|
}: {
|
|
text: string;
|
|
annotations: Annotation[];
|
|
textColor?: string;
|
|
accentColor?: string;
|
|
fontSize?: number;
|
|
fontFamily?: string;
|
|
visible: (id: string) => boolean;
|
|
onFocus: (range: Range) => void;
|
|
onHover: (range: Range | null) => void;
|
|
onWindow: (window: ReadingWindow) => void;
|
|
mode?: string;
|
|
position?: number;
|
|
selection: Selection;
|
|
onSelection: (selection: Selection) => void;
|
|
hover?: Range | null;
|
|
emphasis?: Range | null;
|
|
focus?: Range | null;
|
|
tooltips?: (Annotation & { label: string; trigger: "hover" | "click" })[];
|
|
onDismissTooltip: () => void;
|
|
processing?: ProcessingRegion[];
|
|
pageSize?: PageSize;
|
|
onSize?: (size: PageSize) => void;
|
|
onContextMenu?: (target: CanvasContextTarget) => void;
|
|
interactive?: boolean;
|
|
}) {
|
|
const viewport = useRef<HTMLDivElement>(null),
|
|
canvas = useRef<HTMLCanvasElement>(null);
|
|
const [availableSize, setSize] = useState({ width: 640, height: 600 }),
|
|
[scrollOffset, setScrollOffset] = useState(0),
|
|
[anchor, setAnchor] = useState(position),
|
|
[fragment, setFragment] = useState(0),
|
|
[fonts, setFonts] = useState(0);
|
|
const [labelHover, setLabelHover] = useState<Range | null>(null);
|
|
const size = mode === "pages" && pageSize ? pageSize : availableSize;
|
|
const previousText = useRef(text);
|
|
const restoreScroll = useRef(mode === "scroll" ? position : null);
|
|
const drag = useRef<{ anchor: number; moved: boolean; selecting: boolean; pointer: number } | null>(null);
|
|
const measure = useMemo(() => {
|
|
if (typeof document === "undefined") return null;
|
|
const context = document.createElement("canvas").getContext("2d");
|
|
return context ? canvasMeasure(context) : null;
|
|
}, [fonts]);
|
|
const layout = useMemo<Layout | null>(() => {
|
|
if (!measure) return null;
|
|
return layoutText({
|
|
text,
|
|
width: size.width,
|
|
font: { family: fontFamily, size: fontSize },
|
|
annotations,
|
|
measure,
|
|
breakAt: mode === "pages" ? anchor : 0,
|
|
});
|
|
}, [text, annotations, size.width, fontSize, fontFamily, measure, mode, anchor]);
|
|
const pages = useMemo(() => (layout ? readingPages(layout, size.height, anchor) : []), [layout, size.height, anchor]);
|
|
// An expanded replacement can occupy several physical pages at one source
|
|
// offset. Retain its fragment while ordinary pages stay source-anchored.
|
|
const anchoredPages = pages.flatMap((page, index) => (page.start === anchor ? [index] : []));
|
|
const pageIndex = anchoredPages[Math.min(fragment, anchoredPages.length - 1)] ?? pageAt(pages, anchor),
|
|
page = pages[pageIndex];
|
|
const top = (mode === "pages" ? (page?.top ?? 0) : 0) + scrollOffset;
|
|
const end = mode === "pages" ? (page?.bottom ?? size.height) : (layout?.height ?? size.height);
|
|
const drawHeight = Math.max(0, Math.min(size.height, end - top));
|
|
function turnPage(index: number, clearSelection = true) {
|
|
const bounded = Math.max(0, Math.min(pages.length - 1, index)),
|
|
target = pages[bounded];
|
|
if (target) {
|
|
setFragment(pages.slice(0, bounded).filter((page) => page.start === target.start).length);
|
|
setAnchor(target.start);
|
|
onDismissTooltip();
|
|
if (clearSelection) onSelection({ anchor: target.start, focus: target.start });
|
|
if (viewport.current) viewport.current.scrollTop = 0;
|
|
setScrollOffset(0);
|
|
onHover(null);
|
|
setLabelHover(null);
|
|
}
|
|
}
|
|
useEffect(() => {
|
|
let closed = false;
|
|
void document.fonts?.ready.then(() => {
|
|
if (!closed) setFonts((value) => value + 1);
|
|
});
|
|
return () => {
|
|
closed = true;
|
|
};
|
|
}, []);
|
|
useLayoutEffect(() => {
|
|
if (!viewport.current) return;
|
|
const observer = new ResizeObserver(([entry]) => {
|
|
if (entry.contentRect.width <= 0 || entry.contentRect.height <= 0) {
|
|
onSize?.({ width: 0, height: 0 });
|
|
return;
|
|
}
|
|
const measured = {
|
|
width: Math.max(20, Math.floor(entry.contentRect.width)),
|
|
height: Math.max(1, Math.floor(entry.contentRect.height)),
|
|
};
|
|
setSize(measured);
|
|
onSize?.(measured);
|
|
});
|
|
observer.observe(viewport.current);
|
|
return () => observer.disconnect();
|
|
}, [onSize]);
|
|
useLayoutEffect(() => {
|
|
if (!canvas.current || !layout) return;
|
|
const ratio = Math.min(3, window.devicePixelRatio || 1);
|
|
const width = Math.ceil(size.width * ratio),
|
|
height = Math.ceil(size.height * ratio);
|
|
if (canvas.current.width !== width) canvas.current.width = width;
|
|
if (canvas.current.height !== height) canvas.current.height = height;
|
|
const context = canvas.current.getContext("2d");
|
|
if (!context) return;
|
|
context.resetTransform();
|
|
context.clearRect(0, 0, width, height);
|
|
context.scale(ratio, ratio);
|
|
drawText(context, layout, {
|
|
top,
|
|
height: drawHeight,
|
|
selection,
|
|
visible,
|
|
hover: labelHover ?? hover,
|
|
emphasis,
|
|
textColor,
|
|
accentColor,
|
|
});
|
|
}, [
|
|
layout,
|
|
top,
|
|
size.width,
|
|
size.height,
|
|
drawHeight,
|
|
selection,
|
|
visible,
|
|
hover,
|
|
labelHover,
|
|
emphasis,
|
|
textColor,
|
|
accentColor,
|
|
]);
|
|
useEffect(() => {
|
|
if (layout && pages.length) onWindow(readingWindow(layout, pages, pageIndex, mode, top, size.height));
|
|
}, [layout, pages, pageIndex, mode, top, size.height, onWindow]);
|
|
useLayoutEffect(() => {
|
|
const appended = text.startsWith(previousText.current) && text !== previousText.current;
|
|
previousText.current = text;
|
|
if (appended) return;
|
|
drag.current = null;
|
|
if (viewport.current) viewport.current.scrollTop = 0;
|
|
setScrollOffset(0);
|
|
setAnchor(position);
|
|
setFragment(0);
|
|
restoreScroll.current = mode === "scroll" ? position : null;
|
|
onHover(null);
|
|
setLabelHover(null);
|
|
}, [text, mode]);
|
|
useLayoutEffect(() => {
|
|
if (
|
|
layout &&
|
|
pages.length &&
|
|
readingWindow(layout, pages, pageIndex, mode, top, size.height).visible.start === position
|
|
)
|
|
return;
|
|
setAnchor(position);
|
|
setFragment(0);
|
|
if (mode === "scroll") restoreScroll.current = position;
|
|
}, [position]);
|
|
useLayoutEffect(() => {
|
|
// Keep the clicked word visible when its first results add overhead rows.
|
|
// Only explicit page navigation dismisses that click intent.
|
|
if (mode === "pages" && focus && page && (focus.start < page.start || focus.start >= page.end))
|
|
setAnchor(focus.start);
|
|
}, [layout, focus, mode]);
|
|
useLayoutEffect(() => {
|
|
if (mode !== "scroll" || restoreScroll.current === null || !layout || !viewport.current) return;
|
|
const row = layout.lines.find((line) => line.glyphs.some((glyph) => glyph.end > restoreScroll.current!));
|
|
const offset = row ? Math.min(row.y, Math.max(0, layout.height - size.height)) : 0;
|
|
viewport.current.scrollTop = offset ? offset + 24 : 0;
|
|
setScrollOffset(offset);
|
|
restoreScroll.current = null;
|
|
}, [layout, mode, size.height]);
|
|
function offset(event: { clientX: number; clientY: number }) {
|
|
if (!layout || !canvas.current) return 0;
|
|
const bounds = canvas.current.getBoundingClientRect();
|
|
return hitTest(layout, event.clientX - bounds.left, Math.min(end - 0.001, event.clientY - bounds.top + top));
|
|
}
|
|
function interaction(event: { clientX: number; clientY: number }) {
|
|
if (!layout || !canvas.current) return null;
|
|
const bounds = canvas.current.getBoundingClientRect();
|
|
const x = event.clientX - bounds.left,
|
|
y = Math.min(end, event.clientY - bounds.top + top);
|
|
if (y >= end || y < top) return null;
|
|
return textInteractionAt(layout, x, y, visible);
|
|
}
|
|
function focusedWord(event: { clientX: number; clientY: number }) {
|
|
return interaction(event)?.range ?? null;
|
|
}
|
|
const processingPaths = useMemo(() => {
|
|
const ranges = new Map<string, (typeof processing)[number]>();
|
|
for (const item of coalesceProcessing(processing, text)) {
|
|
const key = `${item.range.start}:${item.range.end}`,
|
|
previous = ranges.get(key);
|
|
ranges.set(key, previous ? { ...item, label: previous.label + ", " + item.label } : item);
|
|
}
|
|
return [...ranges.values()].map((item) => ({ ...item, path: layout ? rangeOutline(layout, item.range, 4) : "" }));
|
|
}, [layout, processing, text]);
|
|
|
|
const hoveredTips =
|
|
hover && !labelHover ? tooltips.filter((item) => item.trigger === "hover" && intersects(item, hover)) : [];
|
|
const clickedTips = focus ? tooltips.filter((item) => item.trigger === "click" && intersects(item, focus)) : [];
|
|
const tipRange = clickedTips.length ? focus : hoveredTips.length ? hover : null;
|
|
// Keep the last anchor available during the short hover-to-card crossing.
|
|
const lastTipRange = useRef<Range | null>(null);
|
|
if (tipRange) lastTipRange.current = tipRange;
|
|
const activeTips = clickedTips.length ? clickedTips : hoveredTips;
|
|
const tip: TooltipContent | null = activeTips.length
|
|
? {
|
|
key: activeTips.map((item) => item.id).join(":"),
|
|
trigger: clickedTips.length ? "click" : "hover",
|
|
items: activeTips,
|
|
}
|
|
: null;
|
|
const getTipAnchor = useCallback(() => {
|
|
if (!layout || !canvas.current || !lastTipRange.current) return null;
|
|
const rect = rangeRects(layout, lastTipRange.current).find(
|
|
(rect) => rect.y + rect.height > top && rect.y < top + drawHeight,
|
|
);
|
|
if (!rect) return null;
|
|
const bounds = canvas.current.getBoundingClientRect();
|
|
const y = bounds.top + Math.max(0, rect.y - top),
|
|
height = Math.min(rect.height, drawHeight - Math.max(0, rect.y - top));
|
|
if (y + height < 0 || y > window.innerHeight) return null;
|
|
return { x: bounds.left + rect.x, y, width: rect.width, height };
|
|
}, [layout, top, drawHeight]);
|
|
const selected = selectionRange(selection);
|
|
function openContextMenu(event?: { clientX: number; clientY: number }) {
|
|
if (!interactive || !onContextMenu || !layout || !canvas.current) return false;
|
|
const bounds = canvas.current.getBoundingClientRect();
|
|
const rectangles = rangeRects(layout, selected).filter(
|
|
(rect) => rect.y + rect.height > top && rect.y < top + drawHeight,
|
|
);
|
|
const insideSelection = event
|
|
? rectangles.some(
|
|
(rect) =>
|
|
event.clientX >= bounds.left + rect.x &&
|
|
event.clientX <= bounds.left + rect.x + rect.width &&
|
|
event.clientY >= bounds.top + rect.y - top &&
|
|
event.clientY <= bounds.top + rect.y - top + rect.height,
|
|
)
|
|
: selected.end > selected.start;
|
|
const target = contextTarget(
|
|
selection,
|
|
event ? focusedWord(event) : wordAt(text, Math.min(selection.focus, Math.max(0, text.length - 1))),
|
|
insideSelection,
|
|
);
|
|
if (!target) return false;
|
|
const rect = rectangles[0] ?? rangeRects(layout, target.range).find((rect) => rect.y + rect.height > top);
|
|
const anchor = event
|
|
? { x: event.clientX, y: event.clientY, width: 0, height: 0 }
|
|
: rect
|
|
? { ...rect, x: bounds.left + rect.x, y: bounds.top + rect.y - top }
|
|
: { x: bounds.left, y: bounds.top, width: 0, height: 0 };
|
|
canvas.current.focus({ preventScroll: true });
|
|
onDismissTooltip();
|
|
onHover(null);
|
|
onContextMenu({ ...target, anchor });
|
|
return true;
|
|
}
|
|
return (
|
|
<>
|
|
<Tooltip content={tip} getAnchor={getTipAnchor} dismiss={onDismissTooltip} />
|
|
<div
|
|
className="reading-surface"
|
|
data-view={mode}
|
|
data-source-start={page?.start}
|
|
data-source-end={page?.end}
|
|
ref={viewport}
|
|
onScroll={(event) => {
|
|
setScrollOffset(Math.max(0, event.currentTarget.scrollTop - (mode === "scroll" ? 24 : 0)));
|
|
onHover(null);
|
|
setLabelHover(null);
|
|
}}
|
|
>
|
|
<div className="reading-viewport" style={{ width: size.width, height: size.height, marginInline: "auto" }}>
|
|
<canvas
|
|
ref={canvas}
|
|
className="reading-canvas"
|
|
style={{ width: size.width, height: size.height }}
|
|
tabIndex={interactive ? 0 : -1}
|
|
role="textbox"
|
|
aria-readonly="true"
|
|
aria-label="Reading text"
|
|
aria-multiline="true"
|
|
aria-describedby="reading-canvas-help"
|
|
onPointerDown={(event) => {
|
|
if (!interactive || event.button !== 0 || !layout) return;
|
|
event.currentTarget.focus({ preventScroll: true });
|
|
event.currentTarget.setPointerCapture(event.pointerId);
|
|
if (!focusedWord(event)) onDismissTooltip();
|
|
const position = offset(event),
|
|
anchor = event.shiftKey ? selection.anchor : position;
|
|
drag.current = { anchor, moved: false, selecting: event.shiftKey, pointer: event.pointerId };
|
|
onSelection({ anchor, focus: position });
|
|
}}
|
|
onPointerMove={(event) => {
|
|
if (!interactive) return;
|
|
const active = drag.current;
|
|
if (!active) {
|
|
const target = interaction(event);
|
|
const range = target?.kind === "label" ? target.range : null;
|
|
setLabelHover((previous) =>
|
|
previous?.start === range?.start && previous?.end === range?.end ? previous : range,
|
|
);
|
|
onHover(target?.kind === "word" ? target.range : null);
|
|
return;
|
|
}
|
|
const focus = offset(event);
|
|
if (focus !== active.anchor) active.moved = true;
|
|
onSelection({ anchor: active.anchor, focus });
|
|
const bounds = viewport.current?.getBoundingClientRect();
|
|
if (mode === "scroll" && bounds && viewport.current) {
|
|
if (event.clientY < bounds.top + 30) viewport.current.scrollTop -= 18;
|
|
if (event.clientY > bounds.bottom - 30) viewport.current.scrollTop += 18;
|
|
}
|
|
}}
|
|
onPointerUp={(event) => {
|
|
const active = drag.current;
|
|
if (!active || active.pointer !== event.pointerId) return;
|
|
drag.current = null;
|
|
event.currentTarget.releasePointerCapture(event.pointerId);
|
|
if (!active.moved && !active.selecting) {
|
|
const range = focusedWord(event);
|
|
if (range) onFocus(range);
|
|
}
|
|
}}
|
|
onPointerCancel={() => {
|
|
drag.current = null;
|
|
onHover(null);
|
|
setLabelHover(null);
|
|
}}
|
|
onPointerLeave={() => {
|
|
if (!drag.current) {
|
|
onHover(null);
|
|
setLabelHover(null);
|
|
}
|
|
}}
|
|
onDoubleClick={(event) => {
|
|
if (!interactive) return;
|
|
const range = focusedWord(event);
|
|
if (range) {
|
|
onSelection({ anchor: range.start, focus: range.end });
|
|
}
|
|
}}
|
|
onContextMenu={(event) => {
|
|
if (openContextMenu(event)) event.preventDefault();
|
|
}}
|
|
onCopy={(event) => {
|
|
if (selected.end > selected.start) {
|
|
event.preventDefault();
|
|
event.clipboardData.setData("text/plain", text.slice(selected.start, selected.end));
|
|
}
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (!interactive || !layout) return;
|
|
if (event.key === "ContextMenu" || (event.shiftKey && event.key === "F10")) {
|
|
if (openContextMenu()) event.preventDefault();
|
|
return;
|
|
}
|
|
if (
|
|
(event.ctrlKey || event.metaKey) &&
|
|
event.key.toLowerCase() === "c" &&
|
|
selected.end > selected.start
|
|
) {
|
|
event.preventDefault();
|
|
void copyText(text.slice(selected.start, selected.end)).catch(() => {});
|
|
return;
|
|
}
|
|
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "a") {
|
|
event.preventDefault();
|
|
onSelection({ anchor: 0, focus: text.length });
|
|
return;
|
|
}
|
|
if (["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) {
|
|
event.preventDefault();
|
|
const next = moveSelection(layout, selection, event.key, event.shiftKey);
|
|
onSelection(next);
|
|
const line = layout.lines.find((row) =>
|
|
row.glyphs.some((glyph) => glyph.start <= next.focus && glyph.end >= next.focus),
|
|
);
|
|
if (line && viewport.current) {
|
|
if (mode === "pages" && (next.focus < page.start || next.focus >= page.end)) {
|
|
turnPage(pageAt(pages, next.focus), false);
|
|
} else if (line.y < top)
|
|
viewport.current.scrollTop = Math.max(0, line.y - (mode === "pages" ? page.top : 0) - 30);
|
|
else if (line.y + line.height > top + size.height)
|
|
viewport.current.scrollTop =
|
|
line.y + line.height - (mode === "pages" ? page.top : 0) - size.height + 30;
|
|
}
|
|
}
|
|
}}
|
|
/>
|
|
<svg className="reading-overlay" width={size.width} height={drawHeight} aria-hidden="true">
|
|
<g transform={`translate(0,${-top})`}>
|
|
{processingPaths.map(({ range, label, color, path }) => (
|
|
<path
|
|
key={`${range.start}:${range.end}`}
|
|
className="reading-processing"
|
|
d={path}
|
|
style={{ stroke: color }}
|
|
>
|
|
<title>{label}</title>
|
|
</path>
|
|
))}
|
|
</g>
|
|
</svg>
|
|
</div>
|
|
<div
|
|
aria-hidden="true"
|
|
style={{ height: mode === "scroll" ? Math.max(0, (layout?.height ?? 0) - size.height + 24) : 0 }}
|
|
/>
|
|
<p className="reading-sr" id="reading-canvas-help">
|
|
Drag to select text; double-click selects a word. Shift and arrow keys extend selection. Press Control or
|
|
Command C to copy. Original text follows.
|
|
</p>
|
|
<p className="reading-sr">{text}</p>
|
|
</div>
|
|
{mode === "pages" && (
|
|
<nav className="reading-pagination" aria-label="Reading pages">
|
|
<button disabled={pageIndex <= 0} onClick={() => turnPage(pageIndex - 1)}>
|
|
Previous page
|
|
</button>
|
|
<span role="status">
|
|
Page {pageIndex + 1} of {Math.max(1, pages.length)}
|
|
</span>
|
|
<button disabled={pageIndex >= pages.length - 1} onClick={() => turnPage(pageIndex + 1)}>
|
|
Next page
|
|
</button>
|
|
</nav>
|
|
)}
|
|
</>
|
|
);
|
|
}
|