Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
232 changes: 228 additions & 4 deletions src/lib/components/PdfPageView.svelte
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
<script lang="ts">
import { on } from 'svelte/events';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
import type { Snippet } from 'svelte';
import type { NormalizedDocument, SpeechSegment } from '$lib/domain/types';
import type { PageRect, SegmentPlacement } from '$lib/domain/pdf-layout';
import {
selectableWords,
type PageRect,
type SegmentPlacement,
type SelectableWord
} from '$lib/domain/pdf-layout';
import { openPdfRenderer } from '$lib/services/pdf-pages';
import { openPdfLayout } from '$lib/services/pdf-layout';
import { parseColor, type Rgb } from '$lib/domain/page-tone';
Expand All @@ -27,6 +33,27 @@
onPlaySegment: (segmentId: string) => void;
/** The reader scrolled by hand; whatever was following should stop. */
onManualScroll?: () => void;
/** A selection was made over the page, or cleared. Positions are in this
* view's scroll space, which is why they are worked out here. */
onSelect?: (selection: PageSelection | undefined) => void;
/** What the reader lays over a selection — its actions, its explain
* panel — rendered inside this view's scroll so it travels with the
* page and is positioned in the same coordinates. */
overlay?: Snippet;
}

/** What a selection over the paper amounts to, in the terms the reader
* already speaks: a passage and word to start from, one to stop at, and
* where to put the actions. */
export interface PageSelection {
segmentId: string;
wordIndex: number;
endSegmentId: string;
endWordIndex: number;
excerpt: string;
left: number;
top: number;
placement: 'above' | 'below';
}

let {
Expand All @@ -40,7 +67,9 @@
assistantPointId,
follow = true,
onPlaySegment,
onManualScroll
onManualScroll,
onSelect,
overlay
}: Props = $props();

/** US Letter, for a document whose parse recorded no page sizes. */
Expand All @@ -66,6 +95,9 @@
{ width: number; tone: { paper: Rgb; ink: Rgb } | undefined }
>();
const placements = new SvelteMap<number, Map<string, SegmentPlacement>>();
/** Each page's words, tagged with the passage they belong to — the layer
* that makes the paper selectable. */
const words = new SvelteMap<number, SelectableWord[]>();
let hovered = $state<{ page: number; segmentId: string }>();

let sizes = $derived.by(() => {
Expand Down Expand Up @@ -175,7 +207,57 @@
owedFollow = undefined;
onManualScroll?.();
};
// Watched on the document, not on the stack: a click that lands anywhere
// else — the sidebar, the player, the header — drops the selection, and
// the actions have to go with it. Listening only here left them stranded
// on the page with nothing that would dismiss them.
//
// A drag, though, is one gesture and not a stream of selections. The
// browser reports a new one on every frame of it, and offering each one
// back sent the actions chasing the pointer across the page, flashing
// as they went. Nothing is offered while a pointer is down; the whole
// gesture is read once, when it ends.
let selecting = false;
let queued = false;
const afterSelection = () => {
if (selecting || queued) return;
queued = true;
requestAnimationFrame(() => {
queued = false;
readSelection();
});
};
const startPress = (event: PointerEvent) => {
const target = event.target as HTMLElement | null;
// A press on the actions themselves is aimed at a button, and taking
// them away underneath it would mean the button was never clicked.
if (target?.closest('.selection-actions, .explain-box')) return;
selecting = true;
// Whatever is selected now is about to be replaced, so the actions
// go with the press rather than hovering over a selection that is
// being redrawn beneath them.
onSelect?.(undefined);
// Pressing on a blank part of the page — a margin, a figure, the gap
// between paragraphs — puts no caret anywhere, because there is no
// text node under the pointer to put one in. The browser therefore
// leaves the old selection standing, which is what made it feel
// impossible to dismiss. Clearing it by hand is what clicking off a
// selection does everywhere else.
if (target?.closest('.page-text span')) return;
window.getSelection()?.removeAllRanges();
};
// On the document, not the stack: a drag that starts on the page often
// ends off it, and the release is what the actions are waiting for.
const endPress = () => {
if (!selecting) return;
selecting = false;
afterSelection();
};
const removeListeners = [
on(node, 'pointerdown', startPress),
on(document, 'pointerup', endPress),
on(document, 'pointercancel', endPress),
on(document, 'selectionchange', afterSelection),
on(node, 'scroll', measureFocus, { passive: true }),
on(node, 'wheel', stopFollowing, { passive: true }),
on(node, 'touchmove', stopFollowing, { passive: true })
Expand Down Expand Up @@ -369,20 +451,44 @@
if (placedAgainst !== currentSegments) {
placedAgainst = currentSegments;
placements.clear();
words.clear();
}
const layout = openPdfLayout(book);
let cancelled = false;
for (const page of pages) {
if (placements.has(page)) continue;
void layout.placements(page, pageCount, currentSegments, blockText).then((placed) => {
if (!cancelled && placedAgainst === currentSegments) placements.set(page, placed);
void Promise.all([
layout.placements(page, pageCount, currentSegments, blockText),
layout.pageLayout(page, pageCount)
]).then(([placed, geometry]) => {
if (cancelled || placedAgainst !== currentSegments) return;
placements.set(page, placed);
words.set(page, selectableWords(geometry?.boxes ?? [], [...placed.values()]));
});
}
return () => {
cancelled = true;
};
});

/**
* The invisible words have to sit exactly on top of the printed ones, or the
* selection the browser paints lands beside the text it is selecting. Their
* boxes are known; what is not is how wide the same string renders in the
* font this view can actually use, so each is measured once and squeezed to
* fit — which is what pdf.js's own text layer does, and for the same reason.
*/
const MEASURE_FONT = 'sans-serif';
let measurer: CanvasRenderingContext2D | null | undefined;

function widthScale(word: SelectableWord): number {
if (measurer === undefined) measurer = document.createElement('canvas').getContext('2d');
if (!measurer || !word.width || !word.height) return 1;
measurer.font = `${word.height}px ${MEASURE_FONT}`;
const natural = measurer.measureText(word.text).width;
return natural > 0 ? word.width / natural : 1;
}

function pageWidth(page: number): number {
return sizes[page - 1]?.width || FALLBACK_WIDTH;
}
Expand Down Expand Up @@ -462,6 +568,66 @@
hovered = segmentId ? { page, segmentId } : undefined;
}

/**
* Read the browser's selection back as passages.
*
* Every word carries the passage it belongs to, so this is a matter of
* asking the endpoints of the range what they are rather than working it
* out from geometry. A selection that starts or ends on a word no passage
* claimed — an axis label, part of an equation — falls back to the nearest
* claimed word inside the range, and if there is none there is nothing to
* play or annotate.
*/
function readSelection(): void {
if (!scroller) return;
const selection = window.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
onSelect?.(undefined);
return;
}
const range = selection.getRangeAt(0);
// A selection made elsewhere — the sidebar, the player's label — leaves
// nothing here to act on, and the actions must go with it. Returning
// quietly left them stranded on the page with no way to dismiss them.
if (!scroller.contains(range.commonAncestorContainer)) {
onSelect?.(undefined);
return;
}
const claimed = [...scroller.querySelectorAll<HTMLElement>('.page-text span[data-segment-id]')]
.filter((span) => range.intersectsNode(span))
.map((span) => ({
segmentId: span.dataset.segmentId ?? '',
wordIndex: Number(span.dataset.wordIndex ?? 0)
}));
if (!claimed.length) {
onSelect?.(undefined);
return;
}
const first = claimed[0];
const last = claimed[claimed.length - 1];
const bounds = range.getBoundingClientRect();
const stack = scroller.getBoundingClientRect();
const placement = bounds.top - stack.top >= 54 ? 'above' : 'below';
onSelect?.({
segmentId: first.segmentId,
wordIndex: first.wordIndex,
endSegmentId: last.segmentId,
endWordIndex: last.wordIndex,
excerpt: selection.toString().replace(/\s+/g, ' ').trim(),
left: Math.max(
120,
Math.min(
scroller.clientWidth - 120,
scroller.scrollLeft + bounds.left + bounds.width / 2 - stack.left
)
),
top:
scroller.scrollTop +
(placement === 'above' ? bounds.top - stack.top - 8 : bounds.bottom - stack.top + 8),
placement
});
}

function handleActivate(event: MouseEvent, page: number): void {
const { x, y } = pointerPoint(event, page);
const segmentId = segmentAt(page, x, y);
Expand Down Expand Up @@ -577,6 +743,18 @@
<canvas {@attach trackCanvas(size.page)} aria-label={`Page ${size.page} of ${book.title}`}
></canvas>
{#if live.has(size.page)}
<!-- The words, invisible, over the picture of them: this is what
the browser selects, and each one already knows the passage
it belongs to. Laid out in PDF points and scaled with the
sheet, so zooming moves it without rebuilding it. -->
<div class="page-text" style:transform={`scale(${scale})`}>
{#each words.get(size.page) ?? [] as word, index (index)}<span
data-segment-id={word.segmentId}
data-word-index={word.wordIndex}
style={`left:${word.x}px;top:${word.y}px;font-size:${word.height}px;transform:scaleX(${widthScale(word)})`}
>{word.text}</span
>{word.endsLine ? '\n' : ' '}{/each}
</div>
<div class="page-marks" aria-hidden="true">
{#each marksFor(size.page, placed) as mark (mark.key)}
<span class="mark {mark.kind}" style={styleFor(mark.rect, size.page)}></span>
Expand All @@ -589,10 +767,14 @@
</div>
</div>
{/each}
{@render overlay?.()}
</div>

<style>
.page-stack {
/* The selection actions are absolutely positioned against this, and it
is this scroll their coordinates are measured in. */
position: relative;
display: flex;
flex: 1;
flex-direction: column;
Expand Down Expand Up @@ -660,13 +842,55 @@
height: 100%;
}

/* Positioned in PDF points and scaled as a whole, so a zoom is a transform
rather than a thousand style recalculations. The text is invisible and
sized to its own box: what matters is that the browser's selection lands
exactly on the word the page prints there. */
.page-text {
position: absolute;
top: 0;
left: 0;
/* Pinned: each word is squeezed to its box by a measurement taken in
this same family. */
font-family: sans-serif;
/* The spaces and newlines between words are there so a copied selection
reads properly, but they are the only children in normal flow — at any
inherited size they stack up as a visible column of blank lines down
the corner of the page. The words set their own size. */
font-size: 0;
line-height: 1;
white-space: pre;
transform-origin: 0 0;
color: transparent;
cursor: text;
user-select: text;
}

.page-text span {
position: absolute;
white-space: pre;
transform-origin: 0 0;
}

/* A selection paints its text in the browser's own colour, which overrides
`color: transparent` and prints a second copy of every selected word over
the picture of it. The highlight is wanted; the ghost text is not. */
.page-text ::selection {
background: color-mix(in srgb, var(--primary) 34%, transparent);
color: transparent;
-webkit-text-fill-color: transparent;
}

/* Multiply keeps the marks under the ink: a highlighted line stays as
readable as an unhighlighted one, which is the whole point of reading
the original. */
.page-marks {
position: absolute;
mix-blend-mode: multiply;
inset: 0;
/* Painted over the words, so it must not stand between them and the
pointer that is trying to select them. */
pointer-events: none;
}

.mark {
Expand Down
Loading
Loading