Export template source from 11bc3ae35b0034c113daca4c35d1b341aa31ef70: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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import { tooltipPosition } from "./tooltip-position.js";
|
||||
import type { Rect } from "./types.js";
|
||||
export type TooltipContent = {
|
||||
key: string;
|
||||
trigger: "hover" | "click";
|
||||
items: { id: string; label: string; text: string; color: string }[];
|
||||
};
|
||||
/** React content in the browser's top layer: ancestor canvas/scroll clipping and
|
||||
* transforms do not constrain it. A brief exit grace lets readers select/copy
|
||||
* hover content without delaying cancellation of model hover intent. */
|
||||
export function Tooltip({
|
||||
content,
|
||||
getAnchor,
|
||||
dismiss,
|
||||
}: {
|
||||
content: TooltipContent | null;
|
||||
getAnchor: () => Rect | null;
|
||||
dismiss: () => void;
|
||||
}) {
|
||||
const panel = useRef<HTMLDivElement>(null),
|
||||
inside = useRef(false),
|
||||
timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
// Only clear native selection owned by this tooltip. Canvas/source and other
|
||||
// UI selections survive dismissal. Hover notes remain selectable for copying.
|
||||
const clearOwnSelection = (element = panel.current) => {
|
||||
const root = element?.getRootNode() as (Document & { getSelection?: () => Selection | null }) | undefined;
|
||||
const selection = root?.getSelection?.() ?? window.getSelection();
|
||||
if (
|
||||
selection &&
|
||||
element &&
|
||||
((selection.anchorNode && element.contains(selection.anchorNode)) ||
|
||||
(selection.focusNode && element.contains(selection.focusNode)))
|
||||
)
|
||||
selection.removeAllRanges();
|
||||
};
|
||||
const hide = () => {
|
||||
clearOwnSelection();
|
||||
setShown(null);
|
||||
};
|
||||
const [shown, setShown] = useState(content);
|
||||
const cancel = () => {
|
||||
if (timer.current) clearTimeout(timer.current);
|
||||
timer.current = null;
|
||||
};
|
||||
useEffect(() => {
|
||||
cancel();
|
||||
if (content) setShown(content);
|
||||
else if (shown?.trigger === "click") hide();
|
||||
else if (!inside.current) timer.current = setTimeout(() => hide(), 180);
|
||||
return cancel;
|
||||
}, [content]);
|
||||
useEffect(() => {
|
||||
const close = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
cancel();
|
||||
inside.current = false;
|
||||
hide();
|
||||
dismiss();
|
||||
}
|
||||
};
|
||||
const outside = (event: PointerEvent) => {
|
||||
if (panel.current && !event.composedPath().includes(panel.current)) {
|
||||
cancel();
|
||||
inside.current = false;
|
||||
hide();
|
||||
dismiss();
|
||||
}
|
||||
};
|
||||
if (!shown) return;
|
||||
document.addEventListener("keydown", close);
|
||||
document.addEventListener("pointerdown", outside, true);
|
||||
return () => {
|
||||
document.removeEventListener("keydown", close);
|
||||
document.removeEventListener("pointerdown", outside, true);
|
||||
};
|
||||
}, [!!shown, dismiss]);
|
||||
useLayoutEffect(() => {
|
||||
const element = panel.current;
|
||||
if (!element || !shown) return;
|
||||
element.setAttribute("popover", "manual");
|
||||
element.showPopover();
|
||||
const position = () => {
|
||||
const anchor = getAnchor();
|
||||
if (!anchor) {
|
||||
element.hidePopover();
|
||||
return;
|
||||
}
|
||||
if (!element.matches(":popover-open")) element.showPopover();
|
||||
const box = element.getBoundingClientRect();
|
||||
const pos = tooltipPosition(anchor, box.width, box.height, {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
});
|
||||
element.style.left = pos.left + "px";
|
||||
element.style.top = pos.top + "px";
|
||||
element.style.setProperty("--tooltip-arrow", pos.arrow + "px");
|
||||
element.dataset.side = pos.side;
|
||||
};
|
||||
position();
|
||||
const observer = new ResizeObserver(position);
|
||||
observer.observe(element);
|
||||
window.addEventListener("resize", position);
|
||||
document.addEventListener("scroll", position, true);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
window.removeEventListener("resize", position);
|
||||
document.removeEventListener("scroll", position, true);
|
||||
if (element.matches(":popover-open")) element.hidePopover();
|
||||
};
|
||||
}, [shown, getAnchor]);
|
||||
useEffect(() => {
|
||||
const element = panel.current;
|
||||
return () => clearOwnSelection(element);
|
||||
}, [shown?.key]);
|
||||
if (!shown) return null;
|
||||
return (
|
||||
<div
|
||||
ref={panel}
|
||||
role="tooltip"
|
||||
aria-label="Reading annotation"
|
||||
className="lr-annotation-tooltip"
|
||||
onPointerEnter={() => {
|
||||
inside.current = true;
|
||||
cancel();
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
inside.current = false;
|
||||
if (!content) {
|
||||
cancel();
|
||||
hide();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{shown.trigger === "click" && (
|
||||
<button
|
||||
aria-label="Close annotation"
|
||||
className="lr-tooltip-close"
|
||||
onClick={() => {
|
||||
cancel();
|
||||
inside.current = false;
|
||||
hide();
|
||||
dismiss();
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
<div className="lr-tooltip-content">
|
||||
{shown.items.map((item) => (
|
||||
<section key={item.id} style={{ borderColor: item.color }}>
|
||||
<small>{item.label}</small>
|
||||
<p>{item.text}</p>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import { fontString } from "./measure.js";
|
||||
import { selectionRects, type Selection } from "./selection.js";
|
||||
import type { AnnotationBox, Layout } from "./types.js";
|
||||
|
||||
/** Keep alignment marks beside the label's ink. Their lengths follow the
|
||||
* overhang relative to the projected source, without striking through text. */
|
||||
export function labelGuideSegments(box: AnnotationBox) {
|
||||
if (!box.guide) return [];
|
||||
const gap = box.font.size * 0.15;
|
||||
const length = (overhang: number) => Math.min(box.font.size * 0.65, Math.max(box.font.size * 0.25, overhang));
|
||||
const middle = box.y + box.height / 2;
|
||||
const segments: { start: number; end: number; y: number; cap: number }[] = [];
|
||||
const cap = box.font.size * 0.15 * (box.annotation.placement === "below" ? -1 : 1);
|
||||
const left = box.guide.start - box.x;
|
||||
const right = box.x + box.width - box.guide.end;
|
||||
if (left > 0) segments.push({ start: box.x - gap - length(left), end: box.x - gap, y: middle, cap });
|
||||
if (right > 0)
|
||||
segments.push({ start: box.x + box.width + gap, end: box.x + box.width + gap + length(right), y: middle, cap });
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** Every pattern fits the same band, so appearance changes cannot repaginate. */
|
||||
export function drawUnderline(context: CanvasRenderingContext2D, box: AnnotationBox) {
|
||||
if (box.width <= 0) return;
|
||||
if (!box.annotation.underlinePattern || box.annotation.underlinePattern === "solid") {
|
||||
context.fillRect(box.x, box.y, box.width, box.height);
|
||||
return;
|
||||
}
|
||||
context.save();
|
||||
context.beginPath();
|
||||
context.rect(box.x, box.y, box.width, box.height);
|
||||
context.clip();
|
||||
context.strokeStyle = box.annotation.color;
|
||||
context.lineWidth = 1.5;
|
||||
context.lineCap = "round";
|
||||
context.setLineDash(box.annotation.underlinePattern === "dashed" ? [4, 3] : []);
|
||||
context.lineDashOffset = 0;
|
||||
context.beginPath();
|
||||
const middle = box.y + box.height / 2;
|
||||
context.moveTo(box.x, middle);
|
||||
if (box.annotation.underlinePattern === "dashed") context.lineTo(box.x + box.width, middle);
|
||||
else {
|
||||
for (let x = 0.5; x < box.width; x += 0.5) context.lineTo(box.x + x, middle + Math.sin((x * Math.PI) / 3) * 0.7);
|
||||
context.lineTo(box.x + box.width, middle + Math.sin((box.width * Math.PI) / 3) * 0.7);
|
||||
}
|
||||
context.stroke();
|
||||
context.restore();
|
||||
}
|
||||
export function drawText(
|
||||
context: CanvasRenderingContext2D,
|
||||
layout: Layout,
|
||||
{
|
||||
top = 0,
|
||||
height = layout.height,
|
||||
selection = { anchor: 0, focus: 0 },
|
||||
visible = () => true,
|
||||
hover = null,
|
||||
emphasis = null,
|
||||
textColor = "#252923",
|
||||
accentColor = "#39745b",
|
||||
}: {
|
||||
textColor?: string;
|
||||
accentColor?: string;
|
||||
top?: number;
|
||||
height?: number;
|
||||
selection?: Selection;
|
||||
visible?: (id: string) => boolean;
|
||||
hover?: { start: number; end: number } | null;
|
||||
emphasis?: { start: number; end: number } | null;
|
||||
} = {},
|
||||
) {
|
||||
// The pointer's current source/label wins over a sidebar reference. Sidebar
|
||||
// panels can disappear before a pointer-leave event is delivered.
|
||||
const reference = hover ?? emphasis;
|
||||
context.save();
|
||||
context.translate(0, -top);
|
||||
context.beginPath();
|
||||
context.rect(0, top, layout.width, height);
|
||||
context.clip();
|
||||
for (const line of layout.lines) {
|
||||
const lineTop = Math.min(line.y, ...line.overhead.map((box) => box.y));
|
||||
if (line.y + line.height < top || lineTop >= top + height) continue;
|
||||
for (const box of line.decorations) {
|
||||
if (box.annotation.style === "color" || box.annotation.style === "replace") continue;
|
||||
if (!visible(box.annotation.id)) continue;
|
||||
context.fillStyle = box.annotation.color;
|
||||
context.globalAlpha = box.annotation.style === "highlight" ? 0.22 : 0.85;
|
||||
if (box.annotation.style === "underline") drawUnderline(context, box);
|
||||
else context.fillRect(box.x, box.y, box.width, box.height);
|
||||
}
|
||||
context.globalAlpha = 1;
|
||||
context.font = fontString(layout.font);
|
||||
context.fillStyle = textColor;
|
||||
for (const glyph of line.glyphs) {
|
||||
context.font = fontString(glyph.font ?? layout.font);
|
||||
context.fillStyle = textColor;
|
||||
for (const box of line.decorations)
|
||||
if (
|
||||
(box.annotation.style === "color" || box.annotation.style === "replace") &&
|
||||
visible(box.annotation.id) &&
|
||||
box.annotation.start < glyph.end &&
|
||||
box.annotation.end > glyph.start
|
||||
)
|
||||
context.fillStyle = box.annotation.color;
|
||||
if (reference && glyph.start < reference.end && glyph.end > reference.start && /[\p{L}\p{N}]/u.test(glyph.text))
|
||||
context.fillStyle = accentColor;
|
||||
context.fillText(glyph.text === "\t" ? " " : glyph.text, glyph.x, glyph.baseline);
|
||||
}
|
||||
for (const box of line.overhead) {
|
||||
if (!visible(box.annotation.id)) continue;
|
||||
const guides = labelGuideSegments(box);
|
||||
if (guides.length) {
|
||||
context.save();
|
||||
context.strokeStyle = box.annotation.color;
|
||||
context.globalAlpha = 0.14;
|
||||
context.lineWidth = 1;
|
||||
context.beginPath();
|
||||
for (const guide of guides) {
|
||||
context.moveTo(guide.start, guide.y);
|
||||
context.lineTo(guide.end, guide.y);
|
||||
const outer = guide.end < box.x ? guide.start : guide.end;
|
||||
context.moveTo(outer, guide.y);
|
||||
context.lineTo(outer, guide.y + guide.cap);
|
||||
}
|
||||
context.stroke();
|
||||
context.restore();
|
||||
}
|
||||
context.save();
|
||||
context.beginPath();
|
||||
context.rect(box.x, box.y, box.width, box.height);
|
||||
context.clip();
|
||||
context.font = fontString(box.font);
|
||||
context.fillStyle = box.annotation.color;
|
||||
context.fillText(box.lines[0], box.x, box.baseline);
|
||||
context.restore();
|
||||
}
|
||||
}
|
||||
context.fillStyle = "#6c90c6";
|
||||
context.globalAlpha = 0.24;
|
||||
for (const rect of selectionRects(layout, selection)) context.fillRect(rect.x, rect.y, rect.width, rect.height);
|
||||
context.restore();
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { hoverWordAt } from "./text.js";
|
||||
import type { Layout, Range } from "./types.js";
|
||||
|
||||
export type TextInteraction = { kind: "label" | "word"; range: Range };
|
||||
/** Labels emphasize their reference without masquerading as a word hover.
|
||||
* Hit only visible label/text ink; annotation padding is not another word. */
|
||||
export function textInteractionAt(
|
||||
layout: Layout,
|
||||
x: number,
|
||||
y: number,
|
||||
visible: (id: string) => boolean,
|
||||
): TextInteraction | null {
|
||||
let low = 0,
|
||||
high = layout.lines.length;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
if (layout.lines[middle].y + layout.lines[middle].height < y) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
const row = layout.lines[low];
|
||||
const label = row?.overhead.find(
|
||||
(box) =>
|
||||
visible(box.annotation.id) && x >= box.x && x <= box.x + box.width && y >= box.y && y <= box.y + box.height,
|
||||
);
|
||||
if (label) return { kind: "label", range: { start: label.annotation.start, end: label.annotation.end } };
|
||||
const glyph = row?.glyphs.find(
|
||||
(unit) => y >= unit.y && y <= unit.y + unit.height + 8 && x >= unit.x && x <= unit.x + unit.width,
|
||||
);
|
||||
const range = glyph ? hoverWordAt(layout.text, glyph.start) : null;
|
||||
return range ? { kind: "word", range } : null;
|
||||
}
|
||||
@@ -0,0 +1,562 @@
|
||||
import { graphemes } from "./text.js";
|
||||
import { lineSpans } from "./line-breaks.js";
|
||||
import type { Annotation, AnnotationBox, Font, Glyph, Layout, Line, Measure, Metrics } from "./types.js";
|
||||
|
||||
const gap = 5;
|
||||
type Cursor = { row: number; x: number };
|
||||
type Unit = ReturnType<typeof graphemes>[number] & { metrics: Metrics; font: Font; left: number; right: number };
|
||||
type Label = { annotation: Annotation; font: Font; first: number; last: number; width: number };
|
||||
type Group = { feature: string; first: number; last: number; labels: Label[]; width: number };
|
||||
type Fragment = { row: number; x: number; text: string; metrics: Metrics; label: Label };
|
||||
const lane = (annotation: Annotation) => `${annotation.placement ?? "above"}:${annotation.feature}`;
|
||||
|
||||
function lowerBound<T>(items: T[], before: (item: T) => boolean): number {
|
||||
let low = 0,
|
||||
high = items.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >>> 1;
|
||||
if (before(items[mid])) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
return low;
|
||||
}
|
||||
|
||||
// Padding participates in the same horizontal flow as text, even across lines.
|
||||
// Keep x == width on the preceding row so an explicit newline is not doubled.
|
||||
function advance(cursor: Cursor, amount: number, width: number): Cursor {
|
||||
if (amount <= width - cursor.x) return { row: cursor.row, x: cursor.x + amount };
|
||||
const remaining = amount - (width - cursor.x);
|
||||
const rows = Math.ceil(remaining / width);
|
||||
return { row: cursor.row + rows, x: remaining - (rows - 1) * width };
|
||||
}
|
||||
const position = (cursor: Cursor, width: number) => cursor.row * width + cursor.x;
|
||||
|
||||
// Point updates/range sums keep width reservations cheap for large documents.
|
||||
class Widths {
|
||||
private tree: Float64Array;
|
||||
constructor(units: Unit[]) {
|
||||
this.tree = new Float64Array(units.length + 1);
|
||||
units.forEach((unit, index) => this.add(index, unit.metrics.width));
|
||||
}
|
||||
add(index: number, amount: number) {
|
||||
for (let i = index + 1; i < this.tree.length; i += i & -i) this.tree[i] += amount;
|
||||
}
|
||||
private prefix(end: number): number {
|
||||
let total = 0;
|
||||
for (let i = end; i > 0; i -= i & -i) total += this.tree[i];
|
||||
return total;
|
||||
}
|
||||
range(first: number, last: number) {
|
||||
return this.prefix(last + 1) - this.prefix(first);
|
||||
}
|
||||
}
|
||||
|
||||
function flowLabel(label: Label, start: Cursor, width: number, measure: Measure) {
|
||||
const margin = Math.min(gap, width / 4);
|
||||
let cursor = advance(start, margin, width),
|
||||
row = "",
|
||||
rowX = cursor.x;
|
||||
const fragments: Fragment[] = [];
|
||||
function flush() {
|
||||
if (row) fragments.push({ row: cursor.row, x: rowX, text: row, metrics: measure(row, label.font), label });
|
||||
row = "";
|
||||
rowX = cursor.x;
|
||||
}
|
||||
function newline() {
|
||||
flush();
|
||||
cursor = { row: cursor.row + 1, x: margin };
|
||||
rowX = cursor.x;
|
||||
}
|
||||
function piece(text: string) {
|
||||
const metrics = measure(row + text, label.font);
|
||||
if (rowX + metrics.width > width - margin + 0.001) newline();
|
||||
const own = measure(text, label.font);
|
||||
// A single grapheme may be wider than a pathological viewport. Clip that
|
||||
// grapheme instead of looping or inventing another vertical annotation row.
|
||||
if (!row && own.width > width - margin * 2) {
|
||||
rowX = 0;
|
||||
cursor.x = width;
|
||||
fragments.push({ row: cursor.row, x: 0, text, metrics: { ...own, width }, label });
|
||||
rowX = cursor.x;
|
||||
return;
|
||||
}
|
||||
row += text;
|
||||
cursor.x = rowX + measure(row, label.font).width;
|
||||
}
|
||||
for (const span of lineSpans(label.annotation.text)) {
|
||||
const token = label.annotation.text.slice(span.start, span.end);
|
||||
const content = token.replace(/[\r\n]+$/u, "");
|
||||
if (measure(content, label.font).width <= width - margin * 2) piece(content);
|
||||
else {
|
||||
for (const chunk of lineSpans(content, true)) {
|
||||
const fragment = content.slice(chunk.start, chunk.end);
|
||||
if (measure(fragment, label.font).width <= width - margin * 2) piece(fragment);
|
||||
else for (const unit of graphemes(fragment)) piece(unit.text);
|
||||
}
|
||||
}
|
||||
if (content.length < token.length) newline();
|
||||
}
|
||||
flush();
|
||||
return { fragments, cursor: advance(cursor, margin, width) };
|
||||
}
|
||||
|
||||
function flowGroup(group: Group, start: Cursor, width: number, measure: Measure) {
|
||||
let cursor = start;
|
||||
const fragments: Fragment[] = [];
|
||||
for (const label of group.labels) {
|
||||
const flow = flowLabel(label, cursor, width, measure);
|
||||
cursor = flow.cursor;
|
||||
fragments.push(...flow.fragments);
|
||||
}
|
||||
return { fragments, cursor };
|
||||
}
|
||||
|
||||
/** Fluxtapose's shared source/annotation flow: one fixed row per feature on
|
||||
* each physical text line. Labels ask for horizontal source padding and wrap
|
||||
* through subsequent lines; collisions never create extra annotation rows.
|
||||
*/
|
||||
export function layoutText({
|
||||
text,
|
||||
width,
|
||||
annotations = [],
|
||||
font = { family: '"Noto Serif CJK SC", "Songti SC", serif', size: 23 },
|
||||
measure,
|
||||
breakAt = 0,
|
||||
}: {
|
||||
text: string;
|
||||
width: number;
|
||||
annotations?: Annotation[];
|
||||
font?: Font;
|
||||
measure: Measure;
|
||||
breakAt?: number;
|
||||
}): Layout {
|
||||
if (!Number.isFinite(width) || width <= 0 || !Number.isFinite(font.size) || font.size <= 0)
|
||||
throw Error("Invalid text geometry");
|
||||
const valid = annotations.filter(
|
||||
(a) =>
|
||||
Number.isInteger(a.start) && Number.isInteger(a.end) && a.start >= 0 && a.end <= text.length && a.start < a.end,
|
||||
);
|
||||
const replacements = valid
|
||||
.filter((a) => a.style === "replace")
|
||||
.sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0) || a.start - b.start);
|
||||
const accepted: Annotation[] = [];
|
||||
for (const item of replacements)
|
||||
if (!accepted.some((a) => a.start < item.end && a.end > item.start)) accepted.push(item);
|
||||
accepted.sort((a, b) => a.start - b.start);
|
||||
const source = graphemes(text),
|
||||
units: Unit[] = [];
|
||||
let replacementIndex = 0,
|
||||
formatIndex = 0;
|
||||
const formats = valid.filter((a) => a.style === "bold" || a.style === "italic").sort((a, b) => a.start - b.start);
|
||||
let activeFormats: Annotation[] = [];
|
||||
for (let index = 0; index < source.length; index++) {
|
||||
const unit = source[index],
|
||||
replacement = accepted[replacementIndex];
|
||||
if (replacement && unit.end > replacement.start) {
|
||||
if (unit.start >= replacement.end) replacementIndex++;
|
||||
else {
|
||||
const styled: Font = { ...font };
|
||||
for (const item of formats)
|
||||
if (item.start < replacement.end && item.end > replacement.start) {
|
||||
if (item.style === "bold") styled.weight = "bold";
|
||||
if (item.style === "italic") styled.italic = true;
|
||||
}
|
||||
const measured = measure(replacement.text, styled);
|
||||
const pieces =
|
||||
measured.width <= width && !/[\r\n]/.test(replacement.text)
|
||||
? [{ text: replacement.text }]
|
||||
: graphemes(replacement.text);
|
||||
for (const piece of pieces)
|
||||
units.push({
|
||||
start: replacement.start,
|
||||
end: replacement.end,
|
||||
text: piece.text,
|
||||
left: 0,
|
||||
right: 0,
|
||||
font: styled,
|
||||
metrics: measure(piece.text, styled),
|
||||
});
|
||||
while (index + 1 < source.length && source[index + 1].start < replacement.end) index++;
|
||||
replacementIndex++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const styled: Font = { ...font };
|
||||
while (formatIndex < formats.length && formats[formatIndex].start < unit.end)
|
||||
activeFormats.push(formats[formatIndex++]);
|
||||
activeFormats = activeFormats.filter((a) => a.end > unit.start);
|
||||
for (const item of activeFormats) {
|
||||
if (item.style === "bold") styled.weight = "bold";
|
||||
if (item.style === "italic") styled.italic = true;
|
||||
}
|
||||
units.push({
|
||||
...unit,
|
||||
left: 0,
|
||||
right: 0,
|
||||
font: styled,
|
||||
metrics: measure(/[\r\n]/.test(unit.text) ? "" : unit.text === "\t" ? " " : unit.text, styled),
|
||||
});
|
||||
}
|
||||
const byFeature = new Map<string, Label[]>();
|
||||
const priorities = new Map<string, number>();
|
||||
for (const annotation of valid) {
|
||||
if (annotation.style !== "overhead" || !annotation.text) continue;
|
||||
let first = lowerBound(units, (unit) => unit.end <= annotation.start);
|
||||
let last = lowerBound(units, (unit) => unit.start < annotation.end) - 1;
|
||||
while (first <= last && /[\r\n]/.test(units[first].text)) first++;
|
||||
while (last >= first && /[\r\n]/.test(units[last].text)) last--;
|
||||
if (first > last) continue;
|
||||
const labelFont = { family: annotation.fontFamily || font.family, size: annotation.fontSize ?? 13 };
|
||||
const label = {
|
||||
annotation,
|
||||
font: labelFont,
|
||||
first,
|
||||
last,
|
||||
width: measure(annotation.text.replace(/\r\n|[\r\n]/g, " "), labelFont).width + Math.min(gap * 2, width / 2),
|
||||
};
|
||||
const feature = lane(annotation);
|
||||
const labels = byFeature.get(feature) ?? [];
|
||||
labels.push(label);
|
||||
byFeature.set(feature, labels);
|
||||
priorities.set(feature, Math.min(priorities.get(feature) ?? Infinity, annotation.priority ?? 0));
|
||||
}
|
||||
const features = [...byFeature.keys()].sort((a, b) => priorities.get(a)! - priorities.get(b)! || a.localeCompare(b));
|
||||
const groups: Group[] = [];
|
||||
for (const [feature, labels] of byFeature) {
|
||||
labels.sort((a, b) => a.first - b.first || a.last - b.last || a.annotation.id.localeCompare(b.annotation.id));
|
||||
let current: Group | undefined;
|
||||
for (const label of labels) {
|
||||
// Conflicting ranges from one source share a horizontal run in source
|
||||
// order. They never get silently promoted to a second feature row.
|
||||
if (!current || label.first > current.last) {
|
||||
current = { feature, first: label.first, last: label.last, labels: [], width: 0 };
|
||||
groups.push(current);
|
||||
}
|
||||
current.last = Math.max(current.last, label.last);
|
||||
current.labels.push(label);
|
||||
current.width += label.width;
|
||||
}
|
||||
}
|
||||
// Punctuation and brackets can glue neighboring graphemes even when word
|
||||
// segmentation separates them. Move annotation reservations to the outside
|
||||
// of those small spans, so padding cannot push a closing mark onto a new row.
|
||||
// Ordinary letters may split only as an emergency; word/label preferences
|
||||
// below still keep them together whenever their reserved width fits.
|
||||
const displayed = units.map((unit) => unit.text).join("");
|
||||
const displayStarts: number[] = [];
|
||||
let displayOffset = 0;
|
||||
for (const unit of units) {
|
||||
displayStarts.push(displayOffset);
|
||||
displayOffset += unit.text.length;
|
||||
}
|
||||
const glue = new Map<number, number>(),
|
||||
glueEnds = new Map<number, number>();
|
||||
const gluedSpans: { first: number; last: number }[] = [];
|
||||
for (const span of lineSpans(displayed, true)) {
|
||||
const first = lowerBound(displayStarts, (start) => start < span.start);
|
||||
let last = lowerBound(displayStarts, (start) => start < span.end) - 1;
|
||||
while (last > first && /^[^\S\r\n]+$/u.test(units[last].text)) last--;
|
||||
if (last <= first || units.slice(first, last + 1).some((unit) => /[\r\n]/u.test(unit.text))) continue;
|
||||
const ink = units.slice(first, last + 1).reduce((total, unit) => total + unit.metrics.width, 0);
|
||||
if (ink > width) continue; // A pathological viewport still needs emergency clipping.
|
||||
gluedSpans.push({ first, last });
|
||||
for (let index = first; index <= last; index++) glueEnds.set(index, last);
|
||||
glue.set(first, last);
|
||||
}
|
||||
// Adjacent labels from one feature within an inseparable source span must
|
||||
// share a flow too. Moving their padding outside that span must not cause
|
||||
// independent label runs to collide (e.g. a word label and punctuation label).
|
||||
const joined: Group[] = [];
|
||||
for (const group of groups) {
|
||||
const previous = joined.at(-1);
|
||||
if (previous?.feature === group.feature && (glueEnds.get(previous.last) ?? previous.last) >= group.first) {
|
||||
previous.last = Math.max(previous.last, group.last);
|
||||
previous.labels.push(...group.labels);
|
||||
previous.width += group.width;
|
||||
} else joined.push(group);
|
||||
}
|
||||
groups.splice(0, groups.length, ...joined);
|
||||
const widths = new Widths(units);
|
||||
// Reserve short ranges first, so sentence labels can reuse room requested by
|
||||
// word labels. Padding surrounds the range rather than stretching its glyphs.
|
||||
for (const group of [...groups].sort((a, b) => a.last - a.first - (b.last - b.first))) {
|
||||
const extra = Math.max(0, group.width - widths.range(group.first, group.last)) / 2;
|
||||
units[group.first].left += extra;
|
||||
units[group.last].right += extra;
|
||||
widths.add(group.first, extra);
|
||||
widths.add(group.last, extra);
|
||||
}
|
||||
for (const { first, last } of gluedSpans) {
|
||||
let left = 0,
|
||||
right = 0;
|
||||
for (let index = first; index <= last; index++) {
|
||||
const unit = units[index];
|
||||
left += unit.left;
|
||||
right += unit.right;
|
||||
widths.add(index, -unit.left - unit.right);
|
||||
unit.left = unit.right = 0;
|
||||
}
|
||||
units[first].left = left;
|
||||
units[last].right = right;
|
||||
widths.add(first, left);
|
||||
widths.add(last, right);
|
||||
}
|
||||
// Prefer word boundaries, and keep a short overhead label with its entire
|
||||
// source span. Oversized words/labels still flow across physical lines.
|
||||
const keep = new Map<number, number>();
|
||||
const legalStarts = new Map<number, number>(),
|
||||
legalEnds = new Map<number, number>();
|
||||
for (const span of lineSpans(displayed)) {
|
||||
const first = lowerBound(displayStarts, (start) => start < span.start);
|
||||
let last = lowerBound(displayStarts, (start) => start < span.end) - 1;
|
||||
while (last >= first && /^[^\S\r\n]+$/u.test(units[last].text)) last--;
|
||||
for (let index = first; index <= last; index++) {
|
||||
legalStarts.set(index, first);
|
||||
legalEnds.set(index, last);
|
||||
}
|
||||
}
|
||||
const remember = (start: number, end: number) => {
|
||||
let first = lowerBound(units, (u) => u.end <= start),
|
||||
last = lowerBound(units, (u) => u.start < end) - 1;
|
||||
first = legalStarts.get(first) ?? first;
|
||||
last = legalEnds.get(last) ?? last;
|
||||
if (
|
||||
last >= first &&
|
||||
!units.slice(first, last + 1).some((u) => /[\r\n]/.test(u.text)) &&
|
||||
widths.range(first, last) <= width
|
||||
)
|
||||
keep.set(first, Math.max(keep.get(first) ?? first, last));
|
||||
};
|
||||
for (const word of new Intl.Segmenter(undefined, { granularity: "word" }).segment(text))
|
||||
if (word.isWordLike) remember(word.index, word.index + word.segment.length);
|
||||
for (const span of lineSpans(displayed)) {
|
||||
const first = lowerBound(displayStarts, (start) => start < span.start);
|
||||
let last = lowerBound(displayStarts, (start) => start < span.end) - 1;
|
||||
while (last >= first && /^[^\S\r\n]+$/u.test(units[last].text)) last--;
|
||||
if (last >= first) remember(units[first].start, units[last].end);
|
||||
}
|
||||
for (const group of groups)
|
||||
if (group.width <= width && group.labels.every((l) => !/[\r\n]/.test(l.annotation.text)))
|
||||
remember(units[group.first].start, units[group.last].end);
|
||||
const closing = new Map<number, Group[]>();
|
||||
for (const group of groups) {
|
||||
const end = glueEnds.get(group.last) ?? group.last;
|
||||
const list = closing.get(end) ?? [];
|
||||
list.push(group);
|
||||
closing.set(end, list);
|
||||
}
|
||||
const starts: Cursor[] = [],
|
||||
drawn: Cursor[] = [],
|
||||
rows: Glyph[][] = [[]],
|
||||
fragments: Fragment[] = [];
|
||||
let cursor: Cursor = { row: 0, x: 0 };
|
||||
const ensureRow = (row: number) => {
|
||||
while (rows.length <= row) rows.push([]);
|
||||
};
|
||||
for (const [index, unit] of units.entries()) {
|
||||
if (unit.start === breakAt && breakAt > 0 && cursor.x > 0) cursor = { row: cursor.row + 1, x: 0 };
|
||||
if (/[\r\n]/.test(unit.text)) {
|
||||
starts[index] = { ...cursor };
|
||||
cursor = { row: cursor.row + 1, x: 0 };
|
||||
ensureRow(cursor.row);
|
||||
continue;
|
||||
}
|
||||
// Collapse indentation and separators that would overflow visually. Keep
|
||||
// the canonical source and its UTF-16 offsets intact for selection/copy.
|
||||
let collapsed =
|
||||
/^[^\S\r\n]+$/u.test(unit.text) && (cursor.x === 0 || cursor.x + unit.metrics.width > width + 0.001);
|
||||
const last = keep.get(index);
|
||||
if (!collapsed && last !== undefined && cursor.x > 0 && cursor.x + widths.range(index, last) > width + 0.001)
|
||||
cursor = { row: cursor.row + 1, x: 0 };
|
||||
// When even the unpadded glyph needs to wrap, its annotation reservation
|
||||
// starts on that next line too. A short label cannot linger on the old line.
|
||||
if (!collapsed && cursor.x > 0 && cursor.x + Math.min(width, unit.metrics.width) > width + 0.001)
|
||||
cursor = { row: cursor.row + 1, x: 0 };
|
||||
// An annotation can move a whole span beginning with a separator to the
|
||||
// next row; that separator must collapse there too.
|
||||
collapsed ||= cursor.x === 0 && /^[^\S\r\n]+$/u.test(unit.text);
|
||||
starts[index] = { ...cursor };
|
||||
cursor = advance(cursor, collapsed ? 0 : unit.left, width);
|
||||
const glued = glue.get(index);
|
||||
if (glued !== undefined && cursor.x > 0) {
|
||||
const ink = units.slice(index, glued + 1).reduce((total, unit) => total + unit.metrics.width, 0);
|
||||
if (cursor.x + ink > width + 0.001) cursor = { row: cursor.row + 1, x: 0 };
|
||||
}
|
||||
const drawnWidth = collapsed ? 0 : Math.min(width, unit.metrics.width);
|
||||
if (cursor.x > 0 && cursor.x + drawnWidth > width + 0.001) cursor = { row: cursor.row + 1, x: 0 };
|
||||
ensureRow(cursor.row);
|
||||
const glyphRow = cursor.row;
|
||||
drawn[index] = { ...cursor };
|
||||
const glyph: Glyph = {
|
||||
...unit,
|
||||
x: cursor.x,
|
||||
y: 0,
|
||||
width: unit.metrics.width,
|
||||
height: unit.metrics.ascent + unit.metrics.descent,
|
||||
baseline: 0,
|
||||
advance: drawnWidth,
|
||||
};
|
||||
if (!collapsed) rows[cursor.row].push(glyph);
|
||||
cursor = advance(cursor, drawnWidth + (collapsed ? 0 : unit.right), width);
|
||||
const ending = closing.get(index) ?? [];
|
||||
// Enforce wrapped label capacity when its source range closes. This is a
|
||||
// forward constraint, so padding converges in one source pass, without the
|
||||
// original ten-try limit or an arbitrary collision fallback.
|
||||
for (const group of ending) {
|
||||
const flow = flowGroup(group, starts[group.first], width, measure);
|
||||
if (position(flow.cursor, width) > position(cursor, width)) cursor = flow.cursor;
|
||||
}
|
||||
for (const group of ending) {
|
||||
const start = starts[group.first];
|
||||
// Center over the actual source ink, not the enclosing reservations:
|
||||
// nested word labels can give the two ends different amounts of padding.
|
||||
const firstInk = drawn[group.first],
|
||||
lastInk = drawn[group.last];
|
||||
const desired =
|
||||
(position(firstInk, width) + position(lastInk, width) + units[group.last].metrics.width - group.width) / 2;
|
||||
const candidate = advance(start, Math.max(0, desired - position(start, width)), width);
|
||||
const centered = flowGroup(group, candidate, width, measure);
|
||||
// Word wrapping can use more room than the measured label width. Find
|
||||
// the nearest centered start that still fits the reserved source band.
|
||||
let flow = centered;
|
||||
if (position(flow.cursor, width) > position(cursor, width) + 0.001) {
|
||||
let low = 0,
|
||||
high = Math.max(0, desired - position(start, width));
|
||||
flow = flowGroup(group, start, width, measure);
|
||||
for (let attempt = 0; attempt < 12; attempt++) {
|
||||
const middle = (low + high) / 2;
|
||||
const trial = flowGroup(group, advance(start, middle, width), width, measure);
|
||||
if (position(trial.cursor, width) <= position(cursor, width) + 0.001) {
|
||||
low = middle;
|
||||
flow = trial;
|
||||
} else high = middle;
|
||||
}
|
||||
}
|
||||
fragments.push(...flow.fragments);
|
||||
}
|
||||
glyph.advance = Math.max(drawnWidth, (cursor.row === glyphRow ? cursor.x : width) - glyph.x);
|
||||
ensureRow(cursor.row);
|
||||
}
|
||||
const byRow = new Map<number, Fragment[]>();
|
||||
for (const fragment of fragments) {
|
||||
ensureRow(fragment.row);
|
||||
const list = byRow.get(fragment.row) ?? [];
|
||||
list.push(fragment);
|
||||
byRow.set(fragment.row, list);
|
||||
}
|
||||
// Decoration ranges use the same final source geometry as selection.
|
||||
const decorationsByRow = new Map<number, Annotation[]>();
|
||||
const populated = rows.map((glyphs, index) => ({ glyphs, index })).filter((row) => row.glyphs.length);
|
||||
for (const annotation of [
|
||||
...valid.filter((a) => ["highlight", "underline", "color"].includes(a.style)),
|
||||
...accepted,
|
||||
]) {
|
||||
let index = lowerBound(populated, (row) => row.glyphs.at(-1)!.end <= annotation.start);
|
||||
while (index < populated.length && populated[index].glyphs[0].start < annotation.end) {
|
||||
const row = populated[index++].index,
|
||||
list = decorationsByRow.get(row) ?? [];
|
||||
list.push(annotation);
|
||||
decorationsByRow.set(row, list);
|
||||
}
|
||||
}
|
||||
const lines: Line[] = [];
|
||||
let y = 0;
|
||||
for (const [rowIndex, glyphs] of rows.entries()) {
|
||||
const overhead: AnnotationBox[] = [],
|
||||
decorations: AnnotationBox[] = [];
|
||||
const rowFragments = byRow.get(rowIndex) ?? [];
|
||||
// The first source sits closest to the base text. Missing sources collapse
|
||||
// on this physical line rather than reserving an empty annotation lane.
|
||||
// A modest interline allowance absorbs short labels without reserving a
|
||||
// full lane on every line. More annotation space grows only as needed.
|
||||
const rowTop = y;
|
||||
const addLane = (layer: number, members: Fragment[]) => {
|
||||
const laneAscent = Math.max(...members.map((member) => member.metrics.ascent));
|
||||
const laneDescent = Math.max(...members.map((member) => member.metrics.descent));
|
||||
for (const member of members) {
|
||||
const sourceGlyphs = glyphs.filter(
|
||||
(glyph) => glyph.end > member.label.annotation.start && glyph.start < member.label.annotation.end,
|
||||
);
|
||||
const first = sourceGlyphs[0],
|
||||
last = sourceGlyphs.at(-1);
|
||||
const sourceStart = first?.x,
|
||||
sourceEnd = last ? last.x + last.width : undefined;
|
||||
const overhang =
|
||||
sourceStart === undefined || sourceEnd === undefined
|
||||
? 0
|
||||
: Math.max(0, sourceStart - member.x) + Math.max(0, member.x + member.metrics.width - sourceEnd);
|
||||
overhead.push({
|
||||
annotation: member.label.annotation,
|
||||
x: member.x,
|
||||
y: y + laneAscent - member.metrics.ascent,
|
||||
width: member.metrics.width,
|
||||
height: member.metrics.ascent + member.metrics.descent,
|
||||
font: member.label.font,
|
||||
lines: [member.text],
|
||||
layer,
|
||||
baseline: y + laneAscent,
|
||||
// A font-relative tolerance leaves ordinary short glosses clean.
|
||||
// The guide is the source projection, never the annotation's padding.
|
||||
...(overhang > font.size * 0.7 && sourceStart !== undefined && sourceEnd !== undefined
|
||||
? { guide: { start: sourceStart, end: sourceEnd } }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
y += laneAscent + laneDescent + gap;
|
||||
};
|
||||
for (const [layer, feature] of [...features.entries()].reverse()) {
|
||||
const members = rowFragments.filter(
|
||||
(fragment) => lane(fragment.label.annotation) === feature && fragment.label.annotation.placement !== "below",
|
||||
);
|
||||
if (members.length) addLane(layer, members);
|
||||
}
|
||||
y = Math.max(y, rowTop + font.size * 0.45);
|
||||
const ascent = Math.max(font.size * 0.85, ...glyphs.map((glyph) => measure(glyph.text, glyph.font ?? font).ascent));
|
||||
const descent = Math.max(
|
||||
font.size * 0.22,
|
||||
...glyphs.map((glyph) => measure(glyph.text, glyph.font ?? font).descent),
|
||||
);
|
||||
const baseY = y;
|
||||
for (const glyph of glyphs) {
|
||||
glyph.y = baseY;
|
||||
glyph.height = ascent + descent;
|
||||
glyph.baseline = baseY + ascent;
|
||||
}
|
||||
const underlineFeatures = [
|
||||
...new Set((decorationsByRow.get(rowIndex) ?? []).filter((a) => a.style === "underline").map((a) => a.feature)),
|
||||
].sort();
|
||||
for (const annotation of decorationsByRow.get(rowIndex) ?? []) {
|
||||
const first = lowerBound(glyphs, (glyph) => glyph.end <= annotation.start);
|
||||
const last = lowerBound(glyphs, (glyph) => glyph.start < annotation.end) - 1;
|
||||
const layer = annotation.style === "underline" ? underlineFeatures.indexOf(annotation.feature) : 0;
|
||||
decorations.push({
|
||||
annotation,
|
||||
x: glyphs[first].x,
|
||||
y: annotation.style === "underline" ? baseY + ascent + descent + 3 + layer * 5 : baseY,
|
||||
width: Math.max(0, Math.min(width, glyphs[last].x + glyphs[last].width) - glyphs[first].x),
|
||||
height: annotation.style === "underline" ? 3 : ascent + descent,
|
||||
font,
|
||||
lines: [],
|
||||
layer,
|
||||
baseline: 0,
|
||||
});
|
||||
}
|
||||
y += ascent + descent + Math.max(12, underlineFeatures.length * 5 + 6);
|
||||
let hasBelow = false;
|
||||
for (const [layer, feature] of features.entries()) {
|
||||
const members = rowFragments.filter(
|
||||
(fragment) => lane(fragment.label.annotation) === feature && fragment.label.annotation.placement === "below",
|
||||
);
|
||||
if (members.length) {
|
||||
addLane(layer, members);
|
||||
hasBelow = true;
|
||||
}
|
||||
}
|
||||
if (hasBelow)
|
||||
y += Math.max(12, font.size * (features.some((feature) => feature.startsWith("above:")) ? 0.65 : 0.3));
|
||||
const height = y - baseY;
|
||||
lines.push({ x: 0, y: baseY, width, height, glyphs, overhead, decorations });
|
||||
if (!glyphs.length) y += font.size * 0.4;
|
||||
}
|
||||
return { text, width, height: y, font, lines };
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { PASS, Rules } from "@cto.af/linebreak";
|
||||
import type { Range } from "./types.js";
|
||||
import { graphemes } from "./text.js";
|
||||
|
||||
const normal = new Rules();
|
||||
// Emergency wrapping may split an oversized word or number, but still obeys
|
||||
// the punctuation, bracket, combining-mark and nonbreaking-space rules. This
|
||||
// is UAX #14 tailoring, not a list of special cases for a particular language.
|
||||
const emergency = new Rules();
|
||||
for (const rule of ["LB23", "LB25", "LB28", "LB29"]) emergency.replaceRule(rule, () => PASS);
|
||||
const cached = new Map<string, { normal: Range[]; emergency: Range[] }>();
|
||||
|
||||
/** Legal UTF-16 source spans for line wrapping. Intl.Segmenter still owns word
|
||||
* targeting; line breaking is a separate Unicode operation. */
|
||||
export function lineSpans(text: string, emergencyWrap = false): Range[] {
|
||||
let entry = cached.get(text);
|
||||
if (!entry) {
|
||||
const spans = (rules: Rules): Range[] => {
|
||||
let start = 0;
|
||||
const boundaries = new Set(graphemes(text).map((unit) => unit.end));
|
||||
return Array.from(rules.breaks(text))
|
||||
.filter(({ position }) => boundaries.has(position))
|
||||
.map(({ position }) => {
|
||||
const range = { start, end: position };
|
||||
start = position;
|
||||
return range;
|
||||
});
|
||||
};
|
||||
entry = { normal: spans(normal), emergency: spans(emergency) };
|
||||
if (text.length >= 256 && text.length <= 120000) {
|
||||
if (cached.size >= 4) cached.delete(cached.keys().next().value!);
|
||||
cached.set(text, entry);
|
||||
}
|
||||
}
|
||||
return entry[emergencyWrap ? "emergency" : "normal"];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { Font, Measure } from "./types.js";
|
||||
|
||||
export const fontString = (font: Font) =>
|
||||
`${font.italic ? "italic " : ""}${font.weight === "bold" ? "bold " : ""}${font.size}px ${font.family}`;
|
||||
|
||||
// A bounded per-surface cache replaces the original global memoizee cache.
|
||||
// Font loading creates a new measure/layout so fallback metrics cannot persist.
|
||||
export function canvasMeasure(context: CanvasRenderingContext2D, maximum = 12000): Measure {
|
||||
const cache = new Map<string, ReturnType<Measure>>();
|
||||
return (text, font) => {
|
||||
const key = `${fontString(font)}/${text}`;
|
||||
const cached = cache.get(key);
|
||||
if (cached) return cached;
|
||||
context.font = fontString(font);
|
||||
const metrics = context.measureText(text);
|
||||
const value = {
|
||||
width: Math.max(0, metrics.width),
|
||||
ascent: Math.max(metrics.actualBoundingBoxAscent || 0, font.size * 0.85),
|
||||
descent: Math.max(metrics.actualBoundingBoxDescent || 0, font.size * 0.22),
|
||||
};
|
||||
if (cache.size >= maximum) cache.clear();
|
||||
cache.set(key, value);
|
||||
return value;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import type { Layout, Range, Rect } from "./types.js";
|
||||
const sourceLines = new WeakMap<Layout, Layout["lines"]>();
|
||||
type SourceRect = Rect & Range;
|
||||
function rangeRows(layout: Layout, range: Range): SourceRect[] {
|
||||
// Layout source offsets are monotonic, including empty/blank lines. Search
|
||||
// the first candidate row rather than scanning a long document on every hover.
|
||||
let populated = sourceLines.get(layout);
|
||||
if (!populated) {
|
||||
populated = layout.lines.filter((line) => line.glyphs.length);
|
||||
sourceLines.set(layout, populated);
|
||||
}
|
||||
let low = 0,
|
||||
high = populated.length;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
if (populated[middle].glyphs.at(-1)!.end <= range.start) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
const rects: SourceRect[] = [];
|
||||
for (let index = low; index < populated.length; index++) {
|
||||
const line = populated[index];
|
||||
if (line.glyphs[0].start >= range.end) break;
|
||||
const members = line.glyphs.filter((glyph) => glyph.start < range.end && glyph.end > range.start);
|
||||
if (!members.length || members.every((glyph) => /^\s*$/.test(glyph.text))) continue;
|
||||
rects.push({
|
||||
start: members[0].start,
|
||||
end: members.at(-1)!.end,
|
||||
x: members[0].x,
|
||||
y: Math.min(...members.map((glyph) => glyph.y)),
|
||||
width: members.at(-1)!.x + members.at(-1)!.width - members[0].x,
|
||||
height: Math.max(...members.map((glyph) => glyph.height)),
|
||||
});
|
||||
}
|
||||
return rects;
|
||||
}
|
||||
export function rangeRects(layout: Layout, range: Range): Rect[] {
|
||||
return rangeRows(layout, range).map(({ start, end, ...rect }) => rect);
|
||||
}
|
||||
type Point = { x: number; y: number };
|
||||
function rounded(points: Point[], radius: number) {
|
||||
const corners = points.map((point, index) => {
|
||||
const before = points[(index + points.length - 1) % points.length],
|
||||
after = points[(index + 1) % points.length];
|
||||
const incoming = Math.hypot(point.x - before.x, point.y - before.y),
|
||||
outgoing = Math.hypot(after.x - point.x, after.y - point.y);
|
||||
const distance = Math.min(radius, incoming / 2, outgoing / 2);
|
||||
return {
|
||||
point,
|
||||
before: {
|
||||
x: point.x + ((before.x - point.x) * distance) / incoming,
|
||||
y: point.y + ((before.y - point.y) * distance) / incoming,
|
||||
},
|
||||
after: {
|
||||
x: point.x + ((after.x - point.x) * distance) / outgoing,
|
||||
y: point.y + ((after.y - point.y) * distance) / outgoing,
|
||||
},
|
||||
};
|
||||
});
|
||||
return (
|
||||
corners
|
||||
.map(
|
||||
({ point, before, after }, index) =>
|
||||
`${index ? "L" : "M"}${before.x},${before.y}Q${point.x},${point.y} ${after.x},${after.y}`,
|
||||
)
|
||||
.join("") + "Z"
|
||||
);
|
||||
}
|
||||
/** A broad band: only the first and last rows follow source edges. Paragraph
|
||||
* gaps and intermediate short rows stay inside one quiet, full-width outline. */
|
||||
export function rangeOutline(layout: Layout, range: Range, padding = 3): string {
|
||||
const rows = rangeRows(layout, range);
|
||||
if (!rows.length) return "";
|
||||
const first = rows[0],
|
||||
last = rows.at(-1)!;
|
||||
const left = 1,
|
||||
right = layout.width - 1;
|
||||
const firstLeft = Math.max(left, first.x - padding);
|
||||
const lastRight = Math.min(right, last.x + last.width + padding);
|
||||
const top = Math.max(1, first.y - padding),
|
||||
bottom = last.y + last.height + padding;
|
||||
let points: Point[];
|
||||
if (rows.length === 1) {
|
||||
points = [
|
||||
{ x: firstLeft, y: top },
|
||||
{ x: lastRight, y: top },
|
||||
{ x: lastRight, y: bottom },
|
||||
{ x: firstLeft, y: bottom },
|
||||
];
|
||||
} else {
|
||||
const firstJoin = first.y + first.height + padding;
|
||||
const lastJoin = last.y - padding;
|
||||
// Very tightly stacked rows get a plain box rather than a crossing contour.
|
||||
points =
|
||||
firstJoin >= lastJoin
|
||||
? [
|
||||
{ x: left, y: top },
|
||||
{ x: right, y: top },
|
||||
{ x: right, y: bottom },
|
||||
{ x: left, y: bottom },
|
||||
]
|
||||
: [
|
||||
{ x: firstLeft, y: top },
|
||||
{ x: right, y: top },
|
||||
{ x: right, y: lastJoin },
|
||||
{ x: lastRight, y: lastJoin },
|
||||
{ x: lastRight, y: bottom },
|
||||
{ x: left, y: bottom },
|
||||
{ x: left, y: firstJoin },
|
||||
{ x: firstLeft, y: firstJoin },
|
||||
];
|
||||
}
|
||||
points = points.filter((p, i) => {
|
||||
const before = points[(i + points.length - 1) % points.length],
|
||||
after = points[(i + 1) % points.length];
|
||||
return !((before.x === p.x && p.x === after.x) || (before.y === p.y && p.y === after.y));
|
||||
});
|
||||
return rounded(points, 4);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
export type PageSize = { width: number; height: number };
|
||||
export type ViewportReport = PageSize & { client: string; expiresAt: number };
|
||||
/** Only live attachments constrain pages. Our measured size supersedes our
|
||||
* own published report immediately, including while a resize is in flight. */
|
||||
export function sharedPageSize(
|
||||
local: PageSize,
|
||||
reports: readonly ViewportReport[],
|
||||
client: string,
|
||||
now: number,
|
||||
): PageSize {
|
||||
return reports.reduce(
|
||||
(size, report) =>
|
||||
report.client !== client && report.expiresAt > now && report.width > 0 && report.height > 0
|
||||
? { width: Math.min(size.width, report.width), height: Math.min(size.height, report.height) }
|
||||
: size,
|
||||
local,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { Layout, Line, Range } from "./types.js";
|
||||
export type ReadingPage = Range & { top: number; bottom: number };
|
||||
export type ReadingWindow = { visible: Range; nearby: Range };
|
||||
const lineTop = (line: Line) => Math.min(line.y, ...line.overhead.map((box) => box.y));
|
||||
export function readingPages(layout: Layout, height: number, anchor = 0): ReadingPage[] {
|
||||
// A reading position is a source offset, never a page number. Keep its row
|
||||
// at the beginning of a page when annotation arrival or resizing reflows it.
|
||||
const anchored = anchor > 0 ? layout.lines.find((line) => line.glyphs.some((g) => g.end > anchor)) : undefined;
|
||||
const pages: ReadingPage[] = [];
|
||||
let top = 0,
|
||||
first = 0,
|
||||
end = 0,
|
||||
populated = false;
|
||||
for (const line of layout.lines) {
|
||||
if (populated && (line === anchored || line.y + line.height - top > height)) {
|
||||
// Annotation-only continuation rows belong to their source page. An
|
||||
// exceptionally tall source row can scroll within its page.
|
||||
if (line.glyphs.length) {
|
||||
pages.push({ top, bottom: lineTop(line), start: first, end });
|
||||
top = lineTop(line);
|
||||
first = line.glyphs[0].start;
|
||||
populated = false;
|
||||
}
|
||||
}
|
||||
if (line.glyphs.length) {
|
||||
if (!populated) first = line.glyphs[0].start;
|
||||
end = line.glyphs.at(-1)!.end;
|
||||
populated = true;
|
||||
}
|
||||
}
|
||||
if (populated || !pages.length) pages.push({ top, bottom: layout.height, start: first, end });
|
||||
// Assign inter-line whitespace/newlines to the preceding page. Source
|
||||
// coordinates remain global and independent of pagination/reflow.
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
pages[i].start = i === 0 ? 0 : pages[i].start;
|
||||
pages[i].end = Math.max(pages[i].end, pages[i + 1]?.start ?? layout.text.length);
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
export function pageAt(pages: ReadingPage[], offset: number) {
|
||||
let low = 0,
|
||||
high = pages.length;
|
||||
while (low < high) {
|
||||
const middle = (low + high) >>> 1;
|
||||
if (pages[middle].end <= offset) low = middle + 1;
|
||||
else high = middle;
|
||||
}
|
||||
return Math.min(low, Math.max(0, pages.length - 1));
|
||||
}
|
||||
function sourceWindow(layout: Layout, top: number, bottom: number): Range {
|
||||
let start = layout.text.length,
|
||||
end = 0;
|
||||
for (const line of layout.lines) {
|
||||
if (line.y + line.height < top) continue;
|
||||
if (lineTop(line) > bottom) break;
|
||||
if (line.glyphs.length) {
|
||||
start = Math.min(start, line.glyphs[0].start);
|
||||
end = line.glyphs.at(-1)!.end;
|
||||
}
|
||||
}
|
||||
return end > start ? { start, end } : { start: 0, end: 0 };
|
||||
}
|
||||
export function readingWindow(
|
||||
layout: Layout,
|
||||
pages: ReadingPage[],
|
||||
index: number,
|
||||
mode: string,
|
||||
top: number,
|
||||
height: number,
|
||||
): ReadingWindow {
|
||||
if (mode === "pages") {
|
||||
const page = pages[index];
|
||||
return {
|
||||
visible: { start: page.start, end: page.end },
|
||||
nearby: { start: pages[Math.max(0, index - 1)].start, end: pages[Math.min(pages.length - 1, index + 1)].end },
|
||||
};
|
||||
}
|
||||
return {
|
||||
visible: sourceWindow(layout, top, top + height),
|
||||
nearby: sourceWindow(layout, Math.max(0, top - height), top + height * 2),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { Range } from "./types.js";
|
||||
|
||||
export type ProcessingRegion = { feature: string; range: Range; label: string; color: string };
|
||||
|
||||
/** Presentation-only union. Keep annotator identity until its queued and active
|
||||
* scopes have been joined; job boundaries still belong to the scheduler/cache. */
|
||||
export function coalesceProcessing(regions: readonly ProcessingRegion[], text: string): ProcessingRegion[] {
|
||||
const features = new Map<string, ProcessingRegion[]>();
|
||||
for (const region of regions) {
|
||||
if (region.range.end <= region.range.start) continue;
|
||||
const members = features.get(region.feature) ?? [];
|
||||
members.push(region);
|
||||
features.set(region.feature, members);
|
||||
}
|
||||
const result: ProcessingRegion[] = [];
|
||||
for (const members of features.values()) {
|
||||
members.sort((a, b) => a.range.start - b.range.start || a.range.end - b.range.end);
|
||||
let previous: ProcessingRegion | undefined;
|
||||
for (const region of members) {
|
||||
if (
|
||||
previous &&
|
||||
(region.range.start <= previous.range.end || /^\s*$/u.test(text.slice(previous.range.end, region.range.start)))
|
||||
) {
|
||||
previous.range.end = Math.max(previous.range.end, region.range.end);
|
||||
} else {
|
||||
previous = { ...region, range: { ...region.range } };
|
||||
result.push(previous);
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { graphemes, snapOffset } from "./text.js";
|
||||
import type { Layout, Range, Rect } from "./types.js";
|
||||
|
||||
export type Selection = { anchor: number; focus: number };
|
||||
export const selectionRange = ({ anchor, focus }: Selection): Range => ({
|
||||
start: Math.min(anchor, focus),
|
||||
end: Math.max(anchor, focus),
|
||||
});
|
||||
export function contextTarget(
|
||||
selection: Selection,
|
||||
word: Range | null,
|
||||
insideSelection: boolean,
|
||||
): { range: Range; kind: "word" | "selection" } | null {
|
||||
const range = selectionRange(selection);
|
||||
if (insideSelection && range.end > range.start) return { range, kind: "selection" };
|
||||
return word && word.end > word.start ? { range: word, kind: "word" } : null;
|
||||
}
|
||||
export async function copyText(text: string) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return;
|
||||
} catch {}
|
||||
}
|
||||
const active = document.activeElement as HTMLElement | null;
|
||||
const field = document.createElement("textarea");
|
||||
field.value = text;
|
||||
field.readOnly = true;
|
||||
field.style.cssText = "position:fixed;opacity:0;left:0;top:0";
|
||||
document.body.append(field);
|
||||
field.select();
|
||||
const copied = document.execCommand("copy");
|
||||
field.remove();
|
||||
active?.focus();
|
||||
if (!copied) throw Error("Copy was unavailable. Open Original text to copy it directly.");
|
||||
}
|
||||
export function hitTest(layout: Layout, x: number, y: number): number {
|
||||
const populated = layout.lines.filter((line) => line.glyphs.length);
|
||||
if (!populated.length) return 0;
|
||||
const line = populated.reduce((closest, candidate) =>
|
||||
Math.abs(candidate.y + candidate.glyphs[0].height / 2 - y) < Math.abs(closest.y + closest.glyphs[0].height / 2 - y)
|
||||
? candidate
|
||||
: closest,
|
||||
);
|
||||
for (const glyph of line.glyphs) if (x < glyph.x + glyph.advance / 2) return glyph.start;
|
||||
return line.glyphs[line.glyphs.length - 1].end;
|
||||
}
|
||||
export function selectionRects(layout: Layout, selection: Selection): Rect[] {
|
||||
const range = selectionRange(selection);
|
||||
return layout.lines.flatMap((line) => {
|
||||
const glyphs = line.glyphs.filter((glyph) => glyph.start < range.end && glyph.end > range.start);
|
||||
if (!glyphs.length) return [];
|
||||
const first = glyphs[0],
|
||||
last = glyphs[glyphs.length - 1];
|
||||
return [
|
||||
{
|
||||
x: first.x,
|
||||
y: line.y,
|
||||
width: last.x + last.advance - first.x,
|
||||
height: Math.max(...glyphs.map((glyph) => glyph.height)),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
export function moveSelection(layout: Layout, selection: Selection, key: string, shift: boolean): Selection {
|
||||
let focus = selection.focus;
|
||||
const range = selectionRange(selection);
|
||||
if (!shift && range.start !== range.end && (key === "ArrowLeft" || key === "ArrowRight"))
|
||||
focus = key === "ArrowLeft" ? range.start : range.end;
|
||||
else if (key === "ArrowLeft" || key === "ArrowRight") {
|
||||
const bounds = [0, ...graphemes(layout.text).map((unit) => unit.end)];
|
||||
const index = bounds.indexOf(snapOffset(layout.text, focus));
|
||||
focus = bounds[Math.max(0, Math.min(bounds.length - 1, index + (key === "ArrowLeft" ? -1 : 1)))];
|
||||
} else {
|
||||
const row = layout.lines.findIndex((line) =>
|
||||
line.glyphs.some((glyph) => glyph.start <= focus && glyph.end >= focus),
|
||||
);
|
||||
const current = layout.lines[Math.max(0, row)];
|
||||
const target = layout.lines[Math.max(0, Math.min(layout.lines.length - 1, row + (key === "ArrowUp" ? -1 : 1)))];
|
||||
if (key === "Home") focus = current?.glyphs[0]?.start ?? focus;
|
||||
else if (key === "End") focus = current?.glyphs.at(-1)?.end ?? focus;
|
||||
else {
|
||||
const glyph = current?.glyphs.find((unit) => unit.start >= focus) ?? current?.glyphs.at(-1);
|
||||
if (glyph && target) focus = hitTest(layout, glyph.x, target.y + target.glyphs[0].height / 2);
|
||||
}
|
||||
}
|
||||
return { anchor: shift ? selection.anchor : focus, focus };
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { Range } from "./types.js";
|
||||
|
||||
// All ranges refer to the original UTF-16 source. Never trim paragraph text:
|
||||
// Fluxtapose's trimmed paragraphs broke selection and annotations after spaces.
|
||||
const graphemeSegmenter = new Intl.Segmenter("zh", { granularity: "grapheme" });
|
||||
const wordSegmenter = new Intl.Segmenter("zh", { granularity: "word" });
|
||||
let lastWords: { text: string; ranges: Range[]; wordStarts: Set<number> } | null = null;
|
||||
const segmented = new Map<string, (Range & { text: string })[]>();
|
||||
let cachedUnits = 0;
|
||||
export function graphemes(text: string): (Range & { text: string })[] {
|
||||
const cached = segmented.get(text);
|
||||
if (cached) return cached;
|
||||
const units = Array.from(graphemeSegmenter.segment(text), ({ segment, index }) => ({
|
||||
text: segment,
|
||||
start: index,
|
||||
end: index + segment.length,
|
||||
}));
|
||||
// Retain documents across layout changes, without filling the cache with
|
||||
// every small annotation label. Bound both object count and retained units.
|
||||
if (text.length >= 256 && units.length <= 120000) {
|
||||
while (segmented.size >= 4 || cachedUnits + units.length > 120000) {
|
||||
const oldest = segmented.keys().next().value!;
|
||||
cachedUnits -= segmented.get(oldest)!.length;
|
||||
segmented.delete(oldest);
|
||||
}
|
||||
segmented.set(text, units);
|
||||
cachedUnits += units.length;
|
||||
}
|
||||
return units;
|
||||
}
|
||||
export function wordAt(text: string, offset: number): Range {
|
||||
if (lastWords?.text !== text) {
|
||||
const words = Array.from(wordSegmenter.segment(text));
|
||||
lastWords = {
|
||||
text,
|
||||
ranges: words.map(({ index, segment }) => ({
|
||||
start: index,
|
||||
end: index + segment.length,
|
||||
})),
|
||||
wordStarts: new Set(words.filter((word) => word.isWordLike).map((word) => word.index)),
|
||||
};
|
||||
}
|
||||
const ranges = lastWords.ranges;
|
||||
let low = 0,
|
||||
high = ranges.length;
|
||||
while (low < high) {
|
||||
const mid = (low + high) >>> 1;
|
||||
if (ranges[mid].end <= offset) low = mid + 1;
|
||||
else high = mid;
|
||||
}
|
||||
if (ranges[low] && offset >= ranges[low].start) return { start: ranges[low].start, end: ranges[low].end };
|
||||
return { start: text.length, end: text.length };
|
||||
}
|
||||
/** Segmenter classifies word-like spans using Unicode data, including CJK.
|
||||
* Selection still uses wordAt, where selecting punctuation is legitimate. */
|
||||
export function hoverWordAt(text: string, offset: number): Range | null {
|
||||
const range = wordAt(text, offset);
|
||||
return lastWords?.wordStarts.has(range.start) ? range : null;
|
||||
}
|
||||
export function snapOffset(text: string, offset: number, direction = 1): number {
|
||||
const bounded = Math.max(0, Math.min(text.length, offset));
|
||||
for (const unit of graphemes(text)) {
|
||||
if (bounded > unit.start && bounded < unit.end) return direction < 0 ? unit.start : unit.end;
|
||||
}
|
||||
return bounded;
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { Rect } from "./types.js";
|
||||
/** Viewport coordinates; prefer above the word, flip below, then clamp. */
|
||||
export function tooltipPosition(
|
||||
anchor: Rect,
|
||||
width: number,
|
||||
height: number,
|
||||
viewport: { width: number; height: number },
|
||||
) {
|
||||
const margin = 12,
|
||||
gap = 10,
|
||||
center = anchor.x + anchor.width / 2;
|
||||
const left = Math.max(margin, Math.min(viewport.width - width - margin, center - width / 2));
|
||||
const above = anchor.y - height - gap;
|
||||
const side = above >= margin ? "above" : "below";
|
||||
const top = Math.max(
|
||||
margin,
|
||||
Math.min(viewport.height - height - margin, side === "above" ? above : anchor.y + anchor.height + gap),
|
||||
);
|
||||
return { left, top, side, arrow: Math.max(16, Math.min(width - 16, center - left)) };
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
export type Range = { start: number; end: number };
|
||||
export type Rect = { x: number; y: number; width: number; height: number };
|
||||
export type Font = { family: string; size: number; weight?: "normal" | "bold"; italic?: boolean };
|
||||
export type Metrics = { width: number; ascent: number; descent: number };
|
||||
export type Measure = (text: string, font: Font) => Metrics;
|
||||
export type UnderlinePattern = "solid" | "dashed" | "squiggly";
|
||||
export type Annotation = Range & {
|
||||
activation?: Range;
|
||||
id: string;
|
||||
feature: string;
|
||||
text: string;
|
||||
reveal?: "always" | "click" | "hover";
|
||||
style: "overhead" | "highlight" | "underline" | "color" | "sidebar" | "tooltip" | "replace" | "bold" | "italic";
|
||||
placement?: "above" | "below";
|
||||
color: string;
|
||||
underlinePattern?: UnderlinePattern;
|
||||
fontFamily?: string;
|
||||
fontSize?: number;
|
||||
priority?: number;
|
||||
};
|
||||
export type Glyph = Range & Rect & { text: string; baseline: number; advance: number; font?: Font };
|
||||
export type AnnotationBox = Rect & {
|
||||
annotation: Annotation;
|
||||
lines: string[];
|
||||
font: Font;
|
||||
layer: number;
|
||||
baseline: number;
|
||||
guide?: { start: number; end: number };
|
||||
};
|
||||
export type Line = Rect & { glyphs: Glyph[]; overhead: AnnotationBox[]; decorations: AnnotationBox[] };
|
||||
export type Layout = { text: string; width: number; height: number; font: Font; lines: Line[] };
|
||||
export const intersects = (a: Range, b: Range) => a.start < b.end && a.end > b.start;
|
||||
export const rectIntersects = (a: Rect, b: Rect) =>
|
||||
a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
|
||||
Reference in New Issue
Block a user