@happyvertical/smrt-ui 0.57.20 → 0.57.21
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/AGENTS.md +11 -1
- package/README.md +91 -0
- package/dist/components/display/DocumentViewer.svelte +443 -0
- package/dist/components/display/DocumentViewer.svelte.d.ts +24 -0
- package/dist/components/display/DocumentViewer.svelte.d.ts.map +1 -0
- package/dist/components/display/document-viewer-logic.d.ts +35 -0
- package/dist/components/display/document-viewer-logic.d.ts.map +1 -0
- package/dist/components/display/document-viewer-logic.js +56 -0
- package/dist/components/display/index.d.ts +5 -1
- package/dist/components/display/index.d.ts.map +1 -1
- package/dist/components/display/index.js +4 -0
- package/dist/components/display/pdf-opener.d.ts +44 -0
- package/dist/components/display/pdf-opener.d.ts.map +1 -0
- package/dist/components/display/pdf-opener.js +121 -0
- package/dist/components/display/pdf-types.d.ts +19 -0
- package/dist/components/display/pdf-types.d.ts.map +1 -0
- package/dist/components/display/pdf-types.js +7 -0
- package/dist/components/display/types.d.ts +19 -0
- package/dist/components/display/types.d.ts.map +1 -1
- package/dist/components/forms/NumericKeypad.svelte +511 -0
- package/dist/components/forms/NumericKeypad.svelte.d.ts +51 -0
- package/dist/components/forms/NumericKeypad.svelte.d.ts.map +1 -0
- package/dist/components/forms/index.d.ts +3 -1
- package/dist/components/forms/index.d.ts.map +1 -1
- package/dist/components/forms/index.js +2 -0
- package/dist/components/forms/numeric-keypad-logic.d.ts +68 -0
- package/dist/components/forms/numeric-keypad-logic.d.ts.map +1 -0
- package/dist/components/forms/numeric-keypad-logic.js +157 -0
- package/dist/components/forms/types.d.ts +17 -0
- package/dist/components/forms/types.d.ts.map +1 -1
- package/dist/i18n/strings.ui.d.ts +23 -0
- package/dist/i18n/strings.ui.d.ts.map +1 -1
- package/dist/i18n/strings.ui.js +25 -0
- package/dist/svelte/playground/TouchToolsPreview.svelte +47 -0
- package/dist/svelte/playground/TouchToolsPreview.svelte.d.ts +4 -0
- package/dist/svelte/playground/TouchToolsPreview.svelte.d.ts.map +1 -0
- package/dist/svelte/playground.d.ts.map +1 -1
- package/dist/svelte/playground.js +10 -0
- package/package.json +13 -2
package/AGENTS.md
CHANGED
|
@@ -49,11 +49,12 @@ components are exempt — they *are* the primitives.
|
|
|
49
49
|
| `./feedback` | `Alert`, `ToastViewport`, `Modal`, `Drawer`/`Sheet`, `ConfirmDialog`, `LoadingOverlay`, `Progress`, `Meter`, `Spinner`, phone surfaces `PhoneSheet` / `WorkingStrip` and the `swipeDismiss` action |
|
|
50
50
|
| `./data` | `CollectionToolbar`, `CollectionList`/`ContentList`, `DataTable` (footer rows and column `summaries`: [agents/data-table.md](agents/data-table.md)) and their types — a **component barrel**: it re-exports `.svelte` files, so a plain Node `import()` of it throws `ERR_UNKNOWN_FILE_EXTENSION` |
|
|
51
51
|
| `./data-surface` | **Svelte-free**: registry contracts and shared protocol limits (`DATA_SURFACE_MAX_REQUEST_BYTES`, `DATA_SURFACE_IDENTIFIER_MAX_LENGTH`, …). Server adapters must import these from here, not from `./data` — a server entry that reaches the barrel breaks the `smrt` CLI's plain-Node `.smrt/register.js` load (issue #2924) |
|
|
52
|
+
| `./display` | `DocumentViewer` (touch PDF/image viewer reading through an injected `fetch`; pdf.js is an optional peer loaded lazily by `createPdfJsOpener()`), `CurrencyDisplay`, `DateDisplay`, `StatusBadge`, `Icon`; also re-exported from `.` |
|
|
52
53
|
| `./layout` | `Container`, `Grid`, `Header`, `Footer`, `PageHeader`, `EmptyState`, … |
|
|
53
54
|
| `./charts` | Pure `Chart` renderer (`bar`, `line`, `table`, `pie`, `stat`), structural `ChartData`, and minor-unit number formatting; `./charts/format` is Svelte-free |
|
|
54
55
|
| `./calendar` | `CalendarView` (time-zone-aware month/week grids + phone agenda) and its Svelte-free date model (`toEntries`, `layoutMonth`, `layoutWeek`, `dateKeyInZone`, …); deprecated `Calendar`, `DayView` |
|
|
55
56
|
| `./chat` | `MessageBubble`, `ReactionPicker`, `TypingIndicator` |
|
|
56
|
-
| `./forms` | Provider-free fields, choice controls, sliders/ranges, combobox/listbox/multiselect/tags, date/time/file controls, `CameraCapture`/`SignaturePad` capture, plus the transport-neutral control interaction registry |
|
|
57
|
+
| `./forms` | Provider-free fields, choice controls, sliders/ranges, combobox/listbox/multiselect/tags, date/time/file controls, `CameraCapture`/`SignaturePad` capture, `NumericKeypad` (quantity/money keypad; money as integer minor units via the Svelte-free `numeric-keypad-logic.ts`), plus the transport-neutral control interaction registry |
|
|
57
58
|
| `.` permissions | `PermissionCheck` and the domain-agnostic `PermissionPicker` for grouped, searchable native role-grant forms over host-supplied catalog records |
|
|
58
59
|
| `./form-retry` | **Svelte-free** form retry (#3291): submission key, in-flight refusal, conditional reset, opt-in restore-after-reload, browser `runOnce()` digest. Also re-exported from `./forms`. Framework-free TS typed structurally against SvelteKit's `SubmitFunction` — never import `@sveltejs/kit` here. Guide: `docs/content/form-retry.md` |
|
|
59
60
|
| `./i18n` | i18n **client**: `useI18n`, `<Trans>`, `defineMessages`, `renderTemplate` (no `smrt-languages` import — the server resolver stays in `smrt-svelte/i18n/server`) |
|
|
@@ -181,6 +182,15 @@ other text pairing clears WCAG AA in both schemes.
|
|
|
181
182
|
their form's reset (`attachFormResetListener`), even while `disabled`;
|
|
182
183
|
otherwise the UI shows a file the next submit no longer posts.
|
|
183
184
|
|
|
185
|
+
- **Touch viewers and keypads (#3463).** `DocumentViewer` never puts the API
|
|
186
|
+
URL in an `<img>`/`<iframe>`: it reads through `fetch` and shows blobs, and
|
|
187
|
+
revokes them on cleanup. `pdfjs-dist` is an *optional peer*: only
|
|
188
|
+
`display/pdf-opener.ts` imports it (dynamically, itself loaded lazily), so a
|
|
189
|
+
missing engine shows the `unsupported` state and nothing else breaks. Keep
|
|
190
|
+
zoom/scale rules in `document-viewer-logic.ts`. `NumericKeypad` money mode is
|
|
191
|
+
integer minor units end to end (string math, never floats); entry rules stay
|
|
192
|
+
in `numeric-keypad-logic.ts`. Keys use `max(4rem, --smrt-touch-target-min)`.
|
|
193
|
+
|
|
184
194
|
- **i18n split**: the client (here) is dependency-free; the Node-only server
|
|
185
195
|
resolver (`buildI18nSnapshot`, → `@happyvertical/smrt-languages`) stays in
|
|
186
196
|
`@happyvertical/smrt-svelte/i18n/server`. Package string catalogs register via
|
package/README.md
CHANGED
|
@@ -36,6 +36,7 @@ pnpm add @happyvertical/smrt-ui
|
|
|
36
36
|
| Choices | `Checkbox`, `RadioGroup`/`Radio`, `Switch`, `Toggle`, `ToggleButton`, `SegmentedControl` |
|
|
37
37
|
| Values and files | `Slider`, `RangeSlider`, `DatePicker`, `TimePicker`, `FilePicker` |
|
|
38
38
|
| Capture | `CameraCapture`, `SignaturePad` |
|
|
39
|
+
| Touch entry and viewing | `NumericKeypad` (`/forms`), `DocumentViewer` (`/display`) |
|
|
39
40
|
| Actions and display | `Button`, `Dropdown`/`Menu`, `Badge`, `Chip`, `Avatar`, `Card`, `Skeleton`, `Tooltip`, `Tree` |
|
|
40
41
|
| Disclosure and overlays | `Popover`, `Disclosure`, `Accordion`/`AccordionItem`, `Modal`, `Drawer`/`Sheet`, `PhoneSheet`, `ConfirmDialog` |
|
|
41
42
|
| Feedback | `Alert`, `ToastViewport`, `Progress`, `Meter`, `Spinner`, `LoadingOverlay`, `WorkingStrip` |
|
|
@@ -657,6 +658,96 @@ least `--smrt-touch-target-min` (48px by default; see
|
|
|
657
658
|
Both register with an enclosing `Form`'s interaction registry as non-readable,
|
|
658
659
|
non-writable `file` controls.
|
|
659
660
|
|
|
661
|
+
## Touch keypad and document viewer
|
|
662
|
+
|
|
663
|
+
Two controls for gloved, tablet-first use. Every key and control is at least
|
|
664
|
+
4rem square (or `--smrt-touch-target-min`, whichever is larger).
|
|
665
|
+
|
|
666
|
+
### NumericKeypad
|
|
667
|
+
|
|
668
|
+
```svelte
|
|
669
|
+
<script lang="ts">
|
|
670
|
+
import { NumericKeypad } from '@happyvertical/smrt-ui/forms';
|
|
671
|
+
let price = $state('');
|
|
672
|
+
</script>
|
|
673
|
+
|
|
674
|
+
<NumericKeypad
|
|
675
|
+
bind:value={price}
|
|
676
|
+
mode="money"
|
|
677
|
+
currency="CAD"
|
|
678
|
+
prefix="$"
|
|
679
|
+
min={0.01}
|
|
680
|
+
onSubmit={({ minorUnits }) => save(minorUnits)}
|
|
681
|
+
name="price"
|
|
682
|
+
/>
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
`value` is the typed text (`"12.5"`; a number is accepted). A 3x4 grid holds
|
|
686
|
+
the digits, a decimal point (only when `maxDecimals` > 0) and backspace; Clear
|
|
687
|
+
sits beside the display and the confirm key (text `submitLabel`) appears when
|
|
688
|
+
`onSubmit` is given. `onChange` / `onSubmit` receive `{ text, number,
|
|
689
|
+
minorUnits, valid }`. A value outside `min` / `max` shows an error and blocks
|
|
690
|
+
confirm; `clampToMax` refuses a press that would exceed `max`. `maxLength`
|
|
691
|
+
caps the typed text (default 12).
|
|
692
|
+
|
|
693
|
+
`mode="money"` keeps integer minor units: the decimals allowed follow the
|
|
694
|
+
currency's ISO 4217 exponent (`currency`, or `minorUnitExponent`), `minorUnits`
|
|
695
|
+
is computed by string arithmetic and never a float, and a `name` posts the
|
|
696
|
+
integer minor units (in `number` mode it posts the typed text).
|
|
697
|
+
Money amounts beyond JavaScript's safe integer minor-unit range are invalid
|
|
698
|
+
and cannot be confirmed, including when `maxLength` is increased. The pure
|
|
699
|
+
helpers `textToMinorUnits()`, `minorUnitsToText()`, `minorUnitExponent()`,
|
|
700
|
+
`applyKeypadKey()` and `validateKeypadEntry()` are exported from `/forms`.
|
|
701
|
+
|
|
702
|
+
Hardware keys work while focus is inside the keypad: digits, `.` or `,` for the
|
|
703
|
+
point, Backspace, Delete (clear) and Enter (confirm, when the keypad itself has
|
|
704
|
+
focus). The root is a `group`, the display is a polite live `output`, and every
|
|
705
|
+
key has an `aria-label`. This is a quantity/money pad, not a PIN pad: the typed
|
|
706
|
+
value is shown as text. Text comes from `ui.numeric_keypad.*` and `labels`.
|
|
707
|
+
|
|
708
|
+
### DocumentViewer
|
|
709
|
+
|
|
710
|
+
```svelte
|
|
711
|
+
<script lang="ts">
|
|
712
|
+
import { DocumentViewer, createPdfJsOpener } from '@happyvertical/smrt-ui/display';
|
|
713
|
+
import workerSrc from 'pdfjs-dist/legacy/build/pdf.worker.min.mjs?url';
|
|
714
|
+
// Literal imports here let YOUR bundler include pdf.js.
|
|
715
|
+
const openPdf = createPdfJsOpener({
|
|
716
|
+
workerSrc,
|
|
717
|
+
loadLibrary: () => import('pdfjs-dist/legacy/build/pdf.mjs'),
|
|
718
|
+
});
|
|
719
|
+
</script>
|
|
720
|
+
|
|
721
|
+
<DocumentViewer src="/api/prints/42/file" mimeType="application/pdf"
|
|
722
|
+
label="Bracket, rev C" fetch={authedFetch} {openPdf} />
|
|
723
|
+
```
|
|
724
|
+
|
|
725
|
+
The file is read through `fetch` (default: the global one; inject the app's
|
|
726
|
+
authenticated fetch) and shown from a blob. It never sets `<img src>` or
|
|
727
|
+
`<iframe src>` to `src`, so it works in an embedded WebView that only
|
|
728
|
+
authenticates through the app's own fetch and has no built-in PDF viewer. Blob
|
|
729
|
+
URLs are revoked on cleanup. `mimeType` may be omitted, then the response's
|
|
730
|
+
Content-Type decides. Controls: previous / next with "Page N of M", zoom out /
|
|
731
|
+
Fit / zoom in (1, 1.5, 2, 3, 4, 5), and a two-finger pinch; a zoomed document
|
|
732
|
+
scrolls inside its sheet. States (the root's `data-state`): `loading`,
|
|
733
|
+
`ready`, `failed` (with "Try again"), and `unsupported` (an unknown type, or no
|
|
734
|
+
PDF engine). Text comes from `ui.document_viewer.*` and `labels`.
|
|
735
|
+
|
|
736
|
+
**PDFs** are drawn by an `OpenPdf` (`(bytes) => Promise<PdfDocument>` where a
|
|
737
|
+
`PdfDocument` is `{ pages, renderPage(page, canvas, cssWidth), destroy() }`).
|
|
738
|
+
Inject your own, or omit it for the default, `createPdfJsOpener()`, which
|
|
739
|
+
uses `pdfjs-dist`'s legacy build. `pdfjs-dist` is an **optional peer
|
|
740
|
+
dependency**: install it only if you show PDFs; images need nothing. It is
|
|
741
|
+
loaded on the first PDF, and if it is missing the viewer shows `unsupported`.
|
|
742
|
+
The default opener loads it through a variable specifier so an app without
|
|
743
|
+
pdfjs-dist still builds; that means a bundler does not include pdf.js by default.
|
|
744
|
+
An app that wants it bundled passes `loadLibrary` (as above) and `workerSrc`.
|
|
745
|
+
Pass `workerSrc` (a URL such as Vite's `?url` import of
|
|
746
|
+
`pdf.worker.min.mjs`) to render off the main thread; without it the worker
|
|
747
|
+
module is imported and runs on the main thread, which needs no bundler URL
|
|
748
|
+
handling. The canvas is capped at 4096 x 4096 device pixels; zooming past it
|
|
749
|
+
enlarges on screen instead.
|
|
750
|
+
|
|
660
751
|
## DataTable controller
|
|
661
752
|
|
|
662
753
|
`DataTable` can share one headless `DataTableController` between rendered
|
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
A PDF or picture, readable on a touch screen (smrt#3463).
|
|
3
|
+
|
|
4
|
+
The file is read through `fetch` (inject the app's own authenticated one) and
|
|
5
|
+
shown from a blob: never an <img src> or <iframe src> on the API, because an
|
|
6
|
+
embedded WebView may only authenticate through the app's fetch and often has
|
|
7
|
+
no built-in PDF viewer. A PDF is drawn page by page onto a canvas by an
|
|
8
|
+
`OpenPdf` (default: pdf.js through `createPdfJsOpener()`, an optional peer
|
|
9
|
+
dependency; images need nothing).
|
|
10
|
+
|
|
11
|
+
Controls are at least 4rem: previous / next with "Page N of M", zoom out /
|
|
12
|
+
Fit / zoom in (1, 1.5, 2, 3, 4, 5), and a two-finger pinch does the same. A
|
|
13
|
+
zoomed document scrolls inside its sheet. States: loading, failed (with
|
|
14
|
+
Try again), unsupported type, and unsupported engine.
|
|
15
|
+
|
|
16
|
+
Ported from teamworks-os `PrintViewer.svelte`.
|
|
17
|
+
-->
|
|
18
|
+
<script lang="ts">
|
|
19
|
+
import { M, useI18n } from '../../i18n/index.js';
|
|
20
|
+
import { renderTemplate } from '../../i18n/render.js';
|
|
21
|
+
import {
|
|
22
|
+
clampPage,
|
|
23
|
+
clampPinchScale,
|
|
24
|
+
commitPinchZoom,
|
|
25
|
+
isImageType,
|
|
26
|
+
isPdfType,
|
|
27
|
+
MAX_ZOOM,
|
|
28
|
+
MIN_ZOOM,
|
|
29
|
+
stepZoom,
|
|
30
|
+
} from './document-viewer-logic.js';
|
|
31
|
+
import {
|
|
32
|
+
type OpenPdf,
|
|
33
|
+
type PdfDocument,
|
|
34
|
+
PdfEngineUnavailableError,
|
|
35
|
+
} from './pdf-types.js';
|
|
36
|
+
import type { DocumentViewerLabels } from './types.js';
|
|
37
|
+
|
|
38
|
+
export interface Props {
|
|
39
|
+
/** URL of the file; read with `fetch`. */
|
|
40
|
+
src: string;
|
|
41
|
+
/** `application/pdf` or `image/*`. Omitted, the response's Content-Type decides. */
|
|
42
|
+
mimeType?: string;
|
|
43
|
+
/** Names the document for assistive technology (the image alt, the page canvas). */
|
|
44
|
+
label: string;
|
|
45
|
+
/** Reads the file; inject the app's authenticated fetch. Default: global `fetch`. */
|
|
46
|
+
fetch?: typeof globalThis.fetch;
|
|
47
|
+
/** Opens PDF bytes. Default: pdf.js via `createPdfJsOpener()`. */
|
|
48
|
+
openPdf?: OpenPdf;
|
|
49
|
+
/** Text overrides; defaults come from the `ui` i18n catalog. */
|
|
50
|
+
labels?: Partial<DocumentViewerLabels>;
|
|
51
|
+
/** DOM id of the component root. */
|
|
52
|
+
id?: string;
|
|
53
|
+
/** CSS class to apply to the component root. */
|
|
54
|
+
class?: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let {
|
|
58
|
+
src,
|
|
59
|
+
mimeType,
|
|
60
|
+
label,
|
|
61
|
+
fetch: fetchImpl,
|
|
62
|
+
openPdf,
|
|
63
|
+
labels,
|
|
64
|
+
id,
|
|
65
|
+
class: className = '',
|
|
66
|
+
}: Props = $props();
|
|
67
|
+
|
|
68
|
+
const { t } = useI18n();
|
|
69
|
+
const text = $derived<DocumentViewerLabels>({
|
|
70
|
+
region: t(M['ui.document_viewer.region']),
|
|
71
|
+
pages: t(M['ui.document_viewer.pages']),
|
|
72
|
+
zoom: t(M['ui.document_viewer.zoom']),
|
|
73
|
+
previousPage: t(M['ui.document_viewer.previous_page']),
|
|
74
|
+
nextPage: t(M['ui.document_viewer.next_page']),
|
|
75
|
+
pageOf: t(M['ui.document_viewer.page_of']),
|
|
76
|
+
pageLabel: t(M['ui.document_viewer.page_label']),
|
|
77
|
+
zoomOut: t(M['ui.document_viewer.zoom_out']),
|
|
78
|
+
zoomIn: t(M['ui.document_viewer.zoom_in']),
|
|
79
|
+
fit: t(M['ui.document_viewer.fit']),
|
|
80
|
+
loading: t(M['ui.document_viewer.loading']),
|
|
81
|
+
failed: t(M['ui.document_viewer.failed']),
|
|
82
|
+
retry: t(M['ui.document_viewer.retry']),
|
|
83
|
+
unsupported: t(M['ui.document_viewer.unsupported']),
|
|
84
|
+
...labels,
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
type Status = 'loading' | 'ready' | 'failed' | 'unsupported';
|
|
88
|
+
|
|
89
|
+
let status = $state<Status>('loading');
|
|
90
|
+
let kind = $state<'pdf' | 'image' | null>(null);
|
|
91
|
+
let imageUrl = $state<string | null>(null);
|
|
92
|
+
let pdf = $state.raw<PdfDocument | null>(null);
|
|
93
|
+
let page = $state(1);
|
|
94
|
+
let zoom = $state(MIN_ZOOM);
|
|
95
|
+
/** Live scale while two fingers pinch; committed to `zoom` when they lift. */
|
|
96
|
+
let pinchScale = $state(1);
|
|
97
|
+
let attempt = $state(0);
|
|
98
|
+
let width = $state(0);
|
|
99
|
+
let canvas = $state<HTMLCanvasElement | null>(null);
|
|
100
|
+
|
|
101
|
+
function kindOf(type: string | null | undefined): 'pdf' | 'image' | null {
|
|
102
|
+
if (!type) return null;
|
|
103
|
+
if (isPdfType(type)) return 'pdf';
|
|
104
|
+
if (isImageType(type)) return 'image';
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function defaultOpenPdf(bytes: Uint8Array): Promise<PdfDocument> {
|
|
109
|
+
// Loaded on demand so a consumer without pdfjs-dist never pulls it in.
|
|
110
|
+
try {
|
|
111
|
+
const { createPdfJsOpener } = await import('./pdf-opener.js');
|
|
112
|
+
openerFallback ??= createPdfJsOpener();
|
|
113
|
+
} catch (error) {
|
|
114
|
+
throw new PdfEngineUnavailableError(
|
|
115
|
+
error instanceof Error ? error.message : undefined,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return openerFallback(bytes);
|
|
119
|
+
}
|
|
120
|
+
let openerFallback: OpenPdf | undefined;
|
|
121
|
+
|
|
122
|
+
// Read the file whenever the source (or a retry) changes; release the last one.
|
|
123
|
+
$effect(() => {
|
|
124
|
+
const href = src;
|
|
125
|
+
const declared = mimeType;
|
|
126
|
+
const reader = fetchImpl ?? globalThis.fetch.bind(globalThis);
|
|
127
|
+
const open = openPdf ?? defaultOpenPdf;
|
|
128
|
+
void attempt;
|
|
129
|
+
let live = true;
|
|
130
|
+
let url: string | null = null;
|
|
131
|
+
let opened: PdfDocument | null = null;
|
|
132
|
+
status = 'loading';
|
|
133
|
+
imageUrl = null;
|
|
134
|
+
pdf = null;
|
|
135
|
+
kind = kindOf(declared);
|
|
136
|
+
page = 1;
|
|
137
|
+
zoom = MIN_ZOOM;
|
|
138
|
+
pinchScale = 1;
|
|
139
|
+
if (declared !== undefined && kindOf(declared) === null) {
|
|
140
|
+
status = 'unsupported';
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
void (async () => {
|
|
144
|
+
try {
|
|
145
|
+
const response = await reader(href, {
|
|
146
|
+
credentials: 'same-origin',
|
|
147
|
+
cache: 'no-store',
|
|
148
|
+
});
|
|
149
|
+
if (!response.ok) throw new Error(String(response.status));
|
|
150
|
+
const resolved =
|
|
151
|
+
kindOf(declared) ?? kindOf(response.headers?.get('content-type'));
|
|
152
|
+
if (!live) return;
|
|
153
|
+
if (resolved === null) {
|
|
154
|
+
status = 'unsupported';
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
kind = resolved;
|
|
158
|
+
if (resolved === 'pdf') {
|
|
159
|
+
const bytes = new Uint8Array(await response.arrayBuffer());
|
|
160
|
+
if (!live) return;
|
|
161
|
+
opened = await open(bytes);
|
|
162
|
+
if (!live) {
|
|
163
|
+
void opened.destroy();
|
|
164
|
+
opened = null;
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
pdf = opened;
|
|
168
|
+
} else {
|
|
169
|
+
const blob = await response.blob();
|
|
170
|
+
if (!live) return;
|
|
171
|
+
url = URL.createObjectURL(blob);
|
|
172
|
+
imageUrl = url;
|
|
173
|
+
}
|
|
174
|
+
status = 'ready';
|
|
175
|
+
} catch (error) {
|
|
176
|
+
if (!live) return;
|
|
177
|
+
status =
|
|
178
|
+
error instanceof PdfEngineUnavailableError ? 'unsupported' : 'failed';
|
|
179
|
+
}
|
|
180
|
+
})();
|
|
181
|
+
return () => {
|
|
182
|
+
live = false;
|
|
183
|
+
if (url) URL.revokeObjectURL(url);
|
|
184
|
+
if (opened) void opened.destroy();
|
|
185
|
+
};
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Draw the page at the sheet's width times the zoom.
|
|
189
|
+
$effect(() => {
|
|
190
|
+
const doc = pdf;
|
|
191
|
+
const target = canvas;
|
|
192
|
+
const at = page;
|
|
193
|
+
const cssWidth = (width || 800) * zoom;
|
|
194
|
+
if (!doc || !target) return;
|
|
195
|
+
doc.renderPage(at, target, cssWidth).catch(() => {
|
|
196
|
+
if (pdf === doc) status = 'failed';
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
function step(delta: number): void {
|
|
201
|
+
if (!pdf) return;
|
|
202
|
+
page = clampPage(page, delta, pdf.pages);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Pinch: two pointers on the sheet; the distance between them scales the file.
|
|
206
|
+
const pointers = new Map<number, { x: number; y: number }>();
|
|
207
|
+
let pinchStart = 0;
|
|
208
|
+
function distance(): number {
|
|
209
|
+
const [a, b] = [...pointers.values()];
|
|
210
|
+
return a && b ? Math.hypot(a.x - b.x, a.y - b.y) : 0;
|
|
211
|
+
}
|
|
212
|
+
function pointerDown(event: PointerEvent): void {
|
|
213
|
+
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
214
|
+
// Keep receiving moves when a finger drifts outside the sheet.
|
|
215
|
+
(event.currentTarget as HTMLElement | null)?.setPointerCapture?.(
|
|
216
|
+
event.pointerId,
|
|
217
|
+
);
|
|
218
|
+
if (pointers.size === 2) pinchStart = distance();
|
|
219
|
+
}
|
|
220
|
+
function pointerMove(event: PointerEvent): void {
|
|
221
|
+
if (!pointers.has(event.pointerId)) return;
|
|
222
|
+
pointers.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
|
223
|
+
if (pointers.size === 2 && pinchStart > 0) {
|
|
224
|
+
pinchScale = clampPinchScale(zoom, distance() / pinchStart);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
function pointerUp(event: PointerEvent): void {
|
|
228
|
+
pointers.delete(event.pointerId);
|
|
229
|
+
if (pointers.size < 2 && pinchStart > 0) {
|
|
230
|
+
zoom = commitPinchZoom(zoom, pinchScale);
|
|
231
|
+
pinchScale = 1;
|
|
232
|
+
pinchStart = 0;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
</script>
|
|
236
|
+
|
|
237
|
+
<div
|
|
238
|
+
id={id}
|
|
239
|
+
class="document-viewer {className}"
|
|
240
|
+
role="group"
|
|
241
|
+
aria-label={label}
|
|
242
|
+
data-state={status}
|
|
243
|
+
data-kind={kind ?? undefined}
|
|
244
|
+
data-smrt-component="DocumentViewer"
|
|
245
|
+
>
|
|
246
|
+
<div class="controls" data-smrt-part="controls">
|
|
247
|
+
{#if pdf && pdf.pages > 1}
|
|
248
|
+
<div class="group" role="group" aria-label={text.pages}>
|
|
249
|
+
<button
|
|
250
|
+
type="button"
|
|
251
|
+
class="ctl"
|
|
252
|
+
data-smrt-part="control"
|
|
253
|
+
data-control="previous"
|
|
254
|
+
aria-label={text.previousPage}
|
|
255
|
+
disabled={page <= 1}
|
|
256
|
+
onclick={() => step(-1)}
|
|
257
|
+
>
|
|
258
|
+
<span aria-hidden="true">◀</span>
|
|
259
|
+
</button>
|
|
260
|
+
<span class="where" data-smrt-part="page-indicator" aria-live="polite">
|
|
261
|
+
{renderTemplate(text.pageOf, { page, pages: pdf.pages })}
|
|
262
|
+
</span>
|
|
263
|
+
<button
|
|
264
|
+
type="button"
|
|
265
|
+
class="ctl"
|
|
266
|
+
data-smrt-part="control"
|
|
267
|
+
data-control="next"
|
|
268
|
+
aria-label={text.nextPage}
|
|
269
|
+
disabled={page >= pdf.pages}
|
|
270
|
+
onclick={() => step(1)}
|
|
271
|
+
>
|
|
272
|
+
<span aria-hidden="true">▶</span>
|
|
273
|
+
</button>
|
|
274
|
+
</div>
|
|
275
|
+
{/if}
|
|
276
|
+
{#if status === 'ready'}
|
|
277
|
+
<div class="group" role="group" aria-label={text.zoom}>
|
|
278
|
+
<button
|
|
279
|
+
type="button"
|
|
280
|
+
class="ctl"
|
|
281
|
+
data-smrt-part="control"
|
|
282
|
+
data-control="zoom-out"
|
|
283
|
+
aria-label={text.zoomOut}
|
|
284
|
+
disabled={zoom <= MIN_ZOOM}
|
|
285
|
+
onclick={() => (zoom = stepZoom(zoom, -1))}
|
|
286
|
+
>
|
|
287
|
+
<span aria-hidden="true">−</span>
|
|
288
|
+
</button>
|
|
289
|
+
<button
|
|
290
|
+
type="button"
|
|
291
|
+
class="ctl fit"
|
|
292
|
+
data-smrt-part="control"
|
|
293
|
+
data-control="fit"
|
|
294
|
+
disabled={zoom === MIN_ZOOM}
|
|
295
|
+
onclick={() => (zoom = MIN_ZOOM)}
|
|
296
|
+
>
|
|
297
|
+
{text.fit}
|
|
298
|
+
</button>
|
|
299
|
+
<button
|
|
300
|
+
type="button"
|
|
301
|
+
class="ctl"
|
|
302
|
+
data-smrt-part="control"
|
|
303
|
+
data-control="zoom-in"
|
|
304
|
+
aria-label={text.zoomIn}
|
|
305
|
+
disabled={zoom >= MAX_ZOOM}
|
|
306
|
+
onclick={() => (zoom = stepZoom(zoom, 1))}
|
|
307
|
+
>
|
|
308
|
+
<span aria-hidden="true">+</span>
|
|
309
|
+
</button>
|
|
310
|
+
</div>
|
|
311
|
+
{/if}
|
|
312
|
+
</div>
|
|
313
|
+
|
|
314
|
+
<!-- svelte-ignore a11y_no_static_element_interactions (pinch is a shortcut; zoom out / Fit / zoom in are the controls) -->
|
|
315
|
+
<div
|
|
316
|
+
class="sheet"
|
|
317
|
+
data-smrt-part="sheet"
|
|
318
|
+
data-zoom={zoom}
|
|
319
|
+
bind:clientWidth={width}
|
|
320
|
+
onpointerdown={pointerDown}
|
|
321
|
+
onpointermove={pointerMove}
|
|
322
|
+
onpointerup={pointerUp}
|
|
323
|
+
onpointercancel={pointerUp}
|
|
324
|
+
>
|
|
325
|
+
{#if status === 'loading'}
|
|
326
|
+
<p class="hold" data-smrt-part="loading" role="status">{text.loading}</p>
|
|
327
|
+
{:else if status === 'failed'}
|
|
328
|
+
<div class="hold" data-smrt-part="failed">
|
|
329
|
+
<p role="alert">{text.failed}</p>
|
|
330
|
+
<button
|
|
331
|
+
type="button"
|
|
332
|
+
class="ctl retry"
|
|
333
|
+
data-smrt-part="control"
|
|
334
|
+
data-control="retry"
|
|
335
|
+
onclick={() => (attempt += 1)}
|
|
336
|
+
>
|
|
337
|
+
{text.retry}
|
|
338
|
+
</button>
|
|
339
|
+
</div>
|
|
340
|
+
{:else if status === 'unsupported'}
|
|
341
|
+
<p class="hold" data-smrt-part="unsupported" role="status">{text.unsupported}</p>
|
|
342
|
+
{:else if kind === 'pdf'}
|
|
343
|
+
<canvas
|
|
344
|
+
bind:this={canvas}
|
|
345
|
+
aria-label={renderTemplate(text.pageLabel, { label, page })}
|
|
346
|
+
style:transform={pinchScale === 1 ? null : `scale(${pinchScale})`}
|
|
347
|
+
></canvas>
|
|
348
|
+
{:else if kind === 'image' && imageUrl}
|
|
349
|
+
<img
|
|
350
|
+
src={imageUrl}
|
|
351
|
+
alt={label}
|
|
352
|
+
style:width="{zoom * 100}%"
|
|
353
|
+
style:transform={pinchScale === 1 ? null : `scale(${pinchScale})`}
|
|
354
|
+
/>
|
|
355
|
+
{/if}
|
|
356
|
+
</div>
|
|
357
|
+
</div>
|
|
358
|
+
|
|
359
|
+
<style>
|
|
360
|
+
@layer smrt.reset, smrt.structure, smrt.theme;
|
|
361
|
+
@layer smrt.theme {
|
|
362
|
+
.document-viewer {
|
|
363
|
+
display: grid;
|
|
364
|
+
gap: var(--smrt-spacing-3);
|
|
365
|
+
color: var(--smrt-color-on-surface);
|
|
366
|
+
}
|
|
367
|
+
.controls {
|
|
368
|
+
display: flex;
|
|
369
|
+
flex-wrap: wrap;
|
|
370
|
+
justify-content: space-between;
|
|
371
|
+
gap: var(--smrt-spacing-3);
|
|
372
|
+
}
|
|
373
|
+
.group {
|
|
374
|
+
display: flex;
|
|
375
|
+
align-items: center;
|
|
376
|
+
gap: var(--smrt-spacing-2);
|
|
377
|
+
}
|
|
378
|
+
/* Glove-size: at least 4rem square, whatever the density token says. */
|
|
379
|
+
.ctl {
|
|
380
|
+
min-width: max(4rem, var(--smrt-touch-target-min));
|
|
381
|
+
min-height: max(4rem, var(--smrt-touch-target-min));
|
|
382
|
+
padding: 0 var(--smrt-spacing-4);
|
|
383
|
+
border: var(--smrt-hairline-strong);
|
|
384
|
+
border-radius: var(--smrt-radius-medium);
|
|
385
|
+
background: var(--smrt-color-surface-container-highest);
|
|
386
|
+
color: var(--smrt-color-on-surface);
|
|
387
|
+
font: var(--smrt-typography-headline-large-font);
|
|
388
|
+
cursor: pointer;
|
|
389
|
+
touch-action: manipulation;
|
|
390
|
+
}
|
|
391
|
+
.ctl.fit,
|
|
392
|
+
.ctl.retry {
|
|
393
|
+
font: var(--smrt-typography-title-large-font);
|
|
394
|
+
}
|
|
395
|
+
.ctl:focus-visible {
|
|
396
|
+
outline: var(--smrt-focus-ring-width) solid var(--smrt-focus-ring-color);
|
|
397
|
+
outline-offset: var(--smrt-focus-ring-offset);
|
|
398
|
+
}
|
|
399
|
+
.ctl:disabled {
|
|
400
|
+
opacity: 0.5;
|
|
401
|
+
cursor: not-allowed;
|
|
402
|
+
}
|
|
403
|
+
.where {
|
|
404
|
+
min-width: 8rem;
|
|
405
|
+
text-align: center;
|
|
406
|
+
font: var(--smrt-typography-title-large-font);
|
|
407
|
+
}
|
|
408
|
+
/* The document's own sheet: scrolls when zoomed past its width. Paper stays
|
|
409
|
+
light in every scheme (a document is content, not chrome). */
|
|
410
|
+
.sheet {
|
|
411
|
+
display: grid;
|
|
412
|
+
justify-items: start;
|
|
413
|
+
align-items: start;
|
|
414
|
+
min-height: 24rem;
|
|
415
|
+
max-height: 70vh;
|
|
416
|
+
overflow: auto;
|
|
417
|
+
border: var(--smrt-hairline-strong);
|
|
418
|
+
border-radius: var(--smrt-radius-small);
|
|
419
|
+
background: var(--smrt-color-surface-container-lowest);
|
|
420
|
+
touch-action: pan-x pan-y;
|
|
421
|
+
}
|
|
422
|
+
canvas,
|
|
423
|
+
img {
|
|
424
|
+
display: block;
|
|
425
|
+
transform-origin: 0 0;
|
|
426
|
+
}
|
|
427
|
+
img {
|
|
428
|
+
max-width: none;
|
|
429
|
+
height: auto;
|
|
430
|
+
}
|
|
431
|
+
.hold {
|
|
432
|
+
display: grid;
|
|
433
|
+
gap: var(--smrt-spacing-4);
|
|
434
|
+
place-items: center;
|
|
435
|
+
align-self: center;
|
|
436
|
+
justify-self: center;
|
|
437
|
+
margin: 0;
|
|
438
|
+
padding: var(--smrt-spacing-8);
|
|
439
|
+
text-align: center;
|
|
440
|
+
font: var(--smrt-typography-title-large-font);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
</style>
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { type OpenPdf } from './pdf-types.js';
|
|
2
|
+
import type { DocumentViewerLabels } from './types.js';
|
|
3
|
+
export interface Props {
|
|
4
|
+
/** URL of the file; read with `fetch`. */
|
|
5
|
+
src: string;
|
|
6
|
+
/** `application/pdf` or `image/*`. Omitted, the response's Content-Type decides. */
|
|
7
|
+
mimeType?: string;
|
|
8
|
+
/** Names the document for assistive technology (the image alt, the page canvas). */
|
|
9
|
+
label: string;
|
|
10
|
+
/** Reads the file; inject the app's authenticated fetch. Default: global `fetch`. */
|
|
11
|
+
fetch?: typeof globalThis.fetch;
|
|
12
|
+
/** Opens PDF bytes. Default: pdf.js via `createPdfJsOpener()`. */
|
|
13
|
+
openPdf?: OpenPdf;
|
|
14
|
+
/** Text overrides; defaults come from the `ui` i18n catalog. */
|
|
15
|
+
labels?: Partial<DocumentViewerLabels>;
|
|
16
|
+
/** DOM id of the component root. */
|
|
17
|
+
id?: string;
|
|
18
|
+
/** CSS class to apply to the component root. */
|
|
19
|
+
class?: string;
|
|
20
|
+
}
|
|
21
|
+
declare const DocumentViewer: import("svelte").Component<Props, {}, "">;
|
|
22
|
+
type DocumentViewer = ReturnType<typeof DocumentViewer>;
|
|
23
|
+
export default DocumentViewer;
|
|
24
|
+
//# sourceMappingURL=DocumentViewer.svelte.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"DocumentViewer.svelte.d.ts","sourceRoot":"","sources":["../../../src/components/display/DocumentViewer.svelte.ts"],"names":[],"mappings":"AAeA,OAAO,EACL,KAAK,OAAO,EAGb,MAAM,gBAAgB,CAAC;AACxB,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,YAAY,CAAC;AAGvD,MAAM,WAAW,KAAK;IACpB,0CAA0C;IAC1C,GAAG,EAAE,MAAM,CAAC;IACZ,oFAAoF;IACpF,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,oFAAoF;IACpF,KAAK,EAAE,MAAM,CAAC;IACd,qFAAqF;IACrF,KAAK,CAAC,EAAE,OAAO,UAAU,CAAC,KAAK,CAAC;IAChC,kEAAkE;IAClE,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,gEAAgE;IAChE,MAAM,CAAC,EAAE,OAAO,CAAC,oBAAoB,CAAC,CAAC;IACvC,oCAAoC;IACpC,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,gDAAgD;IAChD,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAsPD,QAAA,MAAM,cAAc,2CAAwC,CAAC;AAC7D,KAAK,cAAc,GAAG,UAAU,CAAC,OAAO,cAAc,CAAC,CAAC;AACxD,eAAe,cAAc,CAAC"}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure helpers for `DocumentViewer` (smrt#3463): zoom stepping, pinch
|
|
3
|
+
* clamping, page bounds, and the canvas render scale. Svelte-free.
|
|
4
|
+
*/
|
|
5
|
+
/** The zoom steps, as multiples of "Fit" (the sheet's width). */
|
|
6
|
+
export declare const ZOOM_STEPS: readonly number[];
|
|
7
|
+
/** The smallest and largest zoom. */
|
|
8
|
+
export declare const MIN_ZOOM: number;
|
|
9
|
+
export declare const MAX_ZOOM: number;
|
|
10
|
+
/** The biggest canvas drawn, in device pixels; zooming past it enlarges on screen instead. */
|
|
11
|
+
export declare const MAX_CANVAS_PIXELS: number;
|
|
12
|
+
/**
|
|
13
|
+
* The next zoom step above (`direction` 1) or below (-1) `zoom`. A zoom between
|
|
14
|
+
* steps (left by a pinch) moves to the nearest step in that direction; at an
|
|
15
|
+
* end it stays put.
|
|
16
|
+
*/
|
|
17
|
+
export declare function stepZoom(zoom: number, direction: 1 | -1): number;
|
|
18
|
+
/** Clamp a zoom to the supported range. */
|
|
19
|
+
export declare function clampZoom(zoom: number): number;
|
|
20
|
+
/** The live pinch scale, bounded so `zoom * scale` stays within the zoom range. */
|
|
21
|
+
export declare function clampPinchScale(zoom: number, scale: number): number;
|
|
22
|
+
/** The zoom a finished pinch commits: `zoom * scale`, to 0.1, within range. */
|
|
23
|
+
export declare function commitPinchZoom(zoom: number, scale: number): number;
|
|
24
|
+
/** `page + delta` kept within `1..pages`. */
|
|
25
|
+
export declare function clampPage(page: number, delta: number, pages: number): number;
|
|
26
|
+
/**
|
|
27
|
+
* The pdf.js scale that draws a page `cssWidth` wide at `ratio` device pixels
|
|
28
|
+
* per CSS pixel, reduced when the canvas would exceed `MAX_CANVAS_PIXELS`.
|
|
29
|
+
*/
|
|
30
|
+
export declare function renderScale(pageWidth: number, pageHeight: number, cssWidth: number, ratio: number, maxPixels?: number): number;
|
|
31
|
+
/** Whether a MIME type is a PDF. */
|
|
32
|
+
export declare function isPdfType(mimeType: string): boolean;
|
|
33
|
+
/** Whether a MIME type is an image the browser can show. */
|
|
34
|
+
export declare function isImageType(mimeType: string): boolean;
|
|
35
|
+
//# sourceMappingURL=document-viewer-logic.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"document-viewer-logic.d.ts","sourceRoot":"","sources":["../../../src/components/display/document-viewer-logic.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,iEAAiE;AACjE,eAAO,MAAM,UAAU,EAAE,SAAS,MAAM,EAAyB,CAAC;AAElE,qCAAqC;AACrC,eAAO,MAAM,QAAQ,EAAE,MAAgC,CAAC;AACxD,eAAO,MAAM,QAAQ,EAAE,MAAoD,CAAC;AAE5E,8FAA8F;AAC9F,eAAO,MAAM,iBAAiB,QAAc,CAAC;AAI7C;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,CAAC,GAAG,CAAC,CAAC,GAAG,MAAM,CAOhE;AAED,2CAA2C;AAC3C,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE9C;AAED,mFAAmF;AACnF,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAEnE;AAED,+EAA+E;AAC/E,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAEnE;AAED,6CAA6C;AAC7C,wBAAgB,SAAS,CAAC,IAAI,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,GAAG,MAAM,CAE5E;AAED;;;GAGG;AACH,wBAAgB,WAAW,CACzB,SAAS,EAAE,MAAM,EACjB,UAAU,EAAE,MAAM,EAClB,QAAQ,EAAE,MAAM,EAChB,KAAK,EAAE,MAAM,EACb,SAAS,GAAE,MAA0B,GACpC,MAAM,CAIR;AAED,oCAAoC;AACpC,wBAAgB,SAAS,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAEnD;AAED,4DAA4D;AAC5D,wBAAgB,WAAW,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAErD"}
|