@eka-care/medical-records-ui 1.0.3
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/README.md +151 -0
- package/dist/DocumentViewer-WXL7OZXK.mjs +197 -0
- package/dist/DocumentViewer-WXL7OZXK.mjs.map +1 -0
- package/dist/DocumentViewer-Z3GOU5NU.js +197 -0
- package/dist/DocumentViewer-Z3GOU5NU.js.map +1 -0
- package/dist/PdfViewer-EAN6EQAB.js +141 -0
- package/dist/PdfViewer-EAN6EQAB.js.map +1 -0
- package/dist/PdfViewer-WIV5AUJB.mjs +141 -0
- package/dist/PdfViewer-WIV5AUJB.mjs.map +1 -0
- package/dist/index.css +3869 -0
- package/dist/index.css.map +1 -0
- package/dist/index.d.mts +390 -0
- package/dist/index.d.ts +390 -0
- package/dist/index.js +4796 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +4796 -0
- package/dist/index.mjs.map +1 -0
- package/dist/styles.css +3869 -0
- package/dist/styles.css.map +1 -0
- package/dist/styles.d.mts +2 -0
- package/dist/styles.d.ts +2 -0
- package/package.json +57 -0
- package/src/index.ts +40 -0
package/README.md
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
# @eka-care/medical-records-ui
|
|
2
|
+
|
|
3
|
+
Framework-agnostic React SDK for the Eka Care Medical Records feature. Drop it into any React app (Next.js, Vite, Electron) to get a full records list, filters, upload flow, and document preview.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @eka-care/medical-records-ui
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
### Peer dependencies
|
|
12
|
+
|
|
13
|
+
Make sure these are installed in your host app:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm install react react-dom zustand lucide-react
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Setup
|
|
20
|
+
|
|
21
|
+
### 1. Import styles
|
|
22
|
+
|
|
23
|
+
In your app's entry point (e.g. `_app.tsx`, `main.tsx`):
|
|
24
|
+
|
|
25
|
+
```ts
|
|
26
|
+
import '@eka-care/medical-records-ui/styles';
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
### 2. Wrap with `SdkProvider`
|
|
30
|
+
|
|
31
|
+
```tsx
|
|
32
|
+
import { SdkProvider } from '@eka-care/medical-records-ui';
|
|
33
|
+
|
|
34
|
+
<SdkProvider
|
|
35
|
+
config={{ environment: 'prod' }}
|
|
36
|
+
bid="YOUR_BID"
|
|
37
|
+
patientId="YOUR_PATIENT_ID"
|
|
38
|
+
>
|
|
39
|
+
{/* your app or the RecordsView */}
|
|
40
|
+
</SdkProvider>
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
If `bid` or `patientId` are not yet available (e.g. no patient selected), `SdkProvider` renders a placeholder automatically — no extra handling needed.
|
|
44
|
+
|
|
45
|
+
### 3. Render the records view
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
import { SdkProvider, RecordsView } from '@eka-care/medical-records-ui';
|
|
49
|
+
|
|
50
|
+
export function MedicalRecordsPanel({ bid, patientId }) {
|
|
51
|
+
return (
|
|
52
|
+
<SdkProvider
|
|
53
|
+
config={{ environment: 'prod' }}
|
|
54
|
+
bid={bid}
|
|
55
|
+
patientId={patientId}
|
|
56
|
+
>
|
|
57
|
+
<RecordsView />
|
|
58
|
+
</SdkProvider>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
## `SdkProvider` props
|
|
64
|
+
|
|
65
|
+
| Prop | Type | Required | Description |
|
|
66
|
+
|------|------|----------|-------------|
|
|
67
|
+
| `config` | `SdkConfig` | Yes | SDK configuration (see below) |
|
|
68
|
+
| `bid` | `string` | No | Business/clinic ID. Shows placeholder when absent. |
|
|
69
|
+
| `patientId` | `string` | No | Patient ID. Shows placeholder when absent. |
|
|
70
|
+
| `documentTypes` | `DocumentTypeConfig[]` | No | Document type labels from your onboarding config. Falls back to built-in labels. |
|
|
71
|
+
|
|
72
|
+
### `SdkConfig`
|
|
73
|
+
|
|
74
|
+
| Field | Type | Description |
|
|
75
|
+
|-------|------|-------------|
|
|
76
|
+
| `environment` | `'prod' \| 'staging'` | API environment |
|
|
77
|
+
| `baseUrl` | `string` | Override the API base URL (useful for proxying) |
|
|
78
|
+
| `accessToken` | `string` | Auth token. Not needed if using cookie-based auth. |
|
|
79
|
+
| `onUnauthorized` | `() => void` | Called on 401 — use to refresh the token or redirect to login |
|
|
80
|
+
| `defaultHeaders` | `Record<string, string>` | Extra headers added to every request |
|
|
81
|
+
|
|
82
|
+
## `RecordsView` props
|
|
83
|
+
|
|
84
|
+
| Prop | Type | Description |
|
|
85
|
+
|------|------|-------------|
|
|
86
|
+
| `allowUpload` | `boolean` | Show/hide the upload button. Default: `true` |
|
|
87
|
+
| `attachedIds` | `string[]` | Record IDs already attached to the current session context |
|
|
88
|
+
| `maxAttachable` | `number` | Max records attachable at once |
|
|
89
|
+
| `onAttachToContext` | `(records) => void` | Called when user adds a record to session context |
|
|
90
|
+
| `onRemoveAttachment` | `(documentId) => void` | Called when user removes a record from context |
|
|
91
|
+
| `onCopyToNote` | `(text, anchor?) => void` | Called when user copies a smart report to notes |
|
|
92
|
+
| `onToast` | `(message, type?) => void` | Hook into the host app's toast/notification system |
|
|
93
|
+
|
|
94
|
+
## Authentication
|
|
95
|
+
|
|
96
|
+
The SDK uses cookie-based auth automatically when running on the same domain as the Eka Care backend. For token-based auth pass `accessToken` in config and provide `onUnauthorized` to handle token refresh:
|
|
97
|
+
|
|
98
|
+
```tsx
|
|
99
|
+
<SdkProvider
|
|
100
|
+
config={{
|
|
101
|
+
environment: 'prod',
|
|
102
|
+
accessToken: yourToken,
|
|
103
|
+
onUnauthorized: refreshToken,
|
|
104
|
+
}}
|
|
105
|
+
bid={bid}
|
|
106
|
+
patientId={patientId}
|
|
107
|
+
>
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## PDF support
|
|
111
|
+
|
|
112
|
+
No setup required. PDF preview works out of the box — the SDK loads the pdfjs worker from CDN automatically.
|
|
113
|
+
|
|
114
|
+
## Next.js
|
|
115
|
+
|
|
116
|
+
Works without any extra webpack config. `react-pdf` and `pdfjs-dist` are peer dependencies — install them in your app:
|
|
117
|
+
|
|
118
|
+
```bash
|
|
119
|
+
npm install react-pdf pdfjs-dist
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## Development
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
# Start the dev playground at https://test.1.eka.care:4443
|
|
126
|
+
npm run dev
|
|
127
|
+
|
|
128
|
+
# Build the SDK
|
|
129
|
+
npm run build
|
|
130
|
+
|
|
131
|
+
# Run tests
|
|
132
|
+
npm test
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Dev playground setup
|
|
136
|
+
|
|
137
|
+
The playground runs on `https://test.1.eka.care:4443` so auth cookies from the Eka Care backend are available.
|
|
138
|
+
|
|
139
|
+
1. Add to `/etc/hosts`:
|
|
140
|
+
```
|
|
141
|
+
127.0.0.1 test.1.eka.care
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
2. Generate a local certificate:
|
|
145
|
+
```bash
|
|
146
|
+
cd certificates && mkcert test.1.eka.care
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
3. Log into the Eka Care app on `test.1.eka.care` so the auth cookie is set.
|
|
150
|
+
|
|
151
|
+
4. Run `npm run dev` and open `https://test.1.eka.care:4443`.
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
// src/views/RecordPreview/DocumentViewer.tsx
|
|
2
|
+
import { lazy, Suspense, useCallback, useEffect as useEffect2, useRef, useState as useState2 } from "react";
|
|
3
|
+
import {
|
|
4
|
+
ChevronLeft,
|
|
5
|
+
ChevronRight,
|
|
6
|
+
Minus,
|
|
7
|
+
Plus,
|
|
8
|
+
Download,
|
|
9
|
+
Maximize,
|
|
10
|
+
Minimize,
|
|
11
|
+
Globe
|
|
12
|
+
} from "lucide-react";
|
|
13
|
+
|
|
14
|
+
// src/views/RecordPreview/TextViewer.tsx
|
|
15
|
+
import { useEffect, useState } from "react";
|
|
16
|
+
import { jsx } from "react/jsx-runtime";
|
|
17
|
+
function TextViewer({ url }) {
|
|
18
|
+
const [state, setState] = useState({
|
|
19
|
+
status: "loading",
|
|
20
|
+
text: ""
|
|
21
|
+
});
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
let cancelled = false;
|
|
24
|
+
setState({ status: "loading", text: "" });
|
|
25
|
+
(async () => {
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch(url);
|
|
28
|
+
if (!res.ok) throw new Error(`fetch ${res.status}`);
|
|
29
|
+
const text = await res.text();
|
|
30
|
+
if (!cancelled) setState({ status: "loaded", text });
|
|
31
|
+
} catch {
|
|
32
|
+
if (!cancelled) setState({ status: "error", text: "" });
|
|
33
|
+
}
|
|
34
|
+
})();
|
|
35
|
+
return () => {
|
|
36
|
+
cancelled = true;
|
|
37
|
+
};
|
|
38
|
+
}, [url]);
|
|
39
|
+
if (state.status === "loading") {
|
|
40
|
+
return /* @__PURE__ */ jsx("div", { className: "mr-doc-viewer__status", children: "Loading document..." });
|
|
41
|
+
}
|
|
42
|
+
if (state.status === "error") {
|
|
43
|
+
return /* @__PURE__ */ jsx("div", { className: "mr-doc-viewer__status", children: "Couldn't load this document." });
|
|
44
|
+
}
|
|
45
|
+
if (!state.text.trim()) {
|
|
46
|
+
return /* @__PURE__ */ jsx("div", { className: "mr-doc-viewer__status", children: "This file appears to be empty." });
|
|
47
|
+
}
|
|
48
|
+
return /* @__PURE__ */ jsx(
|
|
49
|
+
"iframe",
|
|
50
|
+
{
|
|
51
|
+
className: "mr-doc-viewer__html-frame",
|
|
52
|
+
srcDoc: state.text,
|
|
53
|
+
sandbox: "allow-same-origin",
|
|
54
|
+
title: "HTML preview"
|
|
55
|
+
}
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/views/RecordPreview/DocumentViewer.tsx
|
|
60
|
+
import { jsx as jsx2, jsxs } from "react/jsx-runtime";
|
|
61
|
+
var PdfViewer = lazy(() => import("./PdfViewer-WIV5AUJB.mjs").then((m) => ({ default: m.PdfViewer })));
|
|
62
|
+
var PAGE_WIDTH = 595;
|
|
63
|
+
var ZOOM_STEP = 0.25;
|
|
64
|
+
var ZOOM_MIN = 0.5;
|
|
65
|
+
var ZOOM_MAX = 3;
|
|
66
|
+
function DocumentViewer({ files, title, isHtml }) {
|
|
67
|
+
const [zoom, setZoom] = useState2(1);
|
|
68
|
+
const [pageInfo, setPageInfo] = useState2({ current: 1, total: 0 });
|
|
69
|
+
const [scrollToPage, setScrollToPage] = useState2(1);
|
|
70
|
+
const [isFullscreen, setIsFullscreen] = useState2(false);
|
|
71
|
+
const rootRef = useRef(null);
|
|
72
|
+
useEffect2(() => {
|
|
73
|
+
const onChange = () => setIsFullscreen(document.fullscreenElement === rootRef.current);
|
|
74
|
+
document.addEventListener("fullscreenchange", onChange);
|
|
75
|
+
return () => document.removeEventListener("fullscreenchange", onChange);
|
|
76
|
+
}, []);
|
|
77
|
+
const toggleFullscreen = () => {
|
|
78
|
+
if (document.fullscreenElement) void document.exitFullscreen();
|
|
79
|
+
else void rootRef.current?.requestFullscreen?.();
|
|
80
|
+
};
|
|
81
|
+
const primary = files[0] ?? null;
|
|
82
|
+
const isPdf = primary?.kind === "pdf";
|
|
83
|
+
const images = files.filter((f) => f.kind === "image");
|
|
84
|
+
const isZoomable = isPdf || images.length > 0;
|
|
85
|
+
const zoomOut = () => setZoom((z) => Math.max(ZOOM_MIN, +(z - ZOOM_STEP).toFixed(2)));
|
|
86
|
+
const zoomIn = () => setZoom((z) => Math.min(ZOOM_MAX, +(z + ZOOM_STEP).toFixed(2)));
|
|
87
|
+
const prevPage = () => setScrollToPage(Math.max(1, pageInfo.current - 1));
|
|
88
|
+
const nextPage = () => setScrollToPage(Math.min(pageInfo.total, pageInfo.current + 1));
|
|
89
|
+
const withBlobUrl = useCallback(async (fn) => {
|
|
90
|
+
if (!primary) return;
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetch(primary.url);
|
|
93
|
+
const blob = await res.blob();
|
|
94
|
+
const objUrl = URL.createObjectURL(blob);
|
|
95
|
+
fn(objUrl);
|
|
96
|
+
setTimeout(() => URL.revokeObjectURL(objUrl), 6e4);
|
|
97
|
+
} catch {
|
|
98
|
+
fn(primary.url);
|
|
99
|
+
}
|
|
100
|
+
}, [primary]);
|
|
101
|
+
const handleDownload = () => withBlobUrl((objUrl) => {
|
|
102
|
+
const a = document.createElement("a");
|
|
103
|
+
a.href = objUrl;
|
|
104
|
+
a.download = title || "document";
|
|
105
|
+
a.click();
|
|
106
|
+
});
|
|
107
|
+
return /* @__PURE__ */ jsxs("div", { className: `mr-doc-viewer${isHtml ? " mr-doc-viewer--html" : ""}`, ref: rootRef, children: [
|
|
108
|
+
!isHtml && /* @__PURE__ */ jsxs("div", { className: "mr-doc-viewer__toolbar", children: [
|
|
109
|
+
/* @__PURE__ */ jsx2("span", { className: "mr-doc-viewer__filename", children: title }),
|
|
110
|
+
isPdf && /* @__PURE__ */ jsxs("div", { className: "mr-doc-viewer__pages", children: [
|
|
111
|
+
/* @__PURE__ */ jsx2(
|
|
112
|
+
"button",
|
|
113
|
+
{
|
|
114
|
+
type: "button",
|
|
115
|
+
"aria-label": "Previous page",
|
|
116
|
+
onClick: prevPage,
|
|
117
|
+
disabled: pageInfo.current <= 1,
|
|
118
|
+
children: /* @__PURE__ */ jsx2(ChevronLeft, { size: 18, "aria-hidden": true })
|
|
119
|
+
}
|
|
120
|
+
),
|
|
121
|
+
/* @__PURE__ */ jsx2("span", { className: "mr-doc-viewer__page-badge", children: pageInfo.current }),
|
|
122
|
+
/* @__PURE__ */ jsx2("span", { children: "/" }),
|
|
123
|
+
/* @__PURE__ */ jsx2("span", { children: pageInfo.total || "\u2014" }),
|
|
124
|
+
/* @__PURE__ */ jsx2(
|
|
125
|
+
"button",
|
|
126
|
+
{
|
|
127
|
+
type: "button",
|
|
128
|
+
"aria-label": "Next page",
|
|
129
|
+
onClick: nextPage,
|
|
130
|
+
disabled: pageInfo.current >= pageInfo.total,
|
|
131
|
+
children: /* @__PURE__ */ jsx2(ChevronRight, { size: 18, "aria-hidden": true })
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
] }),
|
|
135
|
+
isZoomable && /* @__PURE__ */ jsxs("div", { className: "mr-doc-viewer__zoom", children: [
|
|
136
|
+
/* @__PURE__ */ jsx2("button", { type: "button", "aria-label": "Zoom out", onClick: zoomOut, disabled: zoom <= ZOOM_MIN, children: /* @__PURE__ */ jsx2(Minus, { size: 20, "aria-hidden": true }) }),
|
|
137
|
+
/* @__PURE__ */ jsxs("span", { className: "mr-doc-viewer__zoom-badge", children: [
|
|
138
|
+
Math.round(zoom * 100),
|
|
139
|
+
"%"
|
|
140
|
+
] }),
|
|
141
|
+
/* @__PURE__ */ jsx2("button", { type: "button", "aria-label": "Zoom in", onClick: zoomIn, disabled: zoom >= ZOOM_MAX, children: /* @__PURE__ */ jsx2(Plus, { size: 20, "aria-hidden": true }) })
|
|
142
|
+
] }),
|
|
143
|
+
/* @__PURE__ */ jsxs("div", { className: "mr-doc-viewer__actions", children: [
|
|
144
|
+
/* @__PURE__ */ jsx2("button", { type: "button", "aria-label": "Download", onClick: handleDownload, disabled: !primary, children: /* @__PURE__ */ jsx2(Download, { size: 20, "aria-hidden": true }) }),
|
|
145
|
+
/* @__PURE__ */ jsx2(
|
|
146
|
+
"button",
|
|
147
|
+
{
|
|
148
|
+
type: "button",
|
|
149
|
+
"aria-label": isFullscreen ? "Exit full screen" : "Full screen",
|
|
150
|
+
onClick: toggleFullscreen,
|
|
151
|
+
children: isFullscreen ? /* @__PURE__ */ jsx2(Minimize, { size: 20, "aria-hidden": true }) : /* @__PURE__ */ jsx2(Maximize, { size: 20, "aria-hidden": true })
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
] })
|
|
155
|
+
] }),
|
|
156
|
+
isHtml && /* @__PURE__ */ jsxs("div", { className: "mr-doc-viewer__html-bar", children: [
|
|
157
|
+
/* @__PURE__ */ jsxs("div", { className: "mr-doc-viewer__html-bar-left", children: [
|
|
158
|
+
/* @__PURE__ */ jsx2(Globe, { size: 12, "aria-hidden": true }),
|
|
159
|
+
/* @__PURE__ */ jsx2("span", { children: "Previewing HTML file \u2013 you can copy to a note and edit this file. The original record that you are viewing now will not be affected." })
|
|
160
|
+
] }),
|
|
161
|
+
/* @__PURE__ */ jsx2(
|
|
162
|
+
"button",
|
|
163
|
+
{
|
|
164
|
+
type: "button",
|
|
165
|
+
className: "mr-doc-viewer__html-download",
|
|
166
|
+
"aria-label": "Download",
|
|
167
|
+
onClick: handleDownload,
|
|
168
|
+
disabled: !primary,
|
|
169
|
+
children: /* @__PURE__ */ jsx2(Download, { size: 16, "aria-hidden": true })
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
] }),
|
|
173
|
+
/* @__PURE__ */ jsx2("div", { className: "mr-doc-viewer__canvas", children: !primary ? /* @__PURE__ */ jsx2("div", { className: "mr-doc-viewer__status", children: "No preview available for this record." }) : isPdf ? /* @__PURE__ */ jsx2(Suspense, { fallback: /* @__PURE__ */ jsx2("div", { className: "mr-doc-viewer__status", children: "Loading viewer\u2026" }), children: /* @__PURE__ */ jsx2(
|
|
174
|
+
PdfViewer,
|
|
175
|
+
{
|
|
176
|
+
url: primary.url,
|
|
177
|
+
baseWidth: PAGE_WIDTH,
|
|
178
|
+
scale: zoom,
|
|
179
|
+
scrollToPage,
|
|
180
|
+
onPageInfo: setPageInfo
|
|
181
|
+
}
|
|
182
|
+
) }) : images.length > 0 ? images.map((img, i) => /* @__PURE__ */ jsx2(
|
|
183
|
+
"img",
|
|
184
|
+
{
|
|
185
|
+
className: "mr-doc-viewer__image",
|
|
186
|
+
src: img.url,
|
|
187
|
+
alt: `${title} ${i + 1}`,
|
|
188
|
+
style: { transform: `scale(${zoom})`, transformOrigin: "top center" }
|
|
189
|
+
},
|
|
190
|
+
i
|
|
191
|
+
)) : /* @__PURE__ */ jsx2(TextViewer, { url: primary.url }) })
|
|
192
|
+
] });
|
|
193
|
+
}
|
|
194
|
+
export {
|
|
195
|
+
DocumentViewer
|
|
196
|
+
};
|
|
197
|
+
//# sourceMappingURL=DocumentViewer-WXL7OZXK.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/views/RecordPreview/DocumentViewer.tsx","../src/views/RecordPreview/TextViewer.tsx"],"sourcesContent":["import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react';\nimport {\n ChevronLeft,\n ChevronRight,\n Minus,\n Plus,\n Download,\n Maximize,\n Minimize,\n Globe,\n} from 'lucide-react';\nimport { TextViewer } from './TextViewer';\nimport type { PreviewFile } from '../../connection';\n\nconst PdfViewer = lazy(() => import('./PdfViewer').then((m) => ({ default: m.PdfViewer })));\n\nexport interface DocumentViewerProps {\n files: PreviewFile[];\n title: string;\n isHtml?: boolean;\n}\n\nconst PAGE_WIDTH = 595; \nconst ZOOM_STEP = 0.25;\nconst ZOOM_MIN = 0.5;\nconst ZOOM_MAX = 3;\n\nexport function DocumentViewer({ files, title, isHtml }: DocumentViewerProps) {\n const [zoom, setZoom] = useState(1);\n const [pageInfo, setPageInfo] = useState({ current: 1, total: 0 });\n const [scrollToPage, setScrollToPage] = useState(1);\n const [isFullscreen, setIsFullscreen] = useState(false);\n const rootRef = useRef<HTMLDivElement>(null);\n\n useEffect(() => {\n const onChange = () => setIsFullscreen(document.fullscreenElement === rootRef.current);\n document.addEventListener('fullscreenchange', onChange);\n return () => document.removeEventListener('fullscreenchange', onChange);\n }, []);\n\n const toggleFullscreen = () => {\n if (document.fullscreenElement) void document.exitFullscreen();\n else void rootRef.current?.requestFullscreen?.();\n };\n\n const primary = files[0] ?? null;\n const isPdf = primary?.kind === 'pdf';\n const images = files.filter((f) => f.kind === 'image');\n const isZoomable = isPdf || images.length > 0;\n\n const zoomOut = () => setZoom((z) => Math.max(ZOOM_MIN, +(z - ZOOM_STEP).toFixed(2)));\n const zoomIn = () => setZoom((z) => Math.min(ZOOM_MAX, +(z + ZOOM_STEP).toFixed(2)));\n\n const prevPage = () => setScrollToPage(Math.max(1, pageInfo.current - 1));\n const nextPage = () => setScrollToPage(Math.min(pageInfo.total, pageInfo.current + 1));\n\n // Download / print fetch the file into a blob URL rather than re-opening the\n // signed URL directly, so it works even when the document is already loaded.\n const withBlobUrl = useCallback(async (fn: (objUrl: string) => void) => {\n if (!primary) return;\n try {\n const res = await fetch(primary.url);\n const blob = await res.blob();\n const objUrl = URL.createObjectURL(blob);\n fn(objUrl);\n // Revoke after the consumer has had a chance to use it.\n setTimeout(() => URL.revokeObjectURL(objUrl), 60_000);\n } catch {\n // Fall back to opening the original URL.\n fn(primary.url);\n }\n }, [primary]);\n\n const handleDownload = () =>\n withBlobUrl((objUrl) => {\n const a = document.createElement('a');\n a.href = objUrl;\n a.download = title || 'document';\n a.click();\n });\n\n return (\n <div className={`mr-doc-viewer${isHtml ? ' mr-doc-viewer--html' : ''}`} ref={rootRef}>\n {!isHtml && <div className=\"mr-doc-viewer__toolbar\">\n <span className=\"mr-doc-viewer__filename\">{title}</span>\n {isPdf && (\n <div className=\"mr-doc-viewer__pages\">\n <button\n type=\"button\"\n aria-label=\"Previous page\"\n onClick={prevPage}\n disabled={pageInfo.current <= 1}\n >\n <ChevronLeft size={18} aria-hidden />\n </button>\n <span className=\"mr-doc-viewer__page-badge\">{pageInfo.current}</span>\n <span>/</span>\n <span>{pageInfo.total || '—'}</span>\n <button\n type=\"button\"\n aria-label=\"Next page\"\n onClick={nextPage}\n disabled={pageInfo.current >= pageInfo.total}\n >\n <ChevronRight size={18} aria-hidden />\n </button>\n </div>\n )}\n {isZoomable && (\n <div className=\"mr-doc-viewer__zoom\">\n <button type=\"button\" aria-label=\"Zoom out\" onClick={zoomOut} disabled={zoom <= ZOOM_MIN}>\n <Minus size={20} aria-hidden />\n </button>\n <span className=\"mr-doc-viewer__zoom-badge\">{Math.round(zoom * 100)}%</span>\n <button type=\"button\" aria-label=\"Zoom in\" onClick={zoomIn} disabled={zoom >= ZOOM_MAX}>\n <Plus size={20} aria-hidden />\n </button>\n </div>\n )}\n <div className=\"mr-doc-viewer__actions\">\n <button type=\"button\" aria-label=\"Download\" onClick={handleDownload} disabled={!primary}>\n <Download size={20} aria-hidden />\n </button>\n <button\n type=\"button\"\n aria-label={isFullscreen ? 'Exit full screen' : 'Full screen'}\n onClick={toggleFullscreen}\n >\n {isFullscreen ? <Minimize size={20} aria-hidden /> : <Maximize size={20} aria-hidden />}\n </button>\n </div>\n </div>}\n\n {isHtml && (\n <div className=\"mr-doc-viewer__html-bar\">\n <div className=\"mr-doc-viewer__html-bar-left\">\n <Globe size={12} aria-hidden />\n <span>Previewing HTML file – you can copy to a note and edit this file. The original record that you are viewing now will not be affected.</span>\n </div>\n <button\n type=\"button\"\n className=\"mr-doc-viewer__html-download\"\n aria-label=\"Download\"\n onClick={handleDownload}\n disabled={!primary}\n >\n <Download size={16} aria-hidden />\n </button>\n </div>\n )}\n\n <div className=\"mr-doc-viewer__canvas\">\n {!primary ? (\n <div className=\"mr-doc-viewer__status\">No preview available for this record.</div>\n ) : isPdf ? (\n <Suspense fallback={<div className=\"mr-doc-viewer__status\">Loading viewer…</div>}>\n <PdfViewer\n url={primary.url}\n baseWidth={PAGE_WIDTH}\n scale={zoom}\n scrollToPage={scrollToPage}\n onPageInfo={setPageInfo}\n />\n </Suspense>\n ) : images.length > 0 ? (\n images.map((img, i) => (\n <img\n key={i}\n className=\"mr-doc-viewer__image\"\n src={img.url}\n alt={`${title} ${i + 1}`}\n style={{ transform: `scale(${zoom})`, transformOrigin: 'top center' }}\n />\n ))\n ) : (\n <TextViewer url={primary.url} />\n )}\n </div>\n </div>\n );\n}\n","import { useEffect, useState } from 'react';\n\nexport interface TextViewerProps {\n url: string;\n}\n\nexport function TextViewer({ url }: TextViewerProps) {\n const [state, setState] = useState<{ status: 'loading' | 'loaded' | 'error'; text: string }>({\n status: 'loading',\n text: '',\n });\n\n useEffect(() => {\n let cancelled = false;\n setState({ status: 'loading', text: '' });\n (async () => {\n try {\n const res = await fetch(url);\n if (!res.ok) throw new Error(`fetch ${res.status}`);\n const text = await res.text();\n if (!cancelled) setState({ status: 'loaded', text });\n } catch {\n if (!cancelled) setState({ status: 'error', text: '' });\n }\n })();\n return () => {\n cancelled = true;\n };\n }, [url]);\n\n if (state.status === 'loading') {\n return <div className=\"mr-doc-viewer__status\">Loading document...</div>;\n }\n if (state.status === 'error') {\n return <div className=\"mr-doc-viewer__status\">Couldn't load this document.</div>;\n }\n if (!state.text.trim()) {\n return <div className=\"mr-doc-viewer__status\">This file appears to be empty.</div>;\n }\n return (\n <iframe\n className=\"mr-doc-viewer__html-frame\"\n srcDoc={state.text}\n sandbox=\"allow-same-origin\"\n title=\"HTML preview\"\n />\n );\n}\n"],"mappings":";AAAA,SAAS,MAAM,UAAU,aAAa,aAAAA,YAAW,QAAQ,YAAAC,iBAAgB;AACzE;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;;;ACVP,SAAS,WAAW,gBAAgB;AA+BzB;AAzBJ,SAAS,WAAW,EAAE,IAAI,GAAoB;AACnD,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAmE;AAAA,IAC3F,QAAQ;AAAA,IACR,MAAM;AAAA,EACR,CAAC;AAED,YAAU,MAAM;AACd,QAAI,YAAY;AAChB,aAAS,EAAE,QAAQ,WAAW,MAAM,GAAG,CAAC;AACxC,KAAC,YAAY;AACX,UAAI;AACF,cAAM,MAAM,MAAM,MAAM,GAAG;AAC3B,YAAI,CAAC,IAAI,GAAI,OAAM,IAAI,MAAM,SAAS,IAAI,MAAM,EAAE;AAClD,cAAM,OAAO,MAAM,IAAI,KAAK;AAC5B,YAAI,CAAC,UAAW,UAAS,EAAE,QAAQ,UAAU,KAAK,CAAC;AAAA,MACrD,QAAQ;AACN,YAAI,CAAC,UAAW,UAAS,EAAE,QAAQ,SAAS,MAAM,GAAG,CAAC;AAAA,MACxD;AAAA,IACF,GAAG;AACH,WAAO,MAAM;AACX,kBAAY;AAAA,IACd;AAAA,EACF,GAAG,CAAC,GAAG,CAAC;AAER,MAAI,MAAM,WAAW,WAAW;AAC9B,WAAO,oBAAC,SAAI,WAAU,yBAAwB,iCAAmB;AAAA,EACnE;AACA,MAAI,MAAM,WAAW,SAAS;AAC5B,WAAO,oBAAC,SAAI,WAAU,yBAAwB,0CAA4B;AAAA,EAC5E;AACA,MAAI,CAAC,MAAM,KAAK,KAAK,GAAG;AACtB,WAAO,oBAAC,SAAI,WAAU,yBAAwB,4CAA8B;AAAA,EAC9E;AACA,SACE;AAAA,IAAC;AAAA;AAAA,MACC,WAAU;AAAA,MACV,QAAQ,MAAM;AAAA,MACd,SAAQ;AAAA,MACR,OAAM;AAAA;AAAA,EACR;AAEJ;;;ADqCQ,gBAAAC,MAEE,YAFF;AAtER,IAAM,YAAY,KAAK,MAAM,OAAO,0BAAa,EAAE,KAAK,CAAC,OAAO,EAAE,SAAS,EAAE,UAAU,EAAE,CAAC;AAQ1F,IAAM,aAAa;AACnB,IAAM,YAAY;AAClB,IAAM,WAAW;AACjB,IAAM,WAAW;AAEV,SAAS,eAAe,EAAE,OAAO,OAAO,OAAO,GAAwB;AAC5E,QAAM,CAAC,MAAM,OAAO,IAAIC,UAAS,CAAC;AAClC,QAAM,CAAC,UAAU,WAAW,IAAIA,UAAS,EAAE,SAAS,GAAG,OAAO,EAAE,CAAC;AACjE,QAAM,CAAC,cAAc,eAAe,IAAIA,UAAS,CAAC;AAClD,QAAM,CAAC,cAAc,eAAe,IAAIA,UAAS,KAAK;AACtD,QAAM,UAAU,OAAuB,IAAI;AAE3C,EAAAC,WAAU,MAAM;AACd,UAAM,WAAW,MAAM,gBAAgB,SAAS,sBAAsB,QAAQ,OAAO;AACrF,aAAS,iBAAiB,oBAAoB,QAAQ;AACtD,WAAO,MAAM,SAAS,oBAAoB,oBAAoB,QAAQ;AAAA,EACxE,GAAG,CAAC,CAAC;AAEL,QAAM,mBAAmB,MAAM;AAC7B,QAAI,SAAS,kBAAmB,MAAK,SAAS,eAAe;AAAA,QACxD,MAAK,QAAQ,SAAS,oBAAoB;AAAA,EACjD;AAEA,QAAM,UAAU,MAAM,CAAC,KAAK;AAC5B,QAAM,QAAQ,SAAS,SAAS;AAChC,QAAM,SAAS,MAAM,OAAO,CAAC,MAAM,EAAE,SAAS,OAAO;AACrD,QAAM,aAAa,SAAS,OAAO,SAAS;AAE5C,QAAM,UAAU,MAAM,QAAQ,CAAC,MAAM,KAAK,IAAI,UAAU,EAAE,IAAI,WAAW,QAAQ,CAAC,CAAC,CAAC;AACpF,QAAM,SAAS,MAAM,QAAQ,CAAC,MAAM,KAAK,IAAI,UAAU,EAAE,IAAI,WAAW,QAAQ,CAAC,CAAC,CAAC;AAEnF,QAAM,WAAW,MAAM,gBAAgB,KAAK,IAAI,GAAG,SAAS,UAAU,CAAC,CAAC;AACxE,QAAM,WAAW,MAAM,gBAAgB,KAAK,IAAI,SAAS,OAAO,SAAS,UAAU,CAAC,CAAC;AAIrF,QAAM,cAAc,YAAY,OAAO,OAAiC;AACtE,QAAI,CAAC,QAAS;AACd,QAAI;AACF,YAAM,MAAM,MAAM,MAAM,QAAQ,GAAG;AACnC,YAAM,OAAO,MAAM,IAAI,KAAK;AAC5B,YAAM,SAAS,IAAI,gBAAgB,IAAI;AACvC,SAAG,MAAM;AAET,iBAAW,MAAM,IAAI,gBAAgB,MAAM,GAAG,GAAM;AAAA,IACtD,QAAQ;AAEN,SAAG,QAAQ,GAAG;AAAA,IAChB;AAAA,EACF,GAAG,CAAC,OAAO,CAAC;AAEZ,QAAM,iBAAiB,MACrB,YAAY,CAAC,WAAW;AACtB,UAAM,IAAI,SAAS,cAAc,GAAG;AACpC,MAAE,OAAO;AACT,MAAE,WAAW,SAAS;AACtB,MAAE,MAAM;AAAA,EACV,CAAC;AAEH,SACE,qBAAC,SAAI,WAAW,gBAAgB,SAAS,yBAAyB,EAAE,IAAI,KAAK,SAC1E;AAAA,KAAC,UAAU,qBAAC,SAAI,WAAU,0BACzB;AAAA,sBAAAF,KAAC,UAAK,WAAU,2BAA2B,iBAAM;AAAA,MAChD,SACC,qBAAC,SAAI,WAAU,wBACb;AAAA,wBAAAA;AAAA,UAAC;AAAA;AAAA,YACC,MAAK;AAAA,YACL,cAAW;AAAA,YACX,SAAS;AAAA,YACT,UAAU,SAAS,WAAW;AAAA,YAE9B,0BAAAA,KAAC,eAAY,MAAM,IAAI,eAAW,MAAC;AAAA;AAAA,QACrC;AAAA,QACA,gBAAAA,KAAC,UAAK,WAAU,6BAA6B,mBAAS,SAAQ;AAAA,QAC9D,gBAAAA,KAAC,UAAK,eAAC;AAAA,QACP,gBAAAA,KAAC,UAAM,mBAAS,SAAS,UAAI;AAAA,QAC7B,gBAAAA;AAAA,UAAC;AAAA;AAAA,YACC,MAAK;AAAA,YACL,cAAW;AAAA,YACX,SAAS;AAAA,YACT,UAAU,SAAS,WAAW,SAAS;AAAA,YAEvC,0BAAAA,KAAC,gBAAa,MAAM,IAAI,eAAW,MAAC;AAAA;AAAA,QACtC;AAAA,SACF;AAAA,MAED,cACC,qBAAC,SAAI,WAAU,uBACb;AAAA,wBAAAA,KAAC,YAAO,MAAK,UAAS,cAAW,YAAW,SAAS,SAAS,UAAU,QAAQ,UAC9E,0BAAAA,KAAC,SAAM,MAAM,IAAI,eAAW,MAAC,GAC/B;AAAA,QACA,qBAAC,UAAK,WAAU,6BAA6B;AAAA,eAAK,MAAM,OAAO,GAAG;AAAA,UAAE;AAAA,WAAC;AAAA,QACrE,gBAAAA,KAAC,YAAO,MAAK,UAAS,cAAW,WAAU,SAAS,QAAQ,UAAU,QAAQ,UAC5E,0BAAAA,KAAC,QAAK,MAAM,IAAI,eAAW,MAAC,GAC9B;AAAA,SACF;AAAA,MAEF,qBAAC,SAAI,WAAU,0BACb;AAAA,wBAAAA,KAAC,YAAO,MAAK,UAAS,cAAW,YAAW,SAAS,gBAAgB,UAAU,CAAC,SAC9E,0BAAAA,KAAC,YAAS,MAAM,IAAI,eAAW,MAAC,GAClC;AAAA,QACA,gBAAAA;AAAA,UAAC;AAAA;AAAA,YACC,MAAK;AAAA,YACL,cAAY,eAAe,qBAAqB;AAAA,YAChD,SAAS;AAAA,YAER,yBAAe,gBAAAA,KAAC,YAAS,MAAM,IAAI,eAAW,MAAC,IAAK,gBAAAA,KAAC,YAAS,MAAM,IAAI,eAAW,MAAC;AAAA;AAAA,QACvF;AAAA,SACF;AAAA,OACF;AAAA,IAEC,UACC,qBAAC,SAAI,WAAU,2BACb;AAAA,2BAAC,SAAI,WAAU,gCACb;AAAA,wBAAAA,KAAC,SAAM,MAAM,IAAI,eAAW,MAAC;AAAA,QAC7B,gBAAAA,KAAC,UAAK,uJAAoI;AAAA,SAC5I;AAAA,MACA,gBAAAA;AAAA,QAAC;AAAA;AAAA,UACC,MAAK;AAAA,UACL,WAAU;AAAA,UACV,cAAW;AAAA,UACX,SAAS;AAAA,UACT,UAAU,CAAC;AAAA,UAEX,0BAAAA,KAAC,YAAS,MAAM,IAAI,eAAW,MAAC;AAAA;AAAA,MAClC;AAAA,OACF;AAAA,IAGF,gBAAAA,KAAC,SAAI,WAAU,yBACZ,WAAC,UACA,gBAAAA,KAAC,SAAI,WAAU,yBAAwB,mDAAqC,IAC1E,QACF,gBAAAA,KAAC,YAAS,UAAU,gBAAAA,KAAC,SAAI,WAAU,yBAAwB,kCAAe,GACxE,0BAAAA;AAAA,MAAC;AAAA;AAAA,QACC,KAAK,QAAQ;AAAA,QACb,WAAW;AAAA,QACX,OAAO;AAAA,QACP;AAAA,QACA,YAAY;AAAA;AAAA,IACd,GACF,IACE,OAAO,SAAS,IAClB,OAAO,IAAI,CAAC,KAAK,MACf,gBAAAA;AAAA,MAAC;AAAA;AAAA,QAEC,WAAU;AAAA,QACV,KAAK,IAAI;AAAA,QACT,KAAK,GAAG,KAAK,IAAI,IAAI,CAAC;AAAA,QACtB,OAAO,EAAE,WAAW,SAAS,IAAI,KAAK,iBAAiB,aAAa;AAAA;AAAA,MAJ/D;AAAA,IAKP,CACD,IAED,gBAAAA,KAAC,cAAW,KAAK,QAAQ,KAAK,GAElC;AAAA,KACF;AAEJ;","names":["useEffect","useState","jsx","useState","useEffect"]}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"use strict";Object.defineProperty(exports, "__esModule", {value: true}); function _interopRequireWildcard(obj) { if (obj && obj.__esModule) { return obj; } else { var newObj = {}; if (obj != null) { for (var key in obj) { if (Object.prototype.hasOwnProperty.call(obj, key)) { newObj[key] = obj[key]; } } } newObj.default = obj; return newObj; } } function _nullishCoalesce(lhs, rhsFn) { if (lhs != null) { return lhs; } else { return rhsFn(); } } function _optionalChain(ops) { let lastAccessLHS = undefined; let value = ops[0]; let i = 1; while (i < ops.length) { const op = ops[i]; const fn = ops[i + 1]; i += 2; if ((op === 'optionalAccess' || op === 'optionalCall') && value == null) { return undefined; } if (op === 'access' || op === 'optionalAccess') { lastAccessLHS = value; value = fn(value); } else if (op === 'call' || op === 'optionalCall') { value = fn((...args) => value.call(lastAccessLHS, ...args)); lastAccessLHS = undefined; } } return value; }// src/views/RecordPreview/DocumentViewer.tsx
|
|
2
|
+
var _react = require('react');
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
var _lucidereact = require('lucide-react');
|
|
13
|
+
|
|
14
|
+
// src/views/RecordPreview/TextViewer.tsx
|
|
15
|
+
|
|
16
|
+
var _jsxruntime = require('react/jsx-runtime');
|
|
17
|
+
function TextViewer({ url }) {
|
|
18
|
+
const [state, setState] = _react.useState.call(void 0, {
|
|
19
|
+
status: "loading",
|
|
20
|
+
text: ""
|
|
21
|
+
});
|
|
22
|
+
_react.useEffect.call(void 0, () => {
|
|
23
|
+
let cancelled = false;
|
|
24
|
+
setState({ status: "loading", text: "" });
|
|
25
|
+
(async () => {
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch(url);
|
|
28
|
+
if (!res.ok) throw new Error(`fetch ${res.status}`);
|
|
29
|
+
const text = await res.text();
|
|
30
|
+
if (!cancelled) setState({ status: "loaded", text });
|
|
31
|
+
} catch (e) {
|
|
32
|
+
if (!cancelled) setState({ status: "error", text: "" });
|
|
33
|
+
}
|
|
34
|
+
})();
|
|
35
|
+
return () => {
|
|
36
|
+
cancelled = true;
|
|
37
|
+
};
|
|
38
|
+
}, [url]);
|
|
39
|
+
if (state.status === "loading") {
|
|
40
|
+
return /* @__PURE__ */ _jsxruntime.jsx.call(void 0, "div", { className: "mr-doc-viewer__status", children: "Loading document..." });
|
|
41
|
+
}
|
|
42
|
+
if (state.status === "error") {
|
|
43
|
+
return /* @__PURE__ */ _jsxruntime.jsx.call(void 0, "div", { className: "mr-doc-viewer__status", children: "Couldn't load this document." });
|
|
44
|
+
}
|
|
45
|
+
if (!state.text.trim()) {
|
|
46
|
+
return /* @__PURE__ */ _jsxruntime.jsx.call(void 0, "div", { className: "mr-doc-viewer__status", children: "This file appears to be empty." });
|
|
47
|
+
}
|
|
48
|
+
return /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
|
|
49
|
+
"iframe",
|
|
50
|
+
{
|
|
51
|
+
className: "mr-doc-viewer__html-frame",
|
|
52
|
+
srcDoc: state.text,
|
|
53
|
+
sandbox: "allow-same-origin",
|
|
54
|
+
title: "HTML preview"
|
|
55
|
+
}
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// src/views/RecordPreview/DocumentViewer.tsx
|
|
60
|
+
|
|
61
|
+
var PdfViewer = _react.lazy.call(void 0, () => Promise.resolve().then(() => _interopRequireWildcard(require("./PdfViewer-EAN6EQAB.js"))).then((m) => ({ default: m.PdfViewer })));
|
|
62
|
+
var PAGE_WIDTH = 595;
|
|
63
|
+
var ZOOM_STEP = 0.25;
|
|
64
|
+
var ZOOM_MIN = 0.5;
|
|
65
|
+
var ZOOM_MAX = 3;
|
|
66
|
+
function DocumentViewer({ files, title, isHtml }) {
|
|
67
|
+
const [zoom, setZoom] = _react.useState.call(void 0, 1);
|
|
68
|
+
const [pageInfo, setPageInfo] = _react.useState.call(void 0, { current: 1, total: 0 });
|
|
69
|
+
const [scrollToPage, setScrollToPage] = _react.useState.call(void 0, 1);
|
|
70
|
+
const [isFullscreen, setIsFullscreen] = _react.useState.call(void 0, false);
|
|
71
|
+
const rootRef = _react.useRef.call(void 0, null);
|
|
72
|
+
_react.useEffect.call(void 0, () => {
|
|
73
|
+
const onChange = () => setIsFullscreen(document.fullscreenElement === rootRef.current);
|
|
74
|
+
document.addEventListener("fullscreenchange", onChange);
|
|
75
|
+
return () => document.removeEventListener("fullscreenchange", onChange);
|
|
76
|
+
}, []);
|
|
77
|
+
const toggleFullscreen = () => {
|
|
78
|
+
if (document.fullscreenElement) void document.exitFullscreen();
|
|
79
|
+
else void _optionalChain([rootRef, 'access', _ => _.current, 'optionalAccess', _2 => _2.requestFullscreen, 'optionalCall', _3 => _3()]);
|
|
80
|
+
};
|
|
81
|
+
const primary = _nullishCoalesce(files[0], () => ( null));
|
|
82
|
+
const isPdf = _optionalChain([primary, 'optionalAccess', _4 => _4.kind]) === "pdf";
|
|
83
|
+
const images = files.filter((f) => f.kind === "image");
|
|
84
|
+
const isZoomable = isPdf || images.length > 0;
|
|
85
|
+
const zoomOut = () => setZoom((z) => Math.max(ZOOM_MIN, +(z - ZOOM_STEP).toFixed(2)));
|
|
86
|
+
const zoomIn = () => setZoom((z) => Math.min(ZOOM_MAX, +(z + ZOOM_STEP).toFixed(2)));
|
|
87
|
+
const prevPage = () => setScrollToPage(Math.max(1, pageInfo.current - 1));
|
|
88
|
+
const nextPage = () => setScrollToPage(Math.min(pageInfo.total, pageInfo.current + 1));
|
|
89
|
+
const withBlobUrl = _react.useCallback.call(void 0, async (fn) => {
|
|
90
|
+
if (!primary) return;
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetch(primary.url);
|
|
93
|
+
const blob = await res.blob();
|
|
94
|
+
const objUrl = URL.createObjectURL(blob);
|
|
95
|
+
fn(objUrl);
|
|
96
|
+
setTimeout(() => URL.revokeObjectURL(objUrl), 6e4);
|
|
97
|
+
} catch (e2) {
|
|
98
|
+
fn(primary.url);
|
|
99
|
+
}
|
|
100
|
+
}, [primary]);
|
|
101
|
+
const handleDownload = () => withBlobUrl((objUrl) => {
|
|
102
|
+
const a = document.createElement("a");
|
|
103
|
+
a.href = objUrl;
|
|
104
|
+
a.download = title || "document";
|
|
105
|
+
a.click();
|
|
106
|
+
});
|
|
107
|
+
return /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "div", { className: `mr-doc-viewer${isHtml ? " mr-doc-viewer--html" : ""}`, ref: rootRef, children: [
|
|
108
|
+
!isHtml && /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "div", { className: "mr-doc-viewer__toolbar", children: [
|
|
109
|
+
/* @__PURE__ */ _jsxruntime.jsx.call(void 0, "span", { className: "mr-doc-viewer__filename", children: title }),
|
|
110
|
+
isPdf && /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "div", { className: "mr-doc-viewer__pages", children: [
|
|
111
|
+
/* @__PURE__ */ _jsxruntime.jsx.call(void 0,
|
|
112
|
+
"button",
|
|
113
|
+
{
|
|
114
|
+
type: "button",
|
|
115
|
+
"aria-label": "Previous page",
|
|
116
|
+
onClick: prevPage,
|
|
117
|
+
disabled: pageInfo.current <= 1,
|
|
118
|
+
children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.ChevronLeft, { size: 18, "aria-hidden": true })
|
|
119
|
+
}
|
|
120
|
+
),
|
|
121
|
+
/* @__PURE__ */ _jsxruntime.jsx.call(void 0, "span", { className: "mr-doc-viewer__page-badge", children: pageInfo.current }),
|
|
122
|
+
/* @__PURE__ */ _jsxruntime.jsx.call(void 0, "span", { children: "/" }),
|
|
123
|
+
/* @__PURE__ */ _jsxruntime.jsx.call(void 0, "span", { children: pageInfo.total || "\u2014" }),
|
|
124
|
+
/* @__PURE__ */ _jsxruntime.jsx.call(void 0,
|
|
125
|
+
"button",
|
|
126
|
+
{
|
|
127
|
+
type: "button",
|
|
128
|
+
"aria-label": "Next page",
|
|
129
|
+
onClick: nextPage,
|
|
130
|
+
disabled: pageInfo.current >= pageInfo.total,
|
|
131
|
+
children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.ChevronRight, { size: 18, "aria-hidden": true })
|
|
132
|
+
}
|
|
133
|
+
)
|
|
134
|
+
] }),
|
|
135
|
+
isZoomable && /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "div", { className: "mr-doc-viewer__zoom", children: [
|
|
136
|
+
/* @__PURE__ */ _jsxruntime.jsx.call(void 0, "button", { type: "button", "aria-label": "Zoom out", onClick: zoomOut, disabled: zoom <= ZOOM_MIN, children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.Minus, { size: 20, "aria-hidden": true }) }),
|
|
137
|
+
/* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "span", { className: "mr-doc-viewer__zoom-badge", children: [
|
|
138
|
+
Math.round(zoom * 100),
|
|
139
|
+
"%"
|
|
140
|
+
] }),
|
|
141
|
+
/* @__PURE__ */ _jsxruntime.jsx.call(void 0, "button", { type: "button", "aria-label": "Zoom in", onClick: zoomIn, disabled: zoom >= ZOOM_MAX, children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.Plus, { size: 20, "aria-hidden": true }) })
|
|
142
|
+
] }),
|
|
143
|
+
/* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "div", { className: "mr-doc-viewer__actions", children: [
|
|
144
|
+
/* @__PURE__ */ _jsxruntime.jsx.call(void 0, "button", { type: "button", "aria-label": "Download", onClick: handleDownload, disabled: !primary, children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.Download, { size: 20, "aria-hidden": true }) }),
|
|
145
|
+
/* @__PURE__ */ _jsxruntime.jsx.call(void 0,
|
|
146
|
+
"button",
|
|
147
|
+
{
|
|
148
|
+
type: "button",
|
|
149
|
+
"aria-label": isFullscreen ? "Exit full screen" : "Full screen",
|
|
150
|
+
onClick: toggleFullscreen,
|
|
151
|
+
children: isFullscreen ? /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.Minimize, { size: 20, "aria-hidden": true }) : /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.Maximize, { size: 20, "aria-hidden": true })
|
|
152
|
+
}
|
|
153
|
+
)
|
|
154
|
+
] })
|
|
155
|
+
] }),
|
|
156
|
+
isHtml && /* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "div", { className: "mr-doc-viewer__html-bar", children: [
|
|
157
|
+
/* @__PURE__ */ _jsxruntime.jsxs.call(void 0, "div", { className: "mr-doc-viewer__html-bar-left", children: [
|
|
158
|
+
/* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.Globe, { size: 12, "aria-hidden": true }),
|
|
159
|
+
/* @__PURE__ */ _jsxruntime.jsx.call(void 0, "span", { children: "Previewing HTML file \u2013 you can copy to a note and edit this file. The original record that you are viewing now will not be affected." })
|
|
160
|
+
] }),
|
|
161
|
+
/* @__PURE__ */ _jsxruntime.jsx.call(void 0,
|
|
162
|
+
"button",
|
|
163
|
+
{
|
|
164
|
+
type: "button",
|
|
165
|
+
className: "mr-doc-viewer__html-download",
|
|
166
|
+
"aria-label": "Download",
|
|
167
|
+
onClick: handleDownload,
|
|
168
|
+
disabled: !primary,
|
|
169
|
+
children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _lucidereact.Download, { size: 16, "aria-hidden": true })
|
|
170
|
+
}
|
|
171
|
+
)
|
|
172
|
+
] }),
|
|
173
|
+
/* @__PURE__ */ _jsxruntime.jsx.call(void 0, "div", { className: "mr-doc-viewer__canvas", children: !primary ? /* @__PURE__ */ _jsxruntime.jsx.call(void 0, "div", { className: "mr-doc-viewer__status", children: "No preview available for this record." }) : isPdf ? /* @__PURE__ */ _jsxruntime.jsx.call(void 0, _react.Suspense, { fallback: /* @__PURE__ */ _jsxruntime.jsx.call(void 0, "div", { className: "mr-doc-viewer__status", children: "Loading viewer\u2026" }), children: /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
|
|
174
|
+
PdfViewer,
|
|
175
|
+
{
|
|
176
|
+
url: primary.url,
|
|
177
|
+
baseWidth: PAGE_WIDTH,
|
|
178
|
+
scale: zoom,
|
|
179
|
+
scrollToPage,
|
|
180
|
+
onPageInfo: setPageInfo
|
|
181
|
+
}
|
|
182
|
+
) }) : images.length > 0 ? images.map((img, i) => /* @__PURE__ */ _jsxruntime.jsx.call(void 0,
|
|
183
|
+
"img",
|
|
184
|
+
{
|
|
185
|
+
className: "mr-doc-viewer__image",
|
|
186
|
+
src: img.url,
|
|
187
|
+
alt: `${title} ${i + 1}`,
|
|
188
|
+
style: { transform: `scale(${zoom})`, transformOrigin: "top center" }
|
|
189
|
+
},
|
|
190
|
+
i
|
|
191
|
+
)) : /* @__PURE__ */ _jsxruntime.jsx.call(void 0, TextViewer, { url: primary.url }) })
|
|
192
|
+
] });
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
exports.DocumentViewer = DocumentViewer;
|
|
197
|
+
//# sourceMappingURL=DocumentViewer-Z3GOU5NU.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["/home/runner/work/medical-records-ui/medical-records-ui/dist/DocumentViewer-Z3GOU5NU.js","../src/views/RecordPreview/DocumentViewer.tsx","../src/views/RecordPreview/TextViewer.tsx"],"names":["jsx"],"mappings":"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","file":"/home/runner/work/medical-records-ui/medical-records-ui/dist/DocumentViewer-Z3GOU5NU.js","sourcesContent":[null,"import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react';\nimport {\n ChevronLeft,\n ChevronRight,\n Minus,\n Plus,\n Download,\n Maximize,\n Minimize,\n Globe,\n} from 'lucide-react';\nimport { TextViewer } from './TextViewer';\nimport type { PreviewFile } from '../../connection';\n\nconst PdfViewer = lazy(() => import('./PdfViewer').then((m) => ({ default: m.PdfViewer })));\n\nexport interface DocumentViewerProps {\n files: PreviewFile[];\n title: string;\n isHtml?: boolean;\n}\n\nconst PAGE_WIDTH = 595; \nconst ZOOM_STEP = 0.25;\nconst ZOOM_MIN = 0.5;\nconst ZOOM_MAX = 3;\n\nexport function DocumentViewer({ files, title, isHtml }: DocumentViewerProps) {\n const [zoom, setZoom] = useState(1);\n const [pageInfo, setPageInfo] = useState({ current: 1, total: 0 });\n const [scrollToPage, setScrollToPage] = useState(1);\n const [isFullscreen, setIsFullscreen] = useState(false);\n const rootRef = useRef<HTMLDivElement>(null);\n\n useEffect(() => {\n const onChange = () => setIsFullscreen(document.fullscreenElement === rootRef.current);\n document.addEventListener('fullscreenchange', onChange);\n return () => document.removeEventListener('fullscreenchange', onChange);\n }, []);\n\n const toggleFullscreen = () => {\n if (document.fullscreenElement) void document.exitFullscreen();\n else void rootRef.current?.requestFullscreen?.();\n };\n\n const primary = files[0] ?? null;\n const isPdf = primary?.kind === 'pdf';\n const images = files.filter((f) => f.kind === 'image');\n const isZoomable = isPdf || images.length > 0;\n\n const zoomOut = () => setZoom((z) => Math.max(ZOOM_MIN, +(z - ZOOM_STEP).toFixed(2)));\n const zoomIn = () => setZoom((z) => Math.min(ZOOM_MAX, +(z + ZOOM_STEP).toFixed(2)));\n\n const prevPage = () => setScrollToPage(Math.max(1, pageInfo.current - 1));\n const nextPage = () => setScrollToPage(Math.min(pageInfo.total, pageInfo.current + 1));\n\n // Download / print fetch the file into a blob URL rather than re-opening the\n // signed URL directly, so it works even when the document is already loaded.\n const withBlobUrl = useCallback(async (fn: (objUrl: string) => void) => {\n if (!primary) return;\n try {\n const res = await fetch(primary.url);\n const blob = await res.blob();\n const objUrl = URL.createObjectURL(blob);\n fn(objUrl);\n // Revoke after the consumer has had a chance to use it.\n setTimeout(() => URL.revokeObjectURL(objUrl), 60_000);\n } catch {\n // Fall back to opening the original URL.\n fn(primary.url);\n }\n }, [primary]);\n\n const handleDownload = () =>\n withBlobUrl((objUrl) => {\n const a = document.createElement('a');\n a.href = objUrl;\n a.download = title || 'document';\n a.click();\n });\n\n return (\n <div className={`mr-doc-viewer${isHtml ? ' mr-doc-viewer--html' : ''}`} ref={rootRef}>\n {!isHtml && <div className=\"mr-doc-viewer__toolbar\">\n <span className=\"mr-doc-viewer__filename\">{title}</span>\n {isPdf && (\n <div className=\"mr-doc-viewer__pages\">\n <button\n type=\"button\"\n aria-label=\"Previous page\"\n onClick={prevPage}\n disabled={pageInfo.current <= 1}\n >\n <ChevronLeft size={18} aria-hidden />\n </button>\n <span className=\"mr-doc-viewer__page-badge\">{pageInfo.current}</span>\n <span>/</span>\n <span>{pageInfo.total || '—'}</span>\n <button\n type=\"button\"\n aria-label=\"Next page\"\n onClick={nextPage}\n disabled={pageInfo.current >= pageInfo.total}\n >\n <ChevronRight size={18} aria-hidden />\n </button>\n </div>\n )}\n {isZoomable && (\n <div className=\"mr-doc-viewer__zoom\">\n <button type=\"button\" aria-label=\"Zoom out\" onClick={zoomOut} disabled={zoom <= ZOOM_MIN}>\n <Minus size={20} aria-hidden />\n </button>\n <span className=\"mr-doc-viewer__zoom-badge\">{Math.round(zoom * 100)}%</span>\n <button type=\"button\" aria-label=\"Zoom in\" onClick={zoomIn} disabled={zoom >= ZOOM_MAX}>\n <Plus size={20} aria-hidden />\n </button>\n </div>\n )}\n <div className=\"mr-doc-viewer__actions\">\n <button type=\"button\" aria-label=\"Download\" onClick={handleDownload} disabled={!primary}>\n <Download size={20} aria-hidden />\n </button>\n <button\n type=\"button\"\n aria-label={isFullscreen ? 'Exit full screen' : 'Full screen'}\n onClick={toggleFullscreen}\n >\n {isFullscreen ? <Minimize size={20} aria-hidden /> : <Maximize size={20} aria-hidden />}\n </button>\n </div>\n </div>}\n\n {isHtml && (\n <div className=\"mr-doc-viewer__html-bar\">\n <div className=\"mr-doc-viewer__html-bar-left\">\n <Globe size={12} aria-hidden />\n <span>Previewing HTML file – you can copy to a note and edit this file. The original record that you are viewing now will not be affected.</span>\n </div>\n <button\n type=\"button\"\n className=\"mr-doc-viewer__html-download\"\n aria-label=\"Download\"\n onClick={handleDownload}\n disabled={!primary}\n >\n <Download size={16} aria-hidden />\n </button>\n </div>\n )}\n\n <div className=\"mr-doc-viewer__canvas\">\n {!primary ? (\n <div className=\"mr-doc-viewer__status\">No preview available for this record.</div>\n ) : isPdf ? (\n <Suspense fallback={<div className=\"mr-doc-viewer__status\">Loading viewer…</div>}>\n <PdfViewer\n url={primary.url}\n baseWidth={PAGE_WIDTH}\n scale={zoom}\n scrollToPage={scrollToPage}\n onPageInfo={setPageInfo}\n />\n </Suspense>\n ) : images.length > 0 ? (\n images.map((img, i) => (\n <img\n key={i}\n className=\"mr-doc-viewer__image\"\n src={img.url}\n alt={`${title} ${i + 1}`}\n style={{ transform: `scale(${zoom})`, transformOrigin: 'top center' }}\n />\n ))\n ) : (\n <TextViewer url={primary.url} />\n )}\n </div>\n </div>\n );\n}\n","import { useEffect, useState } from 'react';\n\nexport interface TextViewerProps {\n url: string;\n}\n\nexport function TextViewer({ url }: TextViewerProps) {\n const [state, setState] = useState<{ status: 'loading' | 'loaded' | 'error'; text: string }>({\n status: 'loading',\n text: '',\n });\n\n useEffect(() => {\n let cancelled = false;\n setState({ status: 'loading', text: '' });\n (async () => {\n try {\n const res = await fetch(url);\n if (!res.ok) throw new Error(`fetch ${res.status}`);\n const text = await res.text();\n if (!cancelled) setState({ status: 'loaded', text });\n } catch {\n if (!cancelled) setState({ status: 'error', text: '' });\n }\n })();\n return () => {\n cancelled = true;\n };\n }, [url]);\n\n if (state.status === 'loading') {\n return <div className=\"mr-doc-viewer__status\">Loading document...</div>;\n }\n if (state.status === 'error') {\n return <div className=\"mr-doc-viewer__status\">Couldn't load this document.</div>;\n }\n if (!state.text.trim()) {\n return <div className=\"mr-doc-viewer__status\">This file appears to be empty.</div>;\n }\n return (\n <iframe\n className=\"mr-doc-viewer__html-frame\"\n srcDoc={state.text}\n sandbox=\"allow-same-origin\"\n title=\"HTML preview\"\n />\n );\n}\n"]}
|