Export template source from 11bc3ae35b0034c113daca4c35d1b341aa31ef70:examples/local-first/packages/language-reader-presentation

This commit is contained in:
Quixos Template Publisher
2000-01-01 00:00:00 +00:00
commit 131148e054
61 changed files with 10297 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>
)}
</>
);
}
+159
View File
@@ -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>
);
}
+142
View File
@@ -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();
}
+31
View File
@@ -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;
}
+562
View File
@@ -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 };
}
+36
View File
@@ -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"];
}
+25
View File
@@ -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;
};
}
+118
View File
@@ -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);
}
+18
View File
@@ -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,
);
}
+82
View File
@@ -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),
};
}
+32
View File
@@ -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;
}
+88
View File
@@ -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 };
}
+66
View File
@@ -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;
}
+20
View File
@@ -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)) };
}
+34
View File
@@ -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;