@delightstack/components 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +136 -0
- package/SKILL.md +149 -0
- package/bin/agents.js +63 -0
- package/dist/actions/Alert.svelte +202 -0
- package/dist/actions/Alert.svelte.d.ts +36 -0
- package/dist/actions/Alert.svelte.d.ts.map +1 -0
- package/dist/actions/Button.svelte +1450 -0
- package/dist/actions/Button.svelte.d.ts +56 -0
- package/dist/actions/Button.svelte.d.ts.map +1 -0
- package/dist/actions/ButtonGroup.svelte +111 -0
- package/dist/actions/ButtonGroup.svelte.d.ts +41 -0
- package/dist/actions/ButtonGroup.svelte.d.ts.map +1 -0
- package/dist/actions/CommandPalette.svelte +939 -0
- package/dist/actions/CommandPalette.svelte.d.ts +37 -0
- package/dist/actions/CommandPalette.svelte.d.ts.map +1 -0
- package/dist/actions/ContextMenu.svelte +138 -0
- package/dist/actions/ContextMenu.svelte.d.ts +54 -0
- package/dist/actions/ContextMenu.svelte.d.ts.map +1 -0
- package/dist/actions/Modal.svelte +474 -0
- package/dist/actions/Modal.svelte.d.ts +28 -0
- package/dist/actions/Modal.svelte.d.ts.map +1 -0
- package/dist/actions/Popover.svelte +1214 -0
- package/dist/actions/Popover.svelte.d.ts +31 -0
- package/dist/actions/Popover.svelte.d.ts.map +1 -0
- package/dist/actions/Portal.svelte +80 -0
- package/dist/actions/Portal.svelte.d.ts +17 -0
- package/dist/actions/Portal.svelte.d.ts.map +1 -0
- package/dist/actions/ThemeToggle.svelte +345 -0
- package/dist/actions/ThemeToggle.svelte.d.ts +15 -0
- package/dist/actions/ThemeToggle.svelte.d.ts.map +1 -0
- package/dist/actions/index.d.ts +13 -0
- package/dist/actions/index.d.ts.map +1 -0
- package/dist/actions/index.js +10 -0
- package/dist/actions/scrollbar.d.ts +48 -0
- package/dist/actions/scrollbar.d.ts.map +1 -0
- package/dist/actions/scrollbar.js +404 -0
- package/dist/display/Accordion.svelte +586 -0
- package/dist/display/Accordion.svelte.d.ts +41 -0
- package/dist/display/Accordion.svelte.d.ts.map +1 -0
- package/dist/display/Avatar.svelte +527 -0
- package/dist/display/Avatar.svelte.d.ts +22 -0
- package/dist/display/Avatar.svelte.d.ts.map +1 -0
- package/dist/display/AvatarGroup.svelte +298 -0
- package/dist/display/AvatarGroup.svelte.d.ts +31 -0
- package/dist/display/AvatarGroup.svelte.d.ts.map +1 -0
- package/dist/display/Calendar.svelte +1366 -0
- package/dist/display/Calendar.svelte.d.ts +58 -0
- package/dist/display/Calendar.svelte.d.ts.map +1 -0
- package/dist/display/Chart.svelte +1426 -0
- package/dist/display/Chart.svelte.d.ts +35 -0
- package/dist/display/Chart.svelte.d.ts.map +1 -0
- package/dist/display/Code.svelte +780 -0
- package/dist/display/Code.svelte.d.ts +19 -0
- package/dist/display/Code.svelte.d.ts.map +1 -0
- package/dist/display/Comparison.svelte +686 -0
- package/dist/display/Comparison.svelte.d.ts +22 -0
- package/dist/display/Comparison.svelte.d.ts.map +1 -0
- package/dist/display/Counter.svelte +285 -0
- package/dist/display/Counter.svelte.d.ts +21 -0
- package/dist/display/Counter.svelte.d.ts.map +1 -0
- package/dist/display/Expand.svelte +48 -0
- package/dist/display/Expand.svelte.d.ts +9 -0
- package/dist/display/Expand.svelte.d.ts.map +1 -0
- package/dist/display/List.svelte +294 -0
- package/dist/display/List.svelte.d.ts +40 -0
- package/dist/display/List.svelte.d.ts.map +1 -0
- package/dist/display/ListContextReset.svelte +19 -0
- package/dist/display/ListContextReset.svelte.d.ts +7 -0
- package/dist/display/ListContextReset.svelte.d.ts.map +1 -0
- package/dist/display/ListItem.svelte +834 -0
- package/dist/display/ListItem.svelte.d.ts +22 -0
- package/dist/display/ListItem.svelte.d.ts.map +1 -0
- package/dist/display/QR.svelte +1193 -0
- package/dist/display/QR.svelte.d.ts +23 -0
- package/dist/display/QR.svelte.d.ts.map +1 -0
- package/dist/display/SplitPane.svelte +744 -0
- package/dist/display/SplitPane.svelte.d.ts +25 -0
- package/dist/display/SplitPane.svelte.d.ts.map +1 -0
- package/dist/display/Stat.svelte +439 -0
- package/dist/display/Stat.svelte.d.ts +24 -0
- package/dist/display/Stat.svelte.d.ts.map +1 -0
- package/dist/display/Table.svelte +4654 -0
- package/dist/display/Table.svelte.d.ts +249 -0
- package/dist/display/Table.svelte.d.ts.map +1 -0
- package/dist/display/TableCellEditor.svelte +935 -0
- package/dist/display/TableCellEditor.svelte.d.ts +58 -0
- package/dist/display/TableCellEditor.svelte.d.ts.map +1 -0
- package/dist/display/Timeline.svelte +1258 -0
- package/dist/display/Timeline.svelte.d.ts +43 -0
- package/dist/display/Timeline.svelte.d.ts.map +1 -0
- package/dist/display/Tree.svelte +1740 -0
- package/dist/display/Tree.svelte.d.ts +74 -0
- package/dist/display/Tree.svelte.d.ts.map +1 -0
- package/dist/display/Typewriter.svelte +338 -0
- package/dist/display/Typewriter.svelte.d.ts +22 -0
- package/dist/display/Typewriter.svelte.d.ts.map +1 -0
- package/dist/display/index.d.ts +24 -0
- package/dist/display/index.d.ts.map +1 -0
- package/dist/display/index.js +18 -0
- package/dist/feedback/Callout.svelte +529 -0
- package/dist/feedback/Callout.svelte.d.ts +24 -0
- package/dist/feedback/Callout.svelte.d.ts.map +1 -0
- package/dist/feedback/Confetti.svelte +631 -0
- package/dist/feedback/Confetti.svelte.d.ts +90 -0
- package/dist/feedback/Confetti.svelte.d.ts.map +1 -0
- package/dist/feedback/Progress.svelte +382 -0
- package/dist/feedback/Progress.svelte.d.ts +25 -0
- package/dist/feedback/Progress.svelte.d.ts.map +1 -0
- package/dist/feedback/Toast.svelte +967 -0
- package/dist/feedback/Toast.svelte.d.ts +54 -0
- package/dist/feedback/Toast.svelte.d.ts.map +1 -0
- package/dist/feedback/index.d.ts +7 -0
- package/dist/feedback/index.d.ts.map +1 -0
- package/dist/feedback/index.js +4 -0
- package/dist/form/Checkbox.svelte +449 -0
- package/dist/form/Checkbox.svelte.d.ts +27 -0
- package/dist/form/Checkbox.svelte.d.ts.map +1 -0
- package/dist/form/Fieldset.svelte +410 -0
- package/dist/form/Fieldset.svelte.d.ts +22 -0
- package/dist/form/Fieldset.svelte.d.ts.map +1 -0
- package/dist/form/FileUpload.svelte +934 -0
- package/dist/form/FileUpload.svelte.d.ts +41 -0
- package/dist/form/FileUpload.svelte.d.ts.map +1 -0
- package/dist/form/Form.svelte +530 -0
- package/dist/form/Form.svelte.d.ts +120 -0
- package/dist/form/Form.svelte.d.ts.map +1 -0
- package/dist/form/Input.svelte +2858 -0
- package/dist/form/Input.svelte.d.ts +66 -0
- package/dist/form/Input.svelte.d.ts.map +1 -0
- package/dist/form/Radio.svelte +507 -0
- package/dist/form/Radio.svelte.d.ts +39 -0
- package/dist/form/Radio.svelte.d.ts.map +1 -0
- package/dist/form/Range.svelte +912 -0
- package/dist/form/Range.svelte.d.ts +33 -0
- package/dist/form/Range.svelte.d.ts.map +1 -0
- package/dist/form/Rating.svelte +429 -0
- package/dist/form/Rating.svelte.d.ts +28 -0
- package/dist/form/Rating.svelte.d.ts.map +1 -0
- package/dist/form/Select.svelte +1933 -0
- package/dist/form/Select.svelte.d.ts +54 -0
- package/dist/form/Select.svelte.d.ts.map +1 -0
- package/dist/form/Toggle.svelte +645 -0
- package/dist/form/Toggle.svelte.d.ts +50 -0
- package/dist/form/Toggle.svelte.d.ts.map +1 -0
- package/dist/form/index.d.ts +15 -0
- package/dist/form/index.d.ts.map +1 -0
- package/dist/form/index.js +10 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +6 -0
- package/dist/layout/README.md +172 -0
- package/dist/media/Carousel.svelte +2424 -0
- package/dist/media/Carousel.svelte.d.ts +47 -0
- package/dist/media/Carousel.svelte.d.ts.map +1 -0
- package/dist/media/Gallery.svelte +2881 -0
- package/dist/media/Gallery.svelte.d.ts +82 -0
- package/dist/media/Gallery.svelte.d.ts.map +1 -0
- package/dist/media/Image.svelte +389 -0
- package/dist/media/Image.svelte.d.ts +33 -0
- package/dist/media/Image.svelte.d.ts.map +1 -0
- package/dist/media/PDF.svelte +1793 -0
- package/dist/media/PDF.svelte.d.ts +44 -0
- package/dist/media/PDF.svelte.d.ts.map +1 -0
- package/dist/media/Panorama.svelte +1391 -0
- package/dist/media/Panorama.svelte.d.ts +47 -0
- package/dist/media/Panorama.svelte.d.ts.map +1 -0
- package/dist/media/Video.svelte +2501 -0
- package/dist/media/Video.svelte.d.ts +58 -0
- package/dist/media/Video.svelte.d.ts.map +1 -0
- package/dist/media/carousel.d.ts +211 -0
- package/dist/media/carousel.d.ts.map +1 -0
- package/dist/media/carousel.js +408 -0
- package/dist/media/index.d.ts +11 -0
- package/dist/media/index.d.ts.map +1 -0
- package/dist/media/index.js +5 -0
- package/dist/navigation/BottomSheet.svelte +636 -0
- package/dist/navigation/BottomSheet.svelte.d.ts +27 -0
- package/dist/navigation/BottomSheet.svelte.d.ts.map +1 -0
- package/dist/navigation/Breadcrumbs.svelte +611 -0
- package/dist/navigation/Breadcrumbs.svelte.d.ts +28 -0
- package/dist/navigation/Breadcrumbs.svelte.d.ts.map +1 -0
- package/dist/navigation/Pagination.svelte +641 -0
- package/dist/navigation/Pagination.svelte.d.ts +27 -0
- package/dist/navigation/Pagination.svelte.d.ts.map +1 -0
- package/dist/navigation/Steps.svelte +965 -0
- package/dist/navigation/Steps.svelte.d.ts +43 -0
- package/dist/navigation/Steps.svelte.d.ts.map +1 -0
- package/dist/navigation/Tabs.svelte +698 -0
- package/dist/navigation/Tabs.svelte.d.ts +41 -0
- package/dist/navigation/Tabs.svelte.d.ts.map +1 -0
- package/dist/navigation/index.d.ts +8 -0
- package/dist/navigation/index.d.ts.map +1 -0
- package/dist/navigation/index.js +5 -0
- package/package.json +139 -0
|
@@ -0,0 +1,1793 @@
|
|
|
1
|
+
<script lang="ts" module>
|
|
2
|
+
export interface PDFAnnotation {
|
|
3
|
+
/** The kind of annotation */
|
|
4
|
+
type: 'highlight' | 'note';
|
|
5
|
+
/** The 1-based page number the annotation belongs to */
|
|
6
|
+
page: number;
|
|
7
|
+
/** Arbitrary annotation payload (position, text, etc.) */
|
|
8
|
+
data: unknown;
|
|
9
|
+
}
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<script lang="ts">
|
|
13
|
+
import { untrack } from 'svelte';
|
|
14
|
+
import { DelightError } from '@delightstack/utilities';
|
|
15
|
+
import { scrollbar } from '../actions/scrollbar';
|
|
16
|
+
|
|
17
|
+
const propId = $props.id();
|
|
18
|
+
|
|
19
|
+
let {
|
|
20
|
+
/** PDF source: URL string, ArrayBuffer, or Uint8Array */
|
|
21
|
+
src,
|
|
22
|
+
|
|
23
|
+
/** Current page number (1-based) */
|
|
24
|
+
page = $bindable(1),
|
|
25
|
+
|
|
26
|
+
/** Zoom level (1 = 100%) */
|
|
27
|
+
zoom = $bindable(1),
|
|
28
|
+
|
|
29
|
+
/** Rotation in degrees (0, 90, 180, 270) */
|
|
30
|
+
rotation = 0,
|
|
31
|
+
|
|
32
|
+
/** Initial fit mode */
|
|
33
|
+
fit = 'width' as 'width' | 'height' | 'page',
|
|
34
|
+
|
|
35
|
+
/** Show toolbar */
|
|
36
|
+
show_toolbar = true,
|
|
37
|
+
|
|
38
|
+
/** Show download button in toolbar */
|
|
39
|
+
show_download = true,
|
|
40
|
+
|
|
41
|
+
/** Enable text search */
|
|
42
|
+
searchable = true,
|
|
43
|
+
|
|
44
|
+
/** Enable annotations */
|
|
45
|
+
annotatable = false,
|
|
46
|
+
|
|
47
|
+
/** Container height */
|
|
48
|
+
height = '600px',
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* When true, only the current `page` is rendered (others hidden), the
|
|
52
|
+
* internal scroll container is disabled, and the page is centered to
|
|
53
|
+
* fill the container. The toolbar is typically also hidden via
|
|
54
|
+
* `show_toolbar={false}`. Used when an external orchestrator (e.g. the
|
|
55
|
+
* Carousel component) drives page navigation.
|
|
56
|
+
*/
|
|
57
|
+
single_page = false,
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* In single_page mode, automatically translate the stacked page slots so
|
|
61
|
+
* the bound `page` is shown. Defaults to true for standalone use. The
|
|
62
|
+
* Carousel sets this false because it drives the slide translation itself.
|
|
63
|
+
*/
|
|
64
|
+
auto_paginate = true,
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Multiplier applied to the rendered canvas resolution without changing
|
|
68
|
+
* the displayed CSS size. Lets an external orchestrator (Carousel pinch
|
|
69
|
+
* zoom) push more pixels into the canvas so a magnified page stays
|
|
70
|
+
* crisp. Defaults to 1; bumping to 2 quadruples the rasterized pixel
|
|
71
|
+
* count of each rendered page.
|
|
72
|
+
*/
|
|
73
|
+
pixel_density = 1,
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Show a loading skeleton while the document loads (including before
|
|
77
|
+
* `src` is available). It dismisses itself as soon as the document is
|
|
78
|
+
* ready — set to `false` to disable the built-in loading state.
|
|
79
|
+
*/
|
|
80
|
+
skeleton = true,
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Render the selectable/searchable text layer over each page. Disable it
|
|
84
|
+
* (e.g. inside a Carousel) to skip `getTextContent()` and per-glyph span
|
|
85
|
+
* layout on every page — a meaningful render-perf win when text selection
|
|
86
|
+
* isn't needed. Search still works (it extracts text separately).
|
|
87
|
+
*/
|
|
88
|
+
text_layer = true,
|
|
89
|
+
|
|
90
|
+
/** Element ID */
|
|
91
|
+
id = propId,
|
|
92
|
+
|
|
93
|
+
/** Additional CSS classes */
|
|
94
|
+
class: class_name = '',
|
|
95
|
+
|
|
96
|
+
/** Bindable element reference */
|
|
97
|
+
element = $bindable(undefined as HTMLElement | undefined),
|
|
98
|
+
|
|
99
|
+
/** Fired when the current page changes */
|
|
100
|
+
onpagechange = undefined as
|
|
101
|
+
| ((detail: { page: number; total_pages: number }) => void)
|
|
102
|
+
| undefined,
|
|
103
|
+
|
|
104
|
+
/** Fired when the PDF finishes loading */
|
|
105
|
+
onload = undefined as ((detail: { total_pages: number }) => void) | undefined,
|
|
106
|
+
|
|
107
|
+
/** Fired when the PDF fails to load */
|
|
108
|
+
onerror = undefined as ((detail: { error: Error }) => void) | undefined,
|
|
109
|
+
|
|
110
|
+
/** Fired when download is clicked */
|
|
111
|
+
ondownload = undefined as (() => void) | undefined,
|
|
112
|
+
|
|
113
|
+
/** Fired when an annotation is created */
|
|
114
|
+
onannotation = undefined as ((detail: PDFAnnotation) => void) | undefined,
|
|
115
|
+
}: {
|
|
116
|
+
src?: string | ArrayBuffer | Uint8Array;
|
|
117
|
+
page?: number;
|
|
118
|
+
zoom?: number;
|
|
119
|
+
rotation?: number;
|
|
120
|
+
fit?: 'width' | 'height' | 'page';
|
|
121
|
+
show_toolbar?: boolean;
|
|
122
|
+
show_download?: boolean;
|
|
123
|
+
searchable?: boolean;
|
|
124
|
+
annotatable?: boolean;
|
|
125
|
+
height?: string;
|
|
126
|
+
single_page?: boolean;
|
|
127
|
+
auto_paginate?: boolean;
|
|
128
|
+
pixel_density?: number;
|
|
129
|
+
skeleton?: boolean;
|
|
130
|
+
text_layer?: boolean;
|
|
131
|
+
id?: string;
|
|
132
|
+
class?: string;
|
|
133
|
+
element?: HTMLElement | undefined;
|
|
134
|
+
onpagechange?: (detail: { page: number; total_pages: number }) => void;
|
|
135
|
+
onload?: (detail: { total_pages: number }) => void;
|
|
136
|
+
onerror?: (detail: { error: Error }) => void;
|
|
137
|
+
ondownload?: () => void;
|
|
138
|
+
onannotation?: (detail: PDFAnnotation) => void;
|
|
139
|
+
} = $props();
|
|
140
|
+
|
|
141
|
+
/* ------------------------------------------------------------------ */
|
|
142
|
+
/* Types */
|
|
143
|
+
/* ------------------------------------------------------------------ */
|
|
144
|
+
|
|
145
|
+
interface PageInfo {
|
|
146
|
+
width: number;
|
|
147
|
+
height: number;
|
|
148
|
+
rendered: boolean;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
interface SearchMatch {
|
|
152
|
+
page: number;
|
|
153
|
+
index: number;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/* ------------------------------------------------------------------ */
|
|
157
|
+
/* State */
|
|
158
|
+
/* ------------------------------------------------------------------ */
|
|
159
|
+
|
|
160
|
+
let pdf_doc = $state<unknown>(undefined);
|
|
161
|
+
let total_pages = $state(0);
|
|
162
|
+
let loading = $state(true);
|
|
163
|
+
let error_message = $state('');
|
|
164
|
+
let page_infos = $state<PageInfo[]>([]);
|
|
165
|
+
let page_elements = $state<(HTMLDivElement | null)[]>([]);
|
|
166
|
+
let pages_container = $state<HTMLDivElement | null>(null);
|
|
167
|
+
let page_input_value = $state('1');
|
|
168
|
+
|
|
169
|
+
// Search state
|
|
170
|
+
let search_open = $state(false);
|
|
171
|
+
let search_query = $state('');
|
|
172
|
+
let search_matches = $state<SearchMatch[]>([]);
|
|
173
|
+
let search_current = $state(0);
|
|
174
|
+
let page_texts = $state<string[]>([]);
|
|
175
|
+
let search_input_el = $state<HTMLInputElement | null>(null);
|
|
176
|
+
|
|
177
|
+
// Annotation state
|
|
178
|
+
let annotation_mode = $state<'highlight' | 'note' | null>(null);
|
|
179
|
+
|
|
180
|
+
// PDF.js library reference
|
|
181
|
+
let pdfjs_lib: unknown = undefined;
|
|
182
|
+
|
|
183
|
+
// Track which pages are rendered
|
|
184
|
+
let rendered_pages = $state(new Set<number>());
|
|
185
|
+
|
|
186
|
+
// Track in-flight render tasks per page so rapid page switches don't
|
|
187
|
+
// fire concurrent renderPage() calls against the same canvas — that race
|
|
188
|
+
// causes `canvas.width = ...` to clear pixels in the middle of an
|
|
189
|
+
// already-running pdfjs render, making the page momentarily transparent.
|
|
190
|
+
const rendering_pages = new Map<number, Promise<void>>();
|
|
191
|
+
|
|
192
|
+
// Track the current fit mode for cycling
|
|
193
|
+
let current_fit = $state(fit);
|
|
194
|
+
|
|
195
|
+
// Prevent scroll observer feedback loop
|
|
196
|
+
let programmatic_scroll = false;
|
|
197
|
+
|
|
198
|
+
/* ------------------------------------------------------------------ */
|
|
199
|
+
/* Derived */
|
|
200
|
+
/* ------------------------------------------------------------------ */
|
|
201
|
+
|
|
202
|
+
const zoom_percent = $derived(Math.round(zoom * 100));
|
|
203
|
+
|
|
204
|
+
const search_count_text = $derived.by(() => {
|
|
205
|
+
if (search_matches.length === 0) return search_query ? 'No matches' : '';
|
|
206
|
+
return `${search_current + 1} of ${search_matches.length}`;
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
/* ------------------------------------------------------------------ */
|
|
210
|
+
/* PDF.js lazy loader */
|
|
211
|
+
/* ------------------------------------------------------------------ */
|
|
212
|
+
|
|
213
|
+
async function loadPdfJs(): Promise<unknown> {
|
|
214
|
+
if (pdfjs_lib) return pdfjs_lib;
|
|
215
|
+
// @ts-ignore — pdfjs-dist is an optional peer dependency
|
|
216
|
+
const lib = await import('pdfjs-dist');
|
|
217
|
+
const version = (lib as { version: string }).version;
|
|
218
|
+
// pdfjs-dist >= 5 auto-initializes GlobalWorkerOptions.workerSrc to a
|
|
219
|
+
// placeholder relative path ("./pdf.worker.mjs") at import time, which
|
|
220
|
+
// 404s in any non-bundled context. Always set it to a known-good CDN URL
|
|
221
|
+
// for our version. (Consumers who want a different worker can set it
|
|
222
|
+
// themselves before mounting <PDF> — we only overwrite the placeholder.)
|
|
223
|
+
const workerOptions = (lib as { GlobalWorkerOptions: { workerSrc: string } })
|
|
224
|
+
.GlobalWorkerOptions;
|
|
225
|
+
const cdnUrl = `https://unpkg.com/pdfjs-dist@${version}/build/pdf.worker.min.mjs`;
|
|
226
|
+
const isPlaceholder =
|
|
227
|
+
!workerOptions.workerSrc ||
|
|
228
|
+
workerOptions.workerSrc === './pdf.worker.mjs' ||
|
|
229
|
+
workerOptions.workerSrc === 'pdf.worker.mjs';
|
|
230
|
+
if (workerOptions && isPlaceholder) {
|
|
231
|
+
workerOptions.workerSrc = cdnUrl;
|
|
232
|
+
}
|
|
233
|
+
pdfjs_lib = lib;
|
|
234
|
+
return lib;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/* ------------------------------------------------------------------ */
|
|
238
|
+
/* Document loading */
|
|
239
|
+
/* ------------------------------------------------------------------ */
|
|
240
|
+
|
|
241
|
+
async function loadDocument(source: string | ArrayBuffer | Uint8Array) {
|
|
242
|
+
loading = true;
|
|
243
|
+
error_message = '';
|
|
244
|
+
pdf_doc = undefined;
|
|
245
|
+
total_pages = 0;
|
|
246
|
+
page_infos = [];
|
|
247
|
+
page_texts = [];
|
|
248
|
+
rendered_pages = new Set();
|
|
249
|
+
|
|
250
|
+
try {
|
|
251
|
+
const lib = (await loadPdfJs()) as Record<string, unknown>;
|
|
252
|
+
const getDocument = lib.getDocument as (params: Record<string, unknown>) => {
|
|
253
|
+
promise: Promise<unknown>;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const params: Record<string, unknown> = {};
|
|
257
|
+
if (typeof source === 'string') {
|
|
258
|
+
params.url = source;
|
|
259
|
+
} else {
|
|
260
|
+
params.data = source instanceof ArrayBuffer ? new Uint8Array(source) : source;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const doc = await getDocument(params).promise;
|
|
264
|
+
const typedDoc = doc as {
|
|
265
|
+
numPages: number;
|
|
266
|
+
getPage: (n: number) => Promise<unknown>;
|
|
267
|
+
};
|
|
268
|
+
pdf_doc = doc;
|
|
269
|
+
total_pages = typedDoc.numPages;
|
|
270
|
+
|
|
271
|
+
// Gather page dimensions
|
|
272
|
+
const infos: PageInfo[] = [];
|
|
273
|
+
for (let i = 1; i <= total_pages; i++) {
|
|
274
|
+
const pg = await typedDoc.getPage(i);
|
|
275
|
+
const typedPage = pg as {
|
|
276
|
+
getViewport: (opts: { scale: number; rotation: number }) => {
|
|
277
|
+
width: number;
|
|
278
|
+
height: number;
|
|
279
|
+
};
|
|
280
|
+
};
|
|
281
|
+
const vp = typedPage.getViewport({ scale: 1, rotation });
|
|
282
|
+
infos.push({ width: vp.width, height: vp.height, rendered: false });
|
|
283
|
+
}
|
|
284
|
+
page_infos = infos;
|
|
285
|
+
page_elements = Array.from({ length: total_pages }, () => null);
|
|
286
|
+
|
|
287
|
+
loading = false;
|
|
288
|
+
onload?.({ total_pages });
|
|
289
|
+
|
|
290
|
+
// Extract text for search (skipped in single_page mode where there's no search UI)
|
|
291
|
+
if (searchable && !single_page) {
|
|
292
|
+
extractAllText(typedDoc);
|
|
293
|
+
}
|
|
294
|
+
} catch (err) {
|
|
295
|
+
loading = false;
|
|
296
|
+
const e = err instanceof Error ? err : new DelightError(String(err));
|
|
297
|
+
error_message = e.message;
|
|
298
|
+
onerror?.({ error: e });
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function extractAllText(doc: {
|
|
303
|
+
numPages: number;
|
|
304
|
+
getPage: (n: number) => Promise<unknown>;
|
|
305
|
+
}) {
|
|
306
|
+
const texts: string[] = [];
|
|
307
|
+
for (let i = 1; i <= doc.numPages; i++) {
|
|
308
|
+
try {
|
|
309
|
+
const pg = await doc.getPage(i);
|
|
310
|
+
const typedPage = pg as {
|
|
311
|
+
getTextContent: () => Promise<{ items: { str?: string }[] }>;
|
|
312
|
+
};
|
|
313
|
+
const content = await typedPage.getTextContent();
|
|
314
|
+
const text = content.items.map((item) => item.str || '').join(' ');
|
|
315
|
+
texts.push(text);
|
|
316
|
+
} catch {
|
|
317
|
+
texts.push('');
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
page_texts = texts;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/* ------------------------------------------------------------------ */
|
|
324
|
+
/* Page rendering */
|
|
325
|
+
/* ------------------------------------------------------------------ */
|
|
326
|
+
|
|
327
|
+
async function renderPage(page_num: number) {
|
|
328
|
+
if (!pdf_doc || rendered_pages.has(page_num)) return;
|
|
329
|
+
// If a render for this page is already in flight, let it finish —
|
|
330
|
+
// reissuing now would clear the canvas mid-paint and race the first
|
|
331
|
+
// render's `pdfjs.render(...).promise` against a fresh one.
|
|
332
|
+
const inFlight = rendering_pages.get(page_num);
|
|
333
|
+
if (inFlight) return inFlight;
|
|
334
|
+
|
|
335
|
+
const task = renderPageImpl(page_num);
|
|
336
|
+
rendering_pages.set(page_num, task);
|
|
337
|
+
try {
|
|
338
|
+
await task;
|
|
339
|
+
} finally {
|
|
340
|
+
rendering_pages.delete(page_num);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function renderPageImpl(page_num: number) {
|
|
345
|
+
if (!pdf_doc) return;
|
|
346
|
+
const typedDoc = pdf_doc as { getPage: (n: number) => Promise<unknown> };
|
|
347
|
+
const pg = await typedDoc.getPage(page_num);
|
|
348
|
+
const typedPage = pg as {
|
|
349
|
+
getViewport: (opts: { scale: number; rotation: number }) => {
|
|
350
|
+
width: number;
|
|
351
|
+
height: number;
|
|
352
|
+
transform: number[];
|
|
353
|
+
};
|
|
354
|
+
render: (opts: { canvasContext: CanvasRenderingContext2D; viewport: unknown }) => {
|
|
355
|
+
promise: Promise<void>;
|
|
356
|
+
};
|
|
357
|
+
getTextContent: () => Promise<{
|
|
358
|
+
items: { str?: string; transform?: number[]; width?: number; height?: number }[];
|
|
359
|
+
}>;
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// Display viewport drives the CSS size; the render viewport multiplies
|
|
363
|
+
// `pixel_density` so the canvas holds extra pixels for crisp display
|
|
364
|
+
// when an external orchestrator scales the canvas up (e.g. pinch zoom).
|
|
365
|
+
const display_viewport = typedPage.getViewport({ scale: zoom, rotation });
|
|
366
|
+
const effective_density = Math.max(1, pixel_density || 1);
|
|
367
|
+
const render_viewport =
|
|
368
|
+
effective_density === 1
|
|
369
|
+
? display_viewport
|
|
370
|
+
: typedPage.getViewport({ scale: zoom * effective_density, rotation });
|
|
371
|
+
const container_el = page_elements[page_num - 1];
|
|
372
|
+
if (!container_el) return;
|
|
373
|
+
|
|
374
|
+
// Render into an offscreen canvas so the currently-displayed canvas
|
|
375
|
+
// (if any) keeps its pixels until the new draw is complete. Then swap
|
|
376
|
+
// the offscreen into the DOM atomically. Without this, setting
|
|
377
|
+
// `canvas.width` on an in-DOM canvas clears it to transparent and the
|
|
378
|
+
// page background appears to "vanish" until pdfjs finishes painting.
|
|
379
|
+
const next_canvas = document.createElement('canvas');
|
|
380
|
+
next_canvas.classList.add('pdf-page-canvas');
|
|
381
|
+
next_canvas.width = render_viewport.width;
|
|
382
|
+
next_canvas.height = render_viewport.height;
|
|
383
|
+
next_canvas.style.width = `${display_viewport.width}px`;
|
|
384
|
+
next_canvas.style.height = `${display_viewport.height}px`;
|
|
385
|
+
|
|
386
|
+
const ctx = next_canvas.getContext('2d');
|
|
387
|
+
if (!ctx) return;
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
await typedPage.render({ canvasContext: ctx, viewport: render_viewport }).promise;
|
|
391
|
+
} catch {
|
|
392
|
+
// render cancelled or failed, ignore
|
|
393
|
+
return;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// Swap: remove the old canvas (if any), insert the freshly painted one.
|
|
397
|
+
const old_canvas = container_el.querySelector('canvas');
|
|
398
|
+
if (old_canvas) old_canvas.remove();
|
|
399
|
+
container_el.insertBefore(next_canvas, container_el.firstChild);
|
|
400
|
+
|
|
401
|
+
// Text layer (anchored to display size so its hit targets stay aligned
|
|
402
|
+
// with the visible canvas, not the higher-resolution backing store).
|
|
403
|
+
// Skipped entirely when `text_layer` is false (e.g. inside a Carousel) —
|
|
404
|
+
// avoids getTextContent() + per-glyph span layout on every page.
|
|
405
|
+
const existing_layer = container_el.querySelector(
|
|
406
|
+
'.pdf-text-layer',
|
|
407
|
+
) as HTMLDivElement | null;
|
|
408
|
+
if (!text_layer) {
|
|
409
|
+
if (existing_layer) existing_layer.remove();
|
|
410
|
+
} else {
|
|
411
|
+
const layer_el = existing_layer ?? document.createElement('div');
|
|
412
|
+
if (!existing_layer) {
|
|
413
|
+
layer_el.classList.add('pdf-text-layer');
|
|
414
|
+
container_el.appendChild(layer_el);
|
|
415
|
+
}
|
|
416
|
+
layer_el.innerHTML = '';
|
|
417
|
+
layer_el.style.width = `${display_viewport.width}px`;
|
|
418
|
+
layer_el.style.height = `${display_viewport.height}px`;
|
|
419
|
+
|
|
420
|
+
try {
|
|
421
|
+
const content = await typedPage.getTextContent();
|
|
422
|
+
for (const item of content.items) {
|
|
423
|
+
if (!item.str) continue;
|
|
424
|
+
const span = document.createElement('span');
|
|
425
|
+
span.textContent = item.str;
|
|
426
|
+
if (item.transform) {
|
|
427
|
+
const tx = item.transform[4] * zoom;
|
|
428
|
+
const ty = display_viewport.height - item.transform[5] * zoom;
|
|
429
|
+
const font_size = Math.abs(item.transform[3]) * zoom;
|
|
430
|
+
span.style.position = 'absolute';
|
|
431
|
+
span.style.left = `${tx}px`;
|
|
432
|
+
span.style.top = `${ty - font_size}px`;
|
|
433
|
+
span.style.fontSize = `${font_size}px`;
|
|
434
|
+
span.style.fontFamily = 'sans-serif';
|
|
435
|
+
span.style.whiteSpace = 'pre';
|
|
436
|
+
}
|
|
437
|
+
layer_el.appendChild(span);
|
|
438
|
+
}
|
|
439
|
+
} catch {
|
|
440
|
+
// text extraction failed, ignore
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
// Update container size — skipped in single_page mode where the slot
|
|
445
|
+
// is sized to fill the slide and the canvas is centered inside it.
|
|
446
|
+
if (!single_page) {
|
|
447
|
+
container_el.style.width = `${display_viewport.width}px`;
|
|
448
|
+
container_el.style.height = `${display_viewport.height}px`;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
rendered_pages = new Set([...rendered_pages, page_num]);
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function clearRenderedPages() {
|
|
455
|
+
rendered_pages = new Set();
|
|
456
|
+
// Drop the in-flight render registrations too — any pending render
|
|
457
|
+
// will still complete and swap its (possibly stale) canvas into the
|
|
458
|
+
// DOM, but the next render call for that page will not be short-
|
|
459
|
+
// circuited by a stale promise.
|
|
460
|
+
rendering_pages.clear();
|
|
461
|
+
// Note: deliberately DO NOT remove the existing <canvas> nodes here.
|
|
462
|
+
// renderPage paints into an offscreen canvas and only swaps it into
|
|
463
|
+
// the DOM once the new render is complete, so leaving the old one
|
|
464
|
+
// in place prevents the page from going transparent during the
|
|
465
|
+
// re-render (which is what happens when zoom or pixel_density
|
|
466
|
+
// changes while the user is mid-interaction).
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/* ------------------------------------------------------------------ */
|
|
470
|
+
/* Virtualized rendering with IntersectionObserver */
|
|
471
|
+
/* ------------------------------------------------------------------ */
|
|
472
|
+
|
|
473
|
+
function setupObserver() {
|
|
474
|
+
if (!pages_container || typeof IntersectionObserver === 'undefined') return;
|
|
475
|
+
// In single_page mode the consumer drives the page index externally,
|
|
476
|
+
// so we skip the observer (it would fight the external `page` prop).
|
|
477
|
+
if (single_page) return;
|
|
478
|
+
|
|
479
|
+
const observer = new IntersectionObserver(
|
|
480
|
+
(entries) => {
|
|
481
|
+
for (const entry of entries) {
|
|
482
|
+
const el = entry.target as HTMLDivElement;
|
|
483
|
+
const page_num = parseInt(el.dataset.page || '0', 10);
|
|
484
|
+
if (!page_num) continue;
|
|
485
|
+
|
|
486
|
+
if (entry.isIntersecting) {
|
|
487
|
+
renderPage(page_num);
|
|
488
|
+
|
|
489
|
+
// Also render neighbors
|
|
490
|
+
if (page_num > 1) renderPage(page_num - 1);
|
|
491
|
+
if (page_num < total_pages) renderPage(page_num + 1);
|
|
492
|
+
|
|
493
|
+
// Update current page from scroll
|
|
494
|
+
if (!programmatic_scroll) {
|
|
495
|
+
if (entry.intersectionRatio > 0.3 || entry.isIntersecting) {
|
|
496
|
+
const new_page = page_num;
|
|
497
|
+
if (new_page !== page) {
|
|
498
|
+
page = new_page;
|
|
499
|
+
page_input_value = String(new_page);
|
|
500
|
+
onpagechange?.({ page: new_page, total_pages });
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
},
|
|
507
|
+
{
|
|
508
|
+
root: pages_container,
|
|
509
|
+
rootMargin: '200px 0px',
|
|
510
|
+
threshold: [0, 0.3, 0.5, 1],
|
|
511
|
+
},
|
|
512
|
+
);
|
|
513
|
+
|
|
514
|
+
for (const el of page_elements) {
|
|
515
|
+
if (el) observer.observe(el);
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
return () => observer.disconnect();
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/* ------------------------------------------------------------------ */
|
|
522
|
+
/* Navigation */
|
|
523
|
+
/* ------------------------------------------------------------------ */
|
|
524
|
+
|
|
525
|
+
function goToPage(target: number) {
|
|
526
|
+
const clamped = Math.max(1, Math.min(target, total_pages));
|
|
527
|
+
page = clamped;
|
|
528
|
+
page_input_value = String(clamped);
|
|
529
|
+
onpagechange?.({ page: clamped, total_pages });
|
|
530
|
+
scrollToPage(clamped);
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function scrollToPage(page_num: number) {
|
|
534
|
+
const el = page_elements[page_num - 1];
|
|
535
|
+
if (!el || !pages_container) return;
|
|
536
|
+
programmatic_scroll = true;
|
|
537
|
+
// Scroll the pages container directly. Compute the offset using
|
|
538
|
+
// bounding rects so it works regardless of which ancestor is the
|
|
539
|
+
// element's offsetParent — `el.scrollIntoView` walks all scrollable
|
|
540
|
+
// ancestors (including the window), which can result in the outer page
|
|
541
|
+
// scrolling instead of the PDF viewer when both are scrollable.
|
|
542
|
+
const elRect = el.getBoundingClientRect();
|
|
543
|
+
const containerRect = pages_container.getBoundingClientRect();
|
|
544
|
+
const target = pages_container.scrollTop + (elRect.top - containerRect.top);
|
|
545
|
+
pages_container.scrollTo({ top: target, behavior: 'smooth' });
|
|
546
|
+
setTimeout(() => {
|
|
547
|
+
programmatic_scroll = false;
|
|
548
|
+
}, 500);
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
function handlePageInput(e: Event) {
|
|
552
|
+
const input = e.target as HTMLInputElement;
|
|
553
|
+
const val = parseInt(input.value, 10);
|
|
554
|
+
if (!isNaN(val) && val >= 1 && val <= total_pages) {
|
|
555
|
+
goToPage(val);
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function handlePageInputKeydown(e: KeyboardEvent) {
|
|
560
|
+
if (e.key === 'Enter') {
|
|
561
|
+
handlePageInput(e);
|
|
562
|
+
(e.target as HTMLInputElement).blur();
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
/* ------------------------------------------------------------------ */
|
|
567
|
+
/* Zoom */
|
|
568
|
+
/* ------------------------------------------------------------------ */
|
|
569
|
+
|
|
570
|
+
function setZoom(new_zoom: number) {
|
|
571
|
+
const clamped = Math.max(0.25, Math.min(4, new_zoom));
|
|
572
|
+
// Tolerate floating-point drift so applyFit() — which is called on
|
|
573
|
+
// every page change — doesn't wipe the rendered canvases when the
|
|
574
|
+
// computed fit zoom is "the same" but differs by a hair.
|
|
575
|
+
if (Math.abs(clamped - zoom) < 0.001) return;
|
|
576
|
+
zoom = clamped;
|
|
577
|
+
clearRenderedPages();
|
|
578
|
+
requestAnimationFrame(() => {
|
|
579
|
+
renderVisiblePages();
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function zoomIn() {
|
|
584
|
+
const steps = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4];
|
|
585
|
+
const next = steps.find((s) => s > zoom);
|
|
586
|
+
setZoom(next ?? 4);
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function zoomOut() {
|
|
590
|
+
const steps = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 3, 4];
|
|
591
|
+
const prev = [...steps].reverse().find((s) => s < zoom);
|
|
592
|
+
setZoom(prev ?? 0.25);
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function resetZoom() {
|
|
596
|
+
setZoom(1);
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/* ------------------------------------------------------------------ */
|
|
600
|
+
/* Fit mode */
|
|
601
|
+
/* ------------------------------------------------------------------ */
|
|
602
|
+
|
|
603
|
+
function cycleFit() {
|
|
604
|
+
const modes: ('width' | 'height' | 'page')[] = ['width', 'page'];
|
|
605
|
+
const idx = modes.indexOf(current_fit);
|
|
606
|
+
current_fit = modes[(idx + 1) % modes.length];
|
|
607
|
+
applyFit();
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function applyFit() {
|
|
611
|
+
if (!pages_container || page_infos.length === 0) return;
|
|
612
|
+
const container_rect = pages_container.getBoundingClientRect();
|
|
613
|
+
// In single_page mode the active page may be a different size than page 1.
|
|
614
|
+
const ref_page = single_page
|
|
615
|
+
? page_infos[Math.max(0, Math.min(page_infos.length - 1, page - 1))]
|
|
616
|
+
: page_infos[0];
|
|
617
|
+
if (!ref_page) return;
|
|
618
|
+
|
|
619
|
+
// In single_page mode the parent (e.g. Carousel) handles its own framing;
|
|
620
|
+
// don't reserve internal padding.
|
|
621
|
+
const padding = single_page ? 0 : 32;
|
|
622
|
+
const available_width = container_rect.width - padding;
|
|
623
|
+
const available_height = container_rect.height - padding;
|
|
624
|
+
|
|
625
|
+
if (current_fit === 'width') {
|
|
626
|
+
setZoom(available_width / ref_page.width);
|
|
627
|
+
} else if (current_fit === 'height') {
|
|
628
|
+
setZoom(available_height / ref_page.height);
|
|
629
|
+
} else {
|
|
630
|
+
// 'page' - fit the whole page
|
|
631
|
+
const scale_w = available_width / ref_page.width;
|
|
632
|
+
const scale_h = available_height / ref_page.height;
|
|
633
|
+
setZoom(Math.min(scale_w, scale_h));
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/* ------------------------------------------------------------------ */
|
|
638
|
+
/* Render visible pages helper */
|
|
639
|
+
/* ------------------------------------------------------------------ */
|
|
640
|
+
|
|
641
|
+
function renderVisiblePages() {
|
|
642
|
+
if (!pages_container) return;
|
|
643
|
+
// In single_page mode, render the active page plus its immediate
|
|
644
|
+
// neighbors so that a vertical swipe in the parent Carousel can
|
|
645
|
+
// reveal the next/prev page mid-gesture without a flash of empty
|
|
646
|
+
// space. Distant pages stay unrendered to keep memory in check.
|
|
647
|
+
if (single_page) {
|
|
648
|
+
renderPage(page);
|
|
649
|
+
if (page > 1) renderPage(page - 1);
|
|
650
|
+
if (page < total_pages) renderPage(page + 1);
|
|
651
|
+
return;
|
|
652
|
+
}
|
|
653
|
+
const rect = pages_container.getBoundingClientRect();
|
|
654
|
+
for (let i = 0; i < page_elements.length; i++) {
|
|
655
|
+
const el = page_elements[i];
|
|
656
|
+
if (!el) continue;
|
|
657
|
+
const er = el.getBoundingClientRect();
|
|
658
|
+
if (er.bottom >= rect.top - 200 && er.top <= rect.bottom + 200) {
|
|
659
|
+
renderPage(i + 1);
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
|
|
664
|
+
/* ------------------------------------------------------------------ */
|
|
665
|
+
/* Search */
|
|
666
|
+
/* ------------------------------------------------------------------ */
|
|
667
|
+
|
|
668
|
+
function openSearch() {
|
|
669
|
+
if (!searchable) return;
|
|
670
|
+
search_open = true;
|
|
671
|
+
requestAnimationFrame(() => {
|
|
672
|
+
search_input_el?.focus();
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function closeSearch() {
|
|
677
|
+
search_open = false;
|
|
678
|
+
search_query = '';
|
|
679
|
+
search_matches = [];
|
|
680
|
+
search_current = 0;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
function performSearch(query: string) {
|
|
684
|
+
if (!query.trim()) {
|
|
685
|
+
search_matches = [];
|
|
686
|
+
search_current = 0;
|
|
687
|
+
return;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
const lower_query = query.toLowerCase();
|
|
691
|
+
const matches: SearchMatch[] = [];
|
|
692
|
+
|
|
693
|
+
for (let i = 0; i < page_texts.length; i++) {
|
|
694
|
+
const text = page_texts[i].toLowerCase();
|
|
695
|
+
let start = 0;
|
|
696
|
+
let idx = text.indexOf(lower_query, start);
|
|
697
|
+
while (idx !== -1) {
|
|
698
|
+
matches.push({ page: i + 1, index: idx });
|
|
699
|
+
start = idx + 1;
|
|
700
|
+
idx = text.indexOf(lower_query, start);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
search_matches = matches;
|
|
705
|
+
search_current = matches.length > 0 ? 0 : 0;
|
|
706
|
+
|
|
707
|
+
if (matches.length > 0) {
|
|
708
|
+
goToPage(matches[0].page);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function searchNext() {
|
|
713
|
+
if (search_matches.length === 0) return;
|
|
714
|
+
search_current = (search_current + 1) % search_matches.length;
|
|
715
|
+
goToPage(search_matches[search_current].page);
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function searchPrev() {
|
|
719
|
+
if (search_matches.length === 0) return;
|
|
720
|
+
search_current = (search_current - 1 + search_matches.length) % search_matches.length;
|
|
721
|
+
goToPage(search_matches[search_current].page);
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
function handleSearchInput(e: Event) {
|
|
725
|
+
search_query = (e.target as HTMLInputElement).value;
|
|
726
|
+
performSearch(search_query);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
function handleSearchKeydown(e: KeyboardEvent) {
|
|
730
|
+
if (e.key === 'Enter') {
|
|
731
|
+
if (e.shiftKey) {
|
|
732
|
+
searchPrev();
|
|
733
|
+
} else {
|
|
734
|
+
searchNext();
|
|
735
|
+
}
|
|
736
|
+
} else if (e.key === 'Escape') {
|
|
737
|
+
closeSearch();
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/* ------------------------------------------------------------------ */
|
|
742
|
+
/* Download */
|
|
743
|
+
/* ------------------------------------------------------------------ */
|
|
744
|
+
|
|
745
|
+
function handleDownload() {
|
|
746
|
+
ondownload?.();
|
|
747
|
+
|
|
748
|
+
if (src == null) return;
|
|
749
|
+
if (typeof src === 'string') {
|
|
750
|
+
const a = document.createElement('a');
|
|
751
|
+
a.href = src;
|
|
752
|
+
a.download = src.split('/').pop() || 'document.pdf';
|
|
753
|
+
a.click();
|
|
754
|
+
} else {
|
|
755
|
+
const blob = new Blob([src], { type: 'application/pdf' });
|
|
756
|
+
const url = URL.createObjectURL(blob);
|
|
757
|
+
const a = document.createElement('a');
|
|
758
|
+
a.href = url;
|
|
759
|
+
a.download = 'document.pdf';
|
|
760
|
+
a.click();
|
|
761
|
+
URL.revokeObjectURL(url);
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/* ------------------------------------------------------------------ */
|
|
766
|
+
/* Annotations */
|
|
767
|
+
/* ------------------------------------------------------------------ */
|
|
768
|
+
|
|
769
|
+
function handlePageMouseUp(page_num: number) {
|
|
770
|
+
if (!annotatable) return;
|
|
771
|
+
const selection = window.getSelection();
|
|
772
|
+
if (!selection || selection.isCollapsed) return;
|
|
773
|
+
|
|
774
|
+
if (annotation_mode === 'highlight') {
|
|
775
|
+
const text = selection.toString();
|
|
776
|
+
if (text) {
|
|
777
|
+
onannotation?.({ type: 'highlight', page: page_num, data: { text } });
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
function handlePageClick(e: MouseEvent, page_num: number) {
|
|
783
|
+
if (!annotatable || annotation_mode !== 'note') return;
|
|
784
|
+
const target = e.currentTarget as HTMLDivElement;
|
|
785
|
+
const rect = target.getBoundingClientRect();
|
|
786
|
+
const x = e.clientX - rect.left;
|
|
787
|
+
const y = e.clientY - rect.top;
|
|
788
|
+
onannotation?.({ type: 'note', page: page_num, data: { x, y } });
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
/* ------------------------------------------------------------------ */
|
|
792
|
+
/* Keyboard shortcuts */
|
|
793
|
+
/* ------------------------------------------------------------------ */
|
|
794
|
+
|
|
795
|
+
function handleKeydown(e: KeyboardEvent) {
|
|
796
|
+
const is_mac =
|
|
797
|
+
typeof navigator !== 'undefined' && navigator.platform?.includes('Mac');
|
|
798
|
+
const mod = is_mac ? e.metaKey : e.ctrlKey;
|
|
799
|
+
|
|
800
|
+
if (mod && e.key === 'f' && searchable) {
|
|
801
|
+
e.preventDefault();
|
|
802
|
+
openSearch();
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
if (e.key === 'Escape' && search_open) {
|
|
807
|
+
closeSearch();
|
|
808
|
+
return;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
if (mod && (e.key === '=' || e.key === '+')) {
|
|
812
|
+
e.preventDefault();
|
|
813
|
+
zoomIn();
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
if (mod && e.key === '-') {
|
|
818
|
+
e.preventDefault();
|
|
819
|
+
zoomOut();
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
if (mod && e.key === '0') {
|
|
824
|
+
e.preventDefault();
|
|
825
|
+
resetZoom();
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
// Don't handle navigation keys when input is focused
|
|
830
|
+
if ((e.target as HTMLElement)?.tagName === 'INPUT') return;
|
|
831
|
+
|
|
832
|
+
switch (e.key) {
|
|
833
|
+
case 'ArrowLeft':
|
|
834
|
+
case 'PageUp':
|
|
835
|
+
e.preventDefault();
|
|
836
|
+
goToPage(page - 1);
|
|
837
|
+
break;
|
|
838
|
+
case 'ArrowRight':
|
|
839
|
+
case 'PageDown':
|
|
840
|
+
e.preventDefault();
|
|
841
|
+
goToPage(page + 1);
|
|
842
|
+
break;
|
|
843
|
+
case 'Home':
|
|
844
|
+
e.preventDefault();
|
|
845
|
+
goToPage(1);
|
|
846
|
+
break;
|
|
847
|
+
case 'End':
|
|
848
|
+
e.preventDefault();
|
|
849
|
+
goToPage(total_pages);
|
|
850
|
+
break;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
/* ------------------------------------------------------------------ */
|
|
855
|
+
/* Placeholder dimensions for unrendered pages */
|
|
856
|
+
/* ------------------------------------------------------------------ */
|
|
857
|
+
|
|
858
|
+
function getPageStyle(index: number): string {
|
|
859
|
+
const info = page_infos[index];
|
|
860
|
+
if (!info) return '';
|
|
861
|
+
const w = info.width * zoom;
|
|
862
|
+
const h = info.height * zoom;
|
|
863
|
+
return `width: ${w}px; height: ${h}px;`;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/* ------------------------------------------------------------------ */
|
|
867
|
+
/* Effects */
|
|
868
|
+
/* ------------------------------------------------------------------ */
|
|
869
|
+
|
|
870
|
+
// Load document when src changes
|
|
871
|
+
$effect(() => {
|
|
872
|
+
void src;
|
|
873
|
+
if (typeof window === 'undefined') return;
|
|
874
|
+
if (src == null) {
|
|
875
|
+
// No document yet (e.g. progressively assigned) — stay in the
|
|
876
|
+
// loading state so the skeleton shows instead of an error.
|
|
877
|
+
loading = true;
|
|
878
|
+
return;
|
|
879
|
+
}
|
|
880
|
+
loadDocument(src);
|
|
881
|
+
});
|
|
882
|
+
|
|
883
|
+
// Setup intersection observer after pages mount
|
|
884
|
+
$effect(() => {
|
|
885
|
+
if (total_pages > 0 && pages_container && page_elements.length > 0) {
|
|
886
|
+
// Wait for elements to be in DOM
|
|
887
|
+
const timer = setTimeout(() => {
|
|
888
|
+
const cleanup = setupObserver();
|
|
889
|
+
applyFit();
|
|
890
|
+
return cleanup;
|
|
891
|
+
}, 50);
|
|
892
|
+
return () => clearTimeout(timer);
|
|
893
|
+
}
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
// Re-render when zoom/rotation/pixel_density changes — wrap body in
|
|
897
|
+
// untrack so `clearRenderedPages` (which iterates `page_elements`
|
|
898
|
+
// reactively) doesn't pull every page_elements mutation into this
|
|
899
|
+
// effect's dependency set.
|
|
900
|
+
$effect(() => {
|
|
901
|
+
void zoom;
|
|
902
|
+
void rotation;
|
|
903
|
+
void pixel_density;
|
|
904
|
+
untrack(() => {
|
|
905
|
+
if (total_pages > 0) {
|
|
906
|
+
clearRenderedPages();
|
|
907
|
+
requestAnimationFrame(() => renderVisiblePages());
|
|
908
|
+
}
|
|
909
|
+
});
|
|
910
|
+
});
|
|
911
|
+
|
|
912
|
+
// Sync page input value when page prop changes externally
|
|
913
|
+
$effect(() => {
|
|
914
|
+
page_input_value = String(page);
|
|
915
|
+
});
|
|
916
|
+
|
|
917
|
+
// React to external page changes — fire ONLY when `page` itself changes.
|
|
918
|
+
// applyFit/scrollToPage transitively read other reactive state (page_infos,
|
|
919
|
+
// page_elements, etc.) which mutate during rendering, so wrap the body in
|
|
920
|
+
// untrack() to keep this effect from running on every mutation.
|
|
921
|
+
$effect(() => {
|
|
922
|
+
void page;
|
|
923
|
+
untrack(() => {
|
|
924
|
+
if (total_pages > 0 && page >= 1 && page <= total_pages) {
|
|
925
|
+
if (single_page) {
|
|
926
|
+
// In single_page mode the active + adjacent pages are all
|
|
927
|
+
// rendered into the same DOM, stacked vertically. Switching
|
|
928
|
+
// to a neighboring page just needs to render any NEW
|
|
929
|
+
// neighbor that isn't already drawn — don't `clearRenderedPages`
|
|
930
|
+
// or the current page's canvas vanishes for a frame, leaving
|
|
931
|
+
// the slot transparent until pdfjs finishes the re-render.
|
|
932
|
+
// applyFit can change `zoom` if pages differ in size; the
|
|
933
|
+
// zoom effect handles a full re-render in that case.
|
|
934
|
+
const prevZoom = zoom;
|
|
935
|
+
applyFit();
|
|
936
|
+
if (prevZoom === zoom) {
|
|
937
|
+
requestAnimationFrame(() => renderVisiblePages());
|
|
938
|
+
}
|
|
939
|
+
} else {
|
|
940
|
+
scrollToPage(page);
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
});
|
|
944
|
+
});
|
|
945
|
+
</script>
|
|
946
|
+
|
|
947
|
+
<!-- svelte-ignore a11y_no_noninteractive_tabindex -->
|
|
948
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
949
|
+
<div
|
|
950
|
+
{id}
|
|
951
|
+
class={['pdf-container', class_name].filter(Boolean).join(' ')}
|
|
952
|
+
class:single-page={single_page}
|
|
953
|
+
class:auto-paginate={single_page && auto_paginate}
|
|
954
|
+
style:--pdf-height={height}
|
|
955
|
+
bind:this={element}
|
|
956
|
+
onkeydown={handleKeydown}
|
|
957
|
+
tabindex="0"
|
|
958
|
+
role="document"
|
|
959
|
+
aria-label="PDF viewer">
|
|
960
|
+
{#if skeleton && loading && !single_page}
|
|
961
|
+
<!-- Skeleton / Loading (suppressed in single_page mode — used inside
|
|
962
|
+
the Carousel, which prefers a blank slide over a placeholder UI
|
|
963
|
+
while the document is being fetched). -->
|
|
964
|
+
<div class="skeleton">
|
|
965
|
+
{#if show_toolbar}
|
|
966
|
+
<div class="bar">
|
|
967
|
+
<div class="block" style="width: 6rem; height: 1.5rem;"></div>
|
|
968
|
+
<div class="block" style="width: 4rem; height: 1.5rem;"></div>
|
|
969
|
+
</div>
|
|
970
|
+
{/if}
|
|
971
|
+
<div class="page">
|
|
972
|
+
<div class="paper">
|
|
973
|
+
{#each { length: 13 } as _, i}
|
|
974
|
+
<div
|
|
975
|
+
class="line"
|
|
976
|
+
style:width="{45 + ((i * 37) % 50)}%"
|
|
977
|
+
style:--shimmer-delay="{i * 90}ms">
|
|
978
|
+
</div>
|
|
979
|
+
{/each}
|
|
980
|
+
</div>
|
|
981
|
+
</div>
|
|
982
|
+
</div>
|
|
983
|
+
{:else if error_message}
|
|
984
|
+
<!-- Error state -->
|
|
985
|
+
<div class="error">
|
|
986
|
+
<svg
|
|
987
|
+
width="48"
|
|
988
|
+
height="48"
|
|
989
|
+
viewBox="0 0 24 24"
|
|
990
|
+
fill="none"
|
|
991
|
+
stroke="currentColor"
|
|
992
|
+
stroke-width="1.5"
|
|
993
|
+
stroke-linecap="round"
|
|
994
|
+
stroke-linejoin="round"
|
|
995
|
+
aria-hidden="true">
|
|
996
|
+
<circle cx="12" cy="12" r="10" />
|
|
997
|
+
<line x1="12" y1="8" x2="12" y2="12" />
|
|
998
|
+
<line x1="12" y1="16" x2="12.01" y2="16" />
|
|
999
|
+
</svg>
|
|
1000
|
+
<span>Failed to load PDF</span>
|
|
1001
|
+
<span class="detail">{error_message}</span>
|
|
1002
|
+
</div>
|
|
1003
|
+
{:else}
|
|
1004
|
+
<!-- Toolbar -->
|
|
1005
|
+
{#if show_toolbar}
|
|
1006
|
+
<div class="toolbar">
|
|
1007
|
+
<!-- Page navigation -->
|
|
1008
|
+
<div class="group">
|
|
1009
|
+
<button
|
|
1010
|
+
type="button"
|
|
1011
|
+
class="btn"
|
|
1012
|
+
onclick={() => goToPage(page - 1)}
|
|
1013
|
+
disabled={page <= 1}
|
|
1014
|
+
aria-label="Previous page">
|
|
1015
|
+
<svg
|
|
1016
|
+
width="16"
|
|
1017
|
+
height="16"
|
|
1018
|
+
viewBox="0 0 16 16"
|
|
1019
|
+
fill="none"
|
|
1020
|
+
aria-hidden="true">
|
|
1021
|
+
<path
|
|
1022
|
+
d="M10 3L5 8L10 13"
|
|
1023
|
+
stroke="currentColor"
|
|
1024
|
+
stroke-width="1.5"
|
|
1025
|
+
stroke-linecap="round"
|
|
1026
|
+
stroke-linejoin="round" />
|
|
1027
|
+
</svg>
|
|
1028
|
+
</button>
|
|
1029
|
+
|
|
1030
|
+
<input
|
|
1031
|
+
type="text"
|
|
1032
|
+
value={page_input_value}
|
|
1033
|
+
oninput={(e) => {
|
|
1034
|
+
page_input_value = (e.target as HTMLInputElement).value;
|
|
1035
|
+
}}
|
|
1036
|
+
onblur={handlePageInput}
|
|
1037
|
+
onkeydown={handlePageInputKeydown}
|
|
1038
|
+
aria-label="Page number" />
|
|
1039
|
+
<span class="total">/ {total_pages}</span>
|
|
1040
|
+
|
|
1041
|
+
<button
|
|
1042
|
+
type="button"
|
|
1043
|
+
class="btn"
|
|
1044
|
+
onclick={() => goToPage(page + 1)}
|
|
1045
|
+
disabled={page >= total_pages}
|
|
1046
|
+
aria-label="Next page">
|
|
1047
|
+
<svg
|
|
1048
|
+
width="16"
|
|
1049
|
+
height="16"
|
|
1050
|
+
viewBox="0 0 16 16"
|
|
1051
|
+
fill="none"
|
|
1052
|
+
aria-hidden="true">
|
|
1053
|
+
<path
|
|
1054
|
+
d="M6 3L11 8L6 13"
|
|
1055
|
+
stroke="currentColor"
|
|
1056
|
+
stroke-width="1.5"
|
|
1057
|
+
stroke-linecap="round"
|
|
1058
|
+
stroke-linejoin="round" />
|
|
1059
|
+
</svg>
|
|
1060
|
+
</button>
|
|
1061
|
+
</div>
|
|
1062
|
+
|
|
1063
|
+
<div class="separator"></div>
|
|
1064
|
+
|
|
1065
|
+
<!-- Zoom controls -->
|
|
1066
|
+
<div class="group">
|
|
1067
|
+
<button
|
|
1068
|
+
type="button"
|
|
1069
|
+
class="btn"
|
|
1070
|
+
onclick={zoomOut}
|
|
1071
|
+
disabled={zoom <= 0.25}
|
|
1072
|
+
aria-label="Zoom out">
|
|
1073
|
+
<svg
|
|
1074
|
+
width="16"
|
|
1075
|
+
height="16"
|
|
1076
|
+
viewBox="0 0 16 16"
|
|
1077
|
+
fill="none"
|
|
1078
|
+
aria-hidden="true">
|
|
1079
|
+
<path
|
|
1080
|
+
d="M3 8H13"
|
|
1081
|
+
stroke="currentColor"
|
|
1082
|
+
stroke-width="1.5"
|
|
1083
|
+
stroke-linecap="round" />
|
|
1084
|
+
</svg>
|
|
1085
|
+
</button>
|
|
1086
|
+
|
|
1087
|
+
<span class="zoom">{zoom_percent}%</span>
|
|
1088
|
+
|
|
1089
|
+
<button
|
|
1090
|
+
type="button"
|
|
1091
|
+
class="btn"
|
|
1092
|
+
onclick={zoomIn}
|
|
1093
|
+
disabled={zoom >= 4}
|
|
1094
|
+
aria-label="Zoom in">
|
|
1095
|
+
<svg
|
|
1096
|
+
width="16"
|
|
1097
|
+
height="16"
|
|
1098
|
+
viewBox="0 0 16 16"
|
|
1099
|
+
fill="none"
|
|
1100
|
+
aria-hidden="true">
|
|
1101
|
+
<path
|
|
1102
|
+
d="M8 3V13M3 8H13"
|
|
1103
|
+
stroke="currentColor"
|
|
1104
|
+
stroke-width="1.5"
|
|
1105
|
+
stroke-linecap="round" />
|
|
1106
|
+
</svg>
|
|
1107
|
+
</button>
|
|
1108
|
+
</div>
|
|
1109
|
+
|
|
1110
|
+
<div class="separator"></div>
|
|
1111
|
+
|
|
1112
|
+
<!-- Fit mode -->
|
|
1113
|
+
<div class="group">
|
|
1114
|
+
<button
|
|
1115
|
+
type="button"
|
|
1116
|
+
class="btn"
|
|
1117
|
+
onclick={cycleFit}
|
|
1118
|
+
aria-label="Toggle fit mode ({current_fit})"
|
|
1119
|
+
title="Fit: {current_fit}">
|
|
1120
|
+
{#if current_fit === 'width'}
|
|
1121
|
+
<svg
|
|
1122
|
+
width="16"
|
|
1123
|
+
height="16"
|
|
1124
|
+
viewBox="0 0 16 16"
|
|
1125
|
+
fill="none"
|
|
1126
|
+
aria-hidden="true">
|
|
1127
|
+
<path
|
|
1128
|
+
d="M2 4V12M14 4V12M4 8H12M4 6L2 8L4 10M12 6L14 8L12 10"
|
|
1129
|
+
stroke="currentColor"
|
|
1130
|
+
stroke-width="1.25"
|
|
1131
|
+
stroke-linecap="round"
|
|
1132
|
+
stroke-linejoin="round" />
|
|
1133
|
+
</svg>
|
|
1134
|
+
{:else}
|
|
1135
|
+
<svg
|
|
1136
|
+
width="16"
|
|
1137
|
+
height="16"
|
|
1138
|
+
viewBox="0 0 16 16"
|
|
1139
|
+
fill="none"
|
|
1140
|
+
aria-hidden="true">
|
|
1141
|
+
<rect
|
|
1142
|
+
x="3"
|
|
1143
|
+
y="2"
|
|
1144
|
+
width="10"
|
|
1145
|
+
height="12"
|
|
1146
|
+
rx="1"
|
|
1147
|
+
stroke="currentColor"
|
|
1148
|
+
stroke-width="1.25" />
|
|
1149
|
+
</svg>
|
|
1150
|
+
{/if}
|
|
1151
|
+
</button>
|
|
1152
|
+
</div>
|
|
1153
|
+
|
|
1154
|
+
<div class="spacer"></div>
|
|
1155
|
+
|
|
1156
|
+
<!-- Search button -->
|
|
1157
|
+
{#if searchable}
|
|
1158
|
+
<button
|
|
1159
|
+
type="button"
|
|
1160
|
+
class="btn"
|
|
1161
|
+
class:active={search_open}
|
|
1162
|
+
onclick={() => (search_open ? closeSearch() : openSearch())}
|
|
1163
|
+
aria-label="Search">
|
|
1164
|
+
<svg
|
|
1165
|
+
width="16"
|
|
1166
|
+
height="16"
|
|
1167
|
+
viewBox="0 0 16 16"
|
|
1168
|
+
fill="none"
|
|
1169
|
+
aria-hidden="true">
|
|
1170
|
+
<circle cx="7" cy="7" r="4" stroke="currentColor" stroke-width="1.5" />
|
|
1171
|
+
<path
|
|
1172
|
+
d="M10 10L13 13"
|
|
1173
|
+
stroke="currentColor"
|
|
1174
|
+
stroke-width="1.5"
|
|
1175
|
+
stroke-linecap="round" />
|
|
1176
|
+
</svg>
|
|
1177
|
+
</button>
|
|
1178
|
+
{/if}
|
|
1179
|
+
|
|
1180
|
+
<!-- Download button -->
|
|
1181
|
+
{#if show_download}
|
|
1182
|
+
<button
|
|
1183
|
+
type="button"
|
|
1184
|
+
class="btn"
|
|
1185
|
+
onclick={handleDownload}
|
|
1186
|
+
aria-label="Download">
|
|
1187
|
+
<svg
|
|
1188
|
+
width="16"
|
|
1189
|
+
height="16"
|
|
1190
|
+
viewBox="0 0 16 16"
|
|
1191
|
+
fill="none"
|
|
1192
|
+
aria-hidden="true">
|
|
1193
|
+
<path
|
|
1194
|
+
d="M8 2V10M8 10L5 7M8 10L11 7"
|
|
1195
|
+
stroke="currentColor"
|
|
1196
|
+
stroke-width="1.5"
|
|
1197
|
+
stroke-linecap="round"
|
|
1198
|
+
stroke-linejoin="round" />
|
|
1199
|
+
<path
|
|
1200
|
+
d="M3 13H13"
|
|
1201
|
+
stroke="currentColor"
|
|
1202
|
+
stroke-width="1.5"
|
|
1203
|
+
stroke-linecap="round" />
|
|
1204
|
+
</svg>
|
|
1205
|
+
</button>
|
|
1206
|
+
{/if}
|
|
1207
|
+
|
|
1208
|
+
<!-- Annotation toggle -->
|
|
1209
|
+
{#if annotatable}
|
|
1210
|
+
<div class="separator"></div>
|
|
1211
|
+
<div class="group">
|
|
1212
|
+
<button
|
|
1213
|
+
type="button"
|
|
1214
|
+
class="btn"
|
|
1215
|
+
class:active={annotation_mode === 'highlight'}
|
|
1216
|
+
onclick={() =>
|
|
1217
|
+
(annotation_mode = annotation_mode === 'highlight' ? null : 'highlight')}
|
|
1218
|
+
aria-label="Highlight mode"
|
|
1219
|
+
title="Highlight text">
|
|
1220
|
+
<svg
|
|
1221
|
+
width="16"
|
|
1222
|
+
height="16"
|
|
1223
|
+
viewBox="0 0 16 16"
|
|
1224
|
+
fill="none"
|
|
1225
|
+
aria-hidden="true">
|
|
1226
|
+
<rect
|
|
1227
|
+
x="2"
|
|
1228
|
+
y="10"
|
|
1229
|
+
width="12"
|
|
1230
|
+
height="3"
|
|
1231
|
+
rx="0.5"
|
|
1232
|
+
fill="currentColor"
|
|
1233
|
+
opacity="0.3" />
|
|
1234
|
+
<path
|
|
1235
|
+
d="M3 7H13"
|
|
1236
|
+
stroke="currentColor"
|
|
1237
|
+
stroke-width="2"
|
|
1238
|
+
stroke-linecap="round" />
|
|
1239
|
+
</svg>
|
|
1240
|
+
</button>
|
|
1241
|
+
<button
|
|
1242
|
+
type="button"
|
|
1243
|
+
class="btn"
|
|
1244
|
+
class:active={annotation_mode === 'note'}
|
|
1245
|
+
onclick={() =>
|
|
1246
|
+
(annotation_mode = annotation_mode === 'note' ? null : 'note')}
|
|
1247
|
+
aria-label="Note mode"
|
|
1248
|
+
title="Add note">
|
|
1249
|
+
<svg
|
|
1250
|
+
width="16"
|
|
1251
|
+
height="16"
|
|
1252
|
+
viewBox="0 0 16 16"
|
|
1253
|
+
fill="none"
|
|
1254
|
+
aria-hidden="true">
|
|
1255
|
+
<rect
|
|
1256
|
+
x="2"
|
|
1257
|
+
y="2"
|
|
1258
|
+
width="12"
|
|
1259
|
+
height="12"
|
|
1260
|
+
rx="2"
|
|
1261
|
+
stroke="currentColor"
|
|
1262
|
+
stroke-width="1.25" />
|
|
1263
|
+
<path
|
|
1264
|
+
d="M5 5H11M5 8H9"
|
|
1265
|
+
stroke="currentColor"
|
|
1266
|
+
stroke-width="1.25"
|
|
1267
|
+
stroke-linecap="round" />
|
|
1268
|
+
</svg>
|
|
1269
|
+
</button>
|
|
1270
|
+
</div>
|
|
1271
|
+
{/if}
|
|
1272
|
+
</div>
|
|
1273
|
+
{/if}
|
|
1274
|
+
|
|
1275
|
+
<!-- Search bar -->
|
|
1276
|
+
{#if search_open}
|
|
1277
|
+
<div class="search-bar">
|
|
1278
|
+
<input
|
|
1279
|
+
type="text"
|
|
1280
|
+
placeholder="Search in document..."
|
|
1281
|
+
value={search_query}
|
|
1282
|
+
bind:this={search_input_el}
|
|
1283
|
+
oninput={handleSearchInput}
|
|
1284
|
+
onkeydown={handleSearchKeydown}
|
|
1285
|
+
aria-label="Search text" />
|
|
1286
|
+
{#if search_count_text}
|
|
1287
|
+
<span class="count">{search_count_text}</span>
|
|
1288
|
+
{/if}
|
|
1289
|
+
<button
|
|
1290
|
+
type="button"
|
|
1291
|
+
class="btn"
|
|
1292
|
+
onclick={searchPrev}
|
|
1293
|
+
disabled={search_matches.length === 0}
|
|
1294
|
+
aria-label="Previous match">
|
|
1295
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
1296
|
+
<path
|
|
1297
|
+
d="M12 10L8 6L4 10"
|
|
1298
|
+
stroke="currentColor"
|
|
1299
|
+
stroke-width="1.5"
|
|
1300
|
+
stroke-linecap="round"
|
|
1301
|
+
stroke-linejoin="round" />
|
|
1302
|
+
</svg>
|
|
1303
|
+
</button>
|
|
1304
|
+
<button
|
|
1305
|
+
type="button"
|
|
1306
|
+
class="btn"
|
|
1307
|
+
onclick={searchNext}
|
|
1308
|
+
disabled={search_matches.length === 0}
|
|
1309
|
+
aria-label="Next match">
|
|
1310
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
1311
|
+
<path
|
|
1312
|
+
d="M4 6L8 10L12 6"
|
|
1313
|
+
stroke="currentColor"
|
|
1314
|
+
stroke-width="1.5"
|
|
1315
|
+
stroke-linecap="round"
|
|
1316
|
+
stroke-linejoin="round" />
|
|
1317
|
+
</svg>
|
|
1318
|
+
</button>
|
|
1319
|
+
<button type="button" class="btn" onclick={closeSearch} aria-label="Close search">
|
|
1320
|
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
|
1321
|
+
<path
|
|
1322
|
+
d="M4 4L12 12M12 4L4 12"
|
|
1323
|
+
stroke="currentColor"
|
|
1324
|
+
stroke-width="1.5"
|
|
1325
|
+
stroke-linecap="round" />
|
|
1326
|
+
</svg>
|
|
1327
|
+
</button>
|
|
1328
|
+
</div>
|
|
1329
|
+
{/if}
|
|
1330
|
+
|
|
1331
|
+
<!-- Pages -->
|
|
1332
|
+
<div
|
|
1333
|
+
class="pdf-pages"
|
|
1334
|
+
bind:this={pages_container}
|
|
1335
|
+
style={single_page && auto_paginate
|
|
1336
|
+
? `transform: translateY(-${(page - 1) * 100}%); transition: transform 300ms cubic-bezier(0.22, 1, 0.36, 1);`
|
|
1337
|
+
: undefined}
|
|
1338
|
+
{@attach scrollbar()}>
|
|
1339
|
+
{#each page_infos as info, i}
|
|
1340
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
1341
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
1342
|
+
<div
|
|
1343
|
+
class="pdf-page"
|
|
1344
|
+
class:single-page-slot={single_page}
|
|
1345
|
+
data-page={i + 1}
|
|
1346
|
+
style={single_page ? `top: ${i * 100}%;` : getPageStyle(i)}
|
|
1347
|
+
bind:this={page_elements[i]}
|
|
1348
|
+
onmouseup={() => handlePageMouseUp(i + 1)}
|
|
1349
|
+
onclick={(e) => handlePageClick(e, i + 1)}>
|
|
1350
|
+
{#if !rendered_pages.has(i + 1) && !single_page}
|
|
1351
|
+
<div class="placeholder">
|
|
1352
|
+
<span>{i + 1}</span>
|
|
1353
|
+
</div>
|
|
1354
|
+
{/if}
|
|
1355
|
+
</div>
|
|
1356
|
+
{/each}
|
|
1357
|
+
</div>
|
|
1358
|
+
{/if}
|
|
1359
|
+
</div>
|
|
1360
|
+
|
|
1361
|
+
<style>
|
|
1362
|
+
/* ── Container ────────────────────────────────────────────── */
|
|
1363
|
+
|
|
1364
|
+
.pdf-container {
|
|
1365
|
+
position: relative;
|
|
1366
|
+
width: 100%;
|
|
1367
|
+
height: var(--pdf-height, 600px);
|
|
1368
|
+
display: flex;
|
|
1369
|
+
flex-direction: column;
|
|
1370
|
+
border-radius: var(--radius-md, 0.5rem);
|
|
1371
|
+
@supports (corner-shape: squircle) {
|
|
1372
|
+
corner-shape: squircle;
|
|
1373
|
+
border-radius: calc(var(--radius-md, 0.5rem) * var(--squircle-ratio, 2));
|
|
1374
|
+
}
|
|
1375
|
+
overflow: hidden;
|
|
1376
|
+
background: light-dark(
|
|
1377
|
+
var(--color-bg-muted, #f1f5f9),
|
|
1378
|
+
var(--color-bg-muted, #0f172a)
|
|
1379
|
+
);
|
|
1380
|
+
outline: none;
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
.pdf-container:focus-visible {
|
|
1384
|
+
outline: 2px solid var(--color-action, #3b82f6);
|
|
1385
|
+
outline-offset: -2px;
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
/* ── Toolbar ──────────────────────────────────────────────── */
|
|
1389
|
+
|
|
1390
|
+
.toolbar {
|
|
1391
|
+
display: flex;
|
|
1392
|
+
align-items: center;
|
|
1393
|
+
gap: 0.5rem;
|
|
1394
|
+
padding: 0.5rem 0.75rem;
|
|
1395
|
+
background: light-dark(var(--color-surface, #f8fafc), var(--color-surface, #1a2332));
|
|
1396
|
+
border-bottom: 1px solid var(--color-border, light-dark(#e2e8f0, #334155));
|
|
1397
|
+
flex-shrink: 0;
|
|
1398
|
+
flex-wrap: wrap;
|
|
1399
|
+
|
|
1400
|
+
.group {
|
|
1401
|
+
display: flex;
|
|
1402
|
+
align-items: center;
|
|
1403
|
+
gap: 0.25rem;
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
.separator {
|
|
1407
|
+
width: 1px;
|
|
1408
|
+
height: 1.5rem;
|
|
1409
|
+
background: var(--color-border, light-dark(#e2e8f0, #334155));
|
|
1410
|
+
margin-inline: 0.25rem;
|
|
1411
|
+
}
|
|
1412
|
+
|
|
1413
|
+
.spacer {
|
|
1414
|
+
flex: 1;
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
input {
|
|
1418
|
+
width: 3rem;
|
|
1419
|
+
text-align: center;
|
|
1420
|
+
padding: 0.25rem;
|
|
1421
|
+
border: 1px solid var(--color-border, light-dark(#e2e8f0, #334155));
|
|
1422
|
+
border-radius: var(--radius-sm, 0.25rem);
|
|
1423
|
+
@supports (corner-shape: squircle) {
|
|
1424
|
+
corner-shape: squircle;
|
|
1425
|
+
border-radius: calc(var(--radius-sm, 0.25rem) * var(--squircle-ratio, 2));
|
|
1426
|
+
}
|
|
1427
|
+
font-size: var(--text-sm, 0.875rem);
|
|
1428
|
+
background: light-dark(var(--color-bg, #ffffff), var(--color-bg, #1e293b));
|
|
1429
|
+
color: var(--color-text, light-dark(#1e293b, #e2e8f0));
|
|
1430
|
+
|
|
1431
|
+
&:focus {
|
|
1432
|
+
outline: 2px solid var(--color-action, #3b82f6);
|
|
1433
|
+
outline-offset: -1px;
|
|
1434
|
+
}
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
.total {
|
|
1438
|
+
font-size: var(--text-sm, 0.875rem);
|
|
1439
|
+
color: var(--color-text-muted, light-dark(#64748b, #94a3b8));
|
|
1440
|
+
user-select: none;
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
.zoom {
|
|
1444
|
+
font-size: var(--text-sm, 0.875rem);
|
|
1445
|
+
color: var(--color-text, light-dark(#1e293b, #e2e8f0));
|
|
1446
|
+
min-width: 3rem;
|
|
1447
|
+
text-align: center;
|
|
1448
|
+
user-select: none;
|
|
1449
|
+
font-variant-numeric: tabular-nums;
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/* Shared by the toolbar and the search bar */
|
|
1454
|
+
.btn {
|
|
1455
|
+
display: flex;
|
|
1456
|
+
align-items: center;
|
|
1457
|
+
justify-content: center;
|
|
1458
|
+
width: 32px;
|
|
1459
|
+
height: 32px;
|
|
1460
|
+
border: none;
|
|
1461
|
+
background: none;
|
|
1462
|
+
border-radius: var(--radius-sm, 0.25rem);
|
|
1463
|
+
@supports (corner-shape: squircle) {
|
|
1464
|
+
corner-shape: squircle;
|
|
1465
|
+
border-radius: calc(var(--radius-sm, 0.25rem) * var(--squircle-ratio, 2));
|
|
1466
|
+
}
|
|
1467
|
+
cursor: pointer;
|
|
1468
|
+
color: var(--color-text, light-dark(#1e293b, #e2e8f0));
|
|
1469
|
+
transition: background 150ms ease;
|
|
1470
|
+
|
|
1471
|
+
&:hover {
|
|
1472
|
+
background: light-dark(
|
|
1473
|
+
var(--color-surface, rgb(0 0 0 / 0.06)),
|
|
1474
|
+
var(--color-surface, rgb(255 255 255 / 0.08))
|
|
1475
|
+
);
|
|
1476
|
+
/* Snap the tint in on hover; the base rule eases it back out on leave. */
|
|
1477
|
+
transition: none;
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
&:disabled {
|
|
1481
|
+
opacity: 0.4;
|
|
1482
|
+
cursor: default;
|
|
1483
|
+
|
|
1484
|
+
&:hover {
|
|
1485
|
+
background: none;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
&.active {
|
|
1490
|
+
background: light-dark(
|
|
1491
|
+
rgb(from var(--color-action, #3b82f6) r g b / 0.12),
|
|
1492
|
+
rgb(from var(--color-action, #3b82f6) r g b / 0.2)
|
|
1493
|
+
);
|
|
1494
|
+
color: var(--color-action, #3b82f6);
|
|
1495
|
+
}
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/* ── Search bar ───────────────────────────────────────────── */
|
|
1499
|
+
|
|
1500
|
+
.search-bar {
|
|
1501
|
+
display: flex;
|
|
1502
|
+
align-items: center;
|
|
1503
|
+
gap: 0.5rem;
|
|
1504
|
+
padding: 0.5rem 0.75rem;
|
|
1505
|
+
background: light-dark(var(--color-surface, #f8fafc), var(--color-surface, #1a2332));
|
|
1506
|
+
border-bottom: 1px solid var(--color-border, light-dark(#e2e8f0, #334155));
|
|
1507
|
+
flex-shrink: 0;
|
|
1508
|
+
|
|
1509
|
+
input {
|
|
1510
|
+
flex: 1;
|
|
1511
|
+
min-width: 0;
|
|
1512
|
+
padding: 0.25rem 0.5rem;
|
|
1513
|
+
border: 1px solid var(--color-border, light-dark(#e2e8f0, #334155));
|
|
1514
|
+
border-radius: var(--radius-sm, 0.25rem);
|
|
1515
|
+
@supports (corner-shape: squircle) {
|
|
1516
|
+
corner-shape: squircle;
|
|
1517
|
+
border-radius: calc(var(--radius-sm, 0.25rem) * var(--squircle-ratio, 2));
|
|
1518
|
+
}
|
|
1519
|
+
font-size: var(--text-sm, 0.875rem);
|
|
1520
|
+
background: light-dark(var(--color-bg, #ffffff), var(--color-bg, #1e293b));
|
|
1521
|
+
color: var(--color-text, light-dark(#1e293b, #e2e8f0));
|
|
1522
|
+
|
|
1523
|
+
&:focus {
|
|
1524
|
+
outline: 2px solid var(--color-action, #3b82f6);
|
|
1525
|
+
outline-offset: -1px;
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
.count {
|
|
1530
|
+
font-size: var(--text-xs, 0.75rem);
|
|
1531
|
+
color: var(--color-text-muted, light-dark(#64748b, #94a3b8));
|
|
1532
|
+
white-space: nowrap;
|
|
1533
|
+
user-select: none;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
/* ── Pages container ──────────────────────────────────────── */
|
|
1538
|
+
|
|
1539
|
+
.pdf-pages {
|
|
1540
|
+
flex: 1;
|
|
1541
|
+
overflow-y: auto;
|
|
1542
|
+
overscroll-behavior: contain;
|
|
1543
|
+
padding: 1rem;
|
|
1544
|
+
display: flex;
|
|
1545
|
+
flex-direction: column;
|
|
1546
|
+
align-items: center;
|
|
1547
|
+
gap: 1rem;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
.single-page {
|
|
1551
|
+
height: 100%;
|
|
1552
|
+
background: transparent;
|
|
1553
|
+
border-radius: 0;
|
|
1554
|
+
/* Allow adjacent pages to render outside the slide bounds so the
|
|
1555
|
+
Carousel can scroll between them with both pages visible at once. */
|
|
1556
|
+
overflow: visible;
|
|
1557
|
+
}
|
|
1558
|
+
/* When the PDF paginates itself (standalone single-page mode) clip to the
|
|
1559
|
+
container so only the active page shows during the translate. */
|
|
1560
|
+
.single-page.auto-paginate {
|
|
1561
|
+
overflow: hidden;
|
|
1562
|
+
}
|
|
1563
|
+
|
|
1564
|
+
.single-page .pdf-pages {
|
|
1565
|
+
/* The slide is one page tall; adjacent pages live at top: ±100%
|
|
1566
|
+
relative to this container and need to be visible during swipe
|
|
1567
|
+
transitions. */
|
|
1568
|
+
overflow: visible;
|
|
1569
|
+
padding: 0;
|
|
1570
|
+
gap: 0;
|
|
1571
|
+
display: block;
|
|
1572
|
+
position: relative;
|
|
1573
|
+
flex: 1 1 auto;
|
|
1574
|
+
min-height: 0;
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
.pdf-page.single-page-slot {
|
|
1578
|
+
/* Each page slot is absolutely positioned in a vertical stack so a
|
|
1579
|
+
carousel `translateY(-page * 100%)` on the parent reveals the
|
|
1580
|
+
prev/next page during the swipe. The slot fills the slide; the
|
|
1581
|
+
actual page canvas is centered inside via flex.
|
|
1582
|
+
|
|
1583
|
+
`transform-origin: 0 0` is required so the per-slot matrices applied
|
|
1584
|
+
by the Carousel (which already bake the origin into the matrix via
|
|
1585
|
+
the translate-scale-translate trick) anchor zooms at the click
|
|
1586
|
+
location rather than the slot's geometric center. */
|
|
1587
|
+
position: absolute;
|
|
1588
|
+
left: 0;
|
|
1589
|
+
right: 0;
|
|
1590
|
+
width: 100%;
|
|
1591
|
+
height: 100%;
|
|
1592
|
+
display: flex;
|
|
1593
|
+
align-items: center;
|
|
1594
|
+
justify-content: center;
|
|
1595
|
+
background: transparent;
|
|
1596
|
+
box-shadow: none;
|
|
1597
|
+
transform-origin: 0 0;
|
|
1598
|
+
will-change: transform;
|
|
1599
|
+
}
|
|
1600
|
+
|
|
1601
|
+
.pdf-page.single-page-slot :global(canvas) {
|
|
1602
|
+
max-width: 100%;
|
|
1603
|
+
max-height: 100%;
|
|
1604
|
+
object-fit: contain;
|
|
1605
|
+
box-shadow:
|
|
1606
|
+
0 1px 3px rgb(0 0 0 / 0.12),
|
|
1607
|
+
0 1px 2px rgb(0 0 0 / 0.08);
|
|
1608
|
+
}
|
|
1609
|
+
|
|
1610
|
+
/* ── Page ─────────────────────────────────────────────────── */
|
|
1611
|
+
|
|
1612
|
+
.pdf-page {
|
|
1613
|
+
position: relative;
|
|
1614
|
+
box-shadow:
|
|
1615
|
+
0 1px 3px rgb(0 0 0 / 0.12),
|
|
1616
|
+
0 1px 2px rgb(0 0 0 / 0.08);
|
|
1617
|
+
background: white;
|
|
1618
|
+
flex-shrink: 0;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
.pdf-page :global(canvas) {
|
|
1622
|
+
display: block;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
.pdf-page :global(.pdf-text-layer) {
|
|
1626
|
+
position: absolute;
|
|
1627
|
+
inset: 0;
|
|
1628
|
+
overflow: hidden;
|
|
1629
|
+
opacity: 0.25;
|
|
1630
|
+
line-height: 1;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
.pdf-page :global(.pdf-text-layer span) {
|
|
1634
|
+
color: transparent;
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
.pdf-page :global(.pdf-text-layer span::selection) {
|
|
1638
|
+
background: color-mix(in oklch, var(--color-action, #3b82f6) 40%, transparent);
|
|
1639
|
+
}
|
|
1640
|
+
|
|
1641
|
+
.pdf-page .placeholder {
|
|
1642
|
+
position: absolute;
|
|
1643
|
+
inset: 0;
|
|
1644
|
+
display: flex;
|
|
1645
|
+
align-items: center;
|
|
1646
|
+
justify-content: center;
|
|
1647
|
+
background: white;
|
|
1648
|
+
|
|
1649
|
+
span {
|
|
1650
|
+
font-size: 2rem;
|
|
1651
|
+
color: light-dark(#cbd5e1, #475569);
|
|
1652
|
+
user-select: none;
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
/* ── Error state ──────────────────────────────────────────── */
|
|
1657
|
+
|
|
1658
|
+
.error {
|
|
1659
|
+
flex: 1;
|
|
1660
|
+
display: flex;
|
|
1661
|
+
flex-direction: column;
|
|
1662
|
+
align-items: center;
|
|
1663
|
+
justify-content: center;
|
|
1664
|
+
gap: 0.75rem;
|
|
1665
|
+
color: var(--color-text-muted, light-dark(#64748b, #94a3b8));
|
|
1666
|
+
padding: 2rem;
|
|
1667
|
+
text-align: center;
|
|
1668
|
+
|
|
1669
|
+
.detail {
|
|
1670
|
+
font-size: var(--text-sm, 0.875rem);
|
|
1671
|
+
max-width: 30rem;
|
|
1672
|
+
word-break: break-word;
|
|
1673
|
+
}
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
/* ── Skeleton ─────────────────────────────────────────────── */
|
|
1677
|
+
|
|
1678
|
+
.skeleton {
|
|
1679
|
+
flex: 1;
|
|
1680
|
+
display: flex;
|
|
1681
|
+
flex-direction: column;
|
|
1682
|
+
pointer-events: none;
|
|
1683
|
+
|
|
1684
|
+
.bar {
|
|
1685
|
+
display: flex;
|
|
1686
|
+
align-items: center;
|
|
1687
|
+
gap: 1rem;
|
|
1688
|
+
padding: 0.5rem 0.75rem;
|
|
1689
|
+
border-bottom: 1px solid var(--color-border, light-dark(#e2e8f0, #334155));
|
|
1690
|
+
}
|
|
1691
|
+
|
|
1692
|
+
.block {
|
|
1693
|
+
border-radius: var(--radius-sm, 0.25rem);
|
|
1694
|
+
@supports (corner-shape: squircle) {
|
|
1695
|
+
corner-shape: squircle;
|
|
1696
|
+
border-radius: calc(var(--radius-sm, 0.25rem) * var(--squircle-ratio, 2));
|
|
1697
|
+
}
|
|
1698
|
+
background: var(--skeleton-bg, rgb(from var(--color-text, #888) r g b / 0.1));
|
|
1699
|
+
position: relative;
|
|
1700
|
+
overflow: hidden;
|
|
1701
|
+
|
|
1702
|
+
&::after {
|
|
1703
|
+
content: '';
|
|
1704
|
+
position: absolute;
|
|
1705
|
+
inset: 0;
|
|
1706
|
+
transform: translateX(-100%);
|
|
1707
|
+
background-image: linear-gradient(
|
|
1708
|
+
105deg,
|
|
1709
|
+
transparent 25%,
|
|
1710
|
+
var(--skeleton-sheen, rgb(from var(--color-text, #888) r g b / 0.12)) 50%,
|
|
1711
|
+
transparent 75%
|
|
1712
|
+
);
|
|
1713
|
+
animation: delight-skeleton-shimmer var(--skeleton-duration, 2.4s) ease-in-out
|
|
1714
|
+
infinite;
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
|
|
1718
|
+
/* Centering backdrop behind the "paper". A subtle surface tint makes the
|
|
1719
|
+
white page read as a sheet of paper regardless of the page theme. */
|
|
1720
|
+
/* Full-width backdrop behind the "paper". A subtle surface tint makes the
|
|
1721
|
+
white page read as a sheet of paper regardless of the page theme. Grid
|
|
1722
|
+
centering (rather than flex) lets the paper derive its width from its
|
|
1723
|
+
definite height + aspect-ratio instead of collapsing to its content. */
|
|
1724
|
+
.page {
|
|
1725
|
+
flex: 1;
|
|
1726
|
+
min-height: 0;
|
|
1727
|
+
display: grid;
|
|
1728
|
+
place-items: center;
|
|
1729
|
+
padding: 1.5rem;
|
|
1730
|
+
background: light-dark(#f1f5f9, #0f172a);
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
/* The page itself — a portrait US-letter sheet (8.5 × 11) in white, since
|
|
1734
|
+
most PDFs are white paper. Lines below mimic dark text. */
|
|
1735
|
+
.paper {
|
|
1736
|
+
height: 100%;
|
|
1737
|
+
aspect-ratio: 8.5 / 11;
|
|
1738
|
+
max-width: 100%;
|
|
1739
|
+
background: #ffffff;
|
|
1740
|
+
border-radius: 3px;
|
|
1741
|
+
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
|
|
1742
|
+
display: flex;
|
|
1743
|
+
flex-direction: column;
|
|
1744
|
+
gap: 0.85rem;
|
|
1745
|
+
padding: 9% 10%;
|
|
1746
|
+
overflow: hidden;
|
|
1747
|
+
}
|
|
1748
|
+
|
|
1749
|
+
.line {
|
|
1750
|
+
flex: none;
|
|
1751
|
+
height: 0.7rem;
|
|
1752
|
+
border-radius: 3px;
|
|
1753
|
+
background: rgba(15, 23, 42, 0.08);
|
|
1754
|
+
position: relative;
|
|
1755
|
+
overflow: hidden;
|
|
1756
|
+
|
|
1757
|
+
&::after {
|
|
1758
|
+
content: '';
|
|
1759
|
+
position: absolute;
|
|
1760
|
+
inset: 0;
|
|
1761
|
+
transform: translateX(-100%);
|
|
1762
|
+
/* The paper is always white, so the sheen stays ink-on-paper rather
|
|
1763
|
+
than using the theme-aware skeleton tokens. */
|
|
1764
|
+
background-image: linear-gradient(
|
|
1765
|
+
105deg,
|
|
1766
|
+
rgba(15, 23, 42, 0) 25%,
|
|
1767
|
+
rgba(15, 23, 42, 0.14) 50%,
|
|
1768
|
+
rgba(15, 23, 42, 0) 75%
|
|
1769
|
+
);
|
|
1770
|
+
animation: delight-skeleton-shimmer var(--skeleton-duration, 2.4s) ease-in-out
|
|
1771
|
+
infinite;
|
|
1772
|
+
animation-delay: var(--shimmer-delay, 0ms);
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
@keyframes -global-delight-skeleton-shimmer {
|
|
1778
|
+
0% {
|
|
1779
|
+
transform: translateX(-100%);
|
|
1780
|
+
}
|
|
1781
|
+
55%,
|
|
1782
|
+
100% {
|
|
1783
|
+
transform: translateX(100%);
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
|
|
1787
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1788
|
+
.skeleton .block::after,
|
|
1789
|
+
.skeleton .line::after {
|
|
1790
|
+
animation: none;
|
|
1791
|
+
}
|
|
1792
|
+
}
|
|
1793
|
+
</style>
|