Skip to content
Draft
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
49 changes: 44 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"svelte-octicons": "^18.20.0",
"svelte-preprocess": "^6.0.3",
"sveltekit-flash-message": "^2.4.6",
"virtua": "^0.50.2",
"vite": "^7.3.6"
},
"devDependencies": {
Expand Down
91 changes: 71 additions & 20 deletions src/lib/components/viewer/PDF.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,22 +10,46 @@
<script lang="ts">
import { browser } from "$app/environment";
import { onMount } from "svelte";
import { Virtualizer } from "virtua/svelte";

import PdfPage from "./PDFPage.svelte";

import { scrollToPage } from "$lib/utils/scroll";
import { getSections } from "$lib/utils/viewer";
import { getViewerState } from "$lib/state/viewer.svelte";
import { pinX } from "$lib/utils/pinX.svelte";
import { pinchZoom, type PinchZoomOptions } from "$lib/utils/pinchZoom";
import Error from "../common/Error.svelte";

const viewer = getViewerState();

let pinching = $state(false);
let pinchEnabled = $derived(viewer.mode === "document");

let pinchZoomOptions = $derived<PinchZoomOptions>({
enabled: () => pinchEnabled,
getScale: () => viewer.scale,
setZoom: (scale) => {
viewer.zoom = scale;
},
min: 0.4,
max: 2.5,
onPinchStart: () => (pinching = true),
onPinchEnd: () => (pinching = false),
});

let document = $derived(viewer.document!);
let sizes = $derived(viewer.pageSizes);
let sections = $derived(getSections(document));
let scale = $derived(viewer.scale);

// The scrolling ancestor is div#content in SidebarLayout.svelte
let scrollRef = $derived(window.document.getElementById("content")!);

// Virtua's container uses `contain: size` and each item is `width: 100%`, so
// wider-than-viewport pages can't push it out on their own. Set the widest
// scaled page as `width` on an inner div so `.pages` gets scrollable overflow.
let maxPageWidth = $derived(viewer.maxPageWidth * scale);

// handle missing page_spec
// (PDF normally only renders when the viewer loads one, but guard `pdf` in
// case this component ends up in a viewer that never loads a PDF)
Expand Down Expand Up @@ -81,22 +105,34 @@
resized — which would shift every page below the change (#1203).
-->
<div class="sizer">
<div class="pages" {@attach pinX}>
<div class="pages" class:pinch={pinchEnabled}>
<div
class="inner"
bind:clientWidth={viewer.width}
style:--pin-width="{viewer.width}px"
{@attach pinchZoom(pinchZoomOptions)}
>
{#if browser && viewer.width !== undefined}
{#each sizes as [width, height], n}
{@const page_number = n + 1}
{#if sections[n]}
<h3 class="section pin-x">
{sections[n].title}
</h3>
{/if}
<PdfPage {page_number} {scale} {width} {height} />
{/each}
{/if}
<div style:width="{maxPageWidth}px">
{#if browser && viewer.width !== undefined}
<Virtualizer
data={sizes}
{scrollRef}
itemProps={() => ({ style: { width: "auto" } })}
>
{#snippet children([width, height], n)}
{@const page_number = n + 1}
<div class={["page", n === sizes.length - 1 && "last"]}>
{#if sections[n]}
<h3 class="section pin-x">
{sections[n].title}
</h3>
{/if}
<PdfPage {page_number} {scale} {width} {height} {pinching} />
</div>
{/snippet}
</Virtualizer>
{/if}
</div>
</div>
</div>
</div>
Expand All @@ -115,26 +151,41 @@
width: 100%;
}
.inner {
display: flex;
flex-direction: column;
margin: 0 auto;
gap: 1.5rem;
width: 100%;
display: flex;
justify-content: center;
}

.page,
.section {
margin-bottom: 1.5rem;
}

.page.last {
margin-bottom: 0;
}

.pinch {
touch-action: pan-x pan-y;
}

@container (width < 35rem) {
.pages {
padding: 1.5rem;
}
.inner {
gap: 0.75rem;
.page,
.section {
margin-bottom: 0.75rem;
}
}
@container (width > 70rem) {
.pages {
padding: 4.5rem;
}
.inner {
gap: 2.25rem;
.page,
.section {
margin-bottom: 2.25rem;
}
}
.section {
Expand Down
8 changes: 6 additions & 2 deletions src/lib/components/viewer/PDFPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ Selectable text can be rendered in one of two ways:
height: number;
text?: TextPosition[];
query?: string;
// While true, skip the expensive pdfjs canvas re-render and rely on the
// CSS-stretched canvas for visual feedback during a pinch gesture.
pinching?: boolean;
// make hidden things visible, for debugging
debug?: boolean;
}
Expand All @@ -51,6 +54,7 @@ Selectable text can be rendered in one of two ways:
height = $bindable(),
text = [],
query = $bindable(getQuery(pageState.url, "q")),
pinching,
debug = false,
}: Props = $props();

Expand Down Expand Up @@ -227,7 +231,7 @@ Selectable text can be rendered in one of two ways:
// reading it only inside the async `.then` below would not register it,
// and numeric zoom changes (which flow through `pxScale`) wouldn't re-render.
pxScale;
if (!visible) return;
if (!visible || pinching) return;
Promise.all([viewer.pdf, page]).then(([pdf, page]) => {
render(page, canvas, pxScale);
textPromise = renderTextLayer(page, textContainer, pxScale);
Expand Down Expand Up @@ -257,7 +261,7 @@ Selectable text can be rendered in one of two ways:
<Page {page_number} track bind:width={pageWidth} bind:visible>
{#snippet children({ visible })}
{#if page_level_notes.length}
<div class={["page-notes", visible && "pin-x"]}>
<div class="page-notes pin-x">
{#each page_level_notes as note}
<Note {note} />
{/each}
Expand Down
2 changes: 1 addition & 1 deletion src/lib/components/viewer/Page.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,7 @@ Must be a child of a ViewerContext

<div {id} bind:this={container} bind:clientWidth={width} class="page">
<div class="title">{@render title?.()}</div>
<header class={visible && "pin-x"}>
<header class="pin-x">
<h4 class="pageNumber">
<a href={documentHref}>
{$_("documents.pageAbbrev")}
Expand Down
14 changes: 0 additions & 14 deletions src/lib/components/viewer/tests/PDF.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,20 +57,6 @@ describe("PDF", () => {
);
});

it("publishes the measurements that `pin-x` descendants read", () => {
const { container } = renderInViewer(PDF, {
context: { document, mode: "document" },
});

const pages = container.querySelector(".pages") as HTMLElement;

expect(pages.style.getPropertyValue("--scroll-range")).toMatch(/px$/);
expect(pages.style.getPropertyValue("--pin-width")).toMatch(/px$/);
// CSS.supports is stubbed false in vitest-setup, so this is the fallback
// path, which tracks scroll position as well
expect(pages.style.getPropertyValue("--scroll-left")).toMatch(/px$/);
});

it("shows an error view instead of pages when loading failed", () => {
const { container } = renderInViewer(PDF, {
context: {
Expand Down
4 changes: 2 additions & 2 deletions src/lib/state/viewer.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,10 @@ export class ViewerState {
pageSizes = $derived(
this.document?.page_spec ? pageSizes(this.document.page_spec) : [],
);
maxPageWidth = $derived(Math.max(...this.pageSizes.map(([w]) => w)));
autoZoomScale = $derived.by(() => {
if (!this.width) return 1;
const maxPageWidth = Math.max(...this.pageSizes.map(([w]) => w));
return Math.min(1, this.width / maxPageWidth);
return Math.min(1, this.width / this.maxPageWidth);
});
scale = $derived.by(() => {
if (typeof this.zoom === "number") return this.zoom;
Expand Down
56 changes: 0 additions & 56 deletions src/lib/utils/pinX.svelte.ts

This file was deleted.

Loading