Export template source from 488f7e229afed8f03fc3bfa4abe707da0c613460:examples/local-first/packages/language-reader-presentation

This commit is contained in:
Quixos Template Publisher
2000-01-01 00:00:00 +00:00
commit 390d953b6f
62 changed files with 10396 additions and 0 deletions
+483
View File
@@ -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>
)}
</>
);
}