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(null), canvas = useRef(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(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(() => { 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(); 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(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 ( <>
{ setScrollOffset(Math.max(0, event.currentTarget.scrollTop - (mode === "scroll" ? 24 : 0))); onHover(null); setLabelHover(null); }} >
{ 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; } } }} />
{mode === "pages" && ( )} ); }