Export template source from 488f7e229afed8f03fc3bfa4abe707da0c613460:examples/local-first/packages/language-reader-presentation
This commit is contained in:
@@ -0,0 +1,483 @@
|
||||
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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user