@brftech/filex-core 0.1.56
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 +108 -0
- package/dist/ArchiveViewer-CJdaw5x3.js +99 -0
- package/dist/ArchiveViewer-CJdaw5x3.js.map +1 -0
- package/dist/CsvViewer-QYx_tePQ.js +132 -0
- package/dist/CsvViewer-QYx_tePQ.js.map +1 -0
- package/dist/DrawioViewer-GWt0TyB7.js +141 -0
- package/dist/DrawioViewer-GWt0TyB7.js.map +1 -0
- package/dist/EpubViewer-Je0ywklQ.js +146 -0
- package/dist/EpubViewer-Je0ywklQ.js.map +1 -0
- package/dist/IpynbViewer-TsB4gqP-.js +175 -0
- package/dist/IpynbViewer-TsB4gqP-.js.map +1 -0
- package/dist/MermaidViewer-CXfeDS-7.js +128 -0
- package/dist/MermaidViewer-CXfeDS-7.js.map +1 -0
- package/dist/PsdViewer-VtPpxKVe.js +113 -0
- package/dist/PsdViewer-VtPpxKVe.js.map +1 -0
- package/dist/TiffViewer-OINn9bbb.js +133 -0
- package/dist/TiffViewer-OINn9bbb.js.map +1 -0
- package/dist/UTIF-Cjsrlm0l.js +3104 -0
- package/dist/UTIF-Cjsrlm0l.js.map +1 -0
- package/dist/Viewer3D-xvvyiWHd.js +61 -0
- package/dist/Viewer3D-xvvyiWHd.js.map +1 -0
- package/dist/_commonjsHelpers-DaMA6jEr.js +9 -0
- package/dist/_commonjsHelpers-DaMA6jEr.js.map +1 -0
- package/dist/filex-core.js +26 -0
- package/dist/filex-core.js.map +1 -0
- package/dist/filex-core.umd.cjs +285 -0
- package/dist/filex-core.umd.cjs.map +1 -0
- package/dist/index-CmSYzt6L.js +5404 -0
- package/dist/index-CmSYzt6L.js.map +1 -0
- package/dist/index-UFULWo35.js +10736 -0
- package/dist/index-UFULWo35.js.map +1 -0
- package/dist/index.d.ts +1089 -0
- package/dist/katex-yuB6V-q6.js +11616 -0
- package/dist/katex-yuB6V-q6.js.map +1 -0
- package/dist/papaparse.min-VB1HBwYX.js +441 -0
- package/dist/papaparse.min-VB1HBwYX.js.map +1 -0
- package/dist/style.css +1 -0
- package/dist/useViewerFetch-czqbd2Lj.js +25 -0
- package/dist/useViewerFetch-czqbd2Lj.js.map +1 -0
- package/package.json +113 -0
- package/src/FileExplorer.vue +1872 -0
- package/src/components/Breadcrumb.vue +282 -0
- package/src/components/ContextMenu.vue +152 -0
- package/src/components/GridView.vue +166 -0
- package/src/components/ListView.vue +212 -0
- package/src/components/PendingOpsTray.vue +91 -0
- package/src/components/RecentlyOpened.vue +155 -0
- package/src/components/StarButton.vue +101 -0
- package/src/components/TagPicker.vue +184 -0
- package/src/components/Toolbar.vue +220 -0
- package/src/components/UploadProgress.vue +78 -0
- package/src/composables/useFileApi.ts +692 -0
- package/src/composables/useKeyboardShortcuts.ts +144 -0
- package/src/composables/useLocale.ts +60 -0
- package/src/composables/useMonacoLoader.ts +98 -0
- package/src/composables/usePendingOps.ts +200 -0
- package/src/composables/useSelection.ts +81 -0
- package/src/composables/useUploadChunked.ts +250 -0
- package/src/composables/useViewerFetch.ts +58 -0
- package/src/index.ts +80 -0
- package/src/locales/en.ts +122 -0
- package/src/locales/index.ts +12 -0
- package/src/locales/tr.ts +122 -0
- package/src/modals/ConvertModal.vue +254 -0
- package/src/modals/DeleteConfirmModal.vue +32 -0
- package/src/modals/Modal.vue +104 -0
- package/src/modals/NewFolderModal.vue +64 -0
- package/src/modals/PermissionsModal.vue +815 -0
- package/src/modals/PreviewModal.vue +1070 -0
- package/src/modals/RenameModal.vue +58 -0
- package/src/modals/ShareModal.vue +135 -0
- package/src/styles/base.css +1224 -0
- package/src/styles/variables.css +90 -0
- package/src/types/ExplorerConfig.ts +282 -0
- package/src/types/FileNode.ts +136 -0
- package/src/types/index.ts +27 -0
- package/src/types/peers.d.ts +45 -0
- package/src/viewers/ArchiveViewer.vue +186 -0
- package/src/viewers/CsvViewer.vue +319 -0
- package/src/viewers/DrawioViewer.vue +243 -0
- package/src/viewers/EpubViewer.vue +307 -0
- package/src/viewers/IpynbViewer.vue +365 -0
- package/src/viewers/MermaidViewer.vue +259 -0
- package/src/viewers/PdfViewer.vue +562 -0
- package/src/viewers/PsdViewer.vue +264 -0
- package/src/viewers/TiffViewer.vue +273 -0
- package/src/viewers/Viewer3D.vue +120 -0
|
@@ -0,0 +1,562 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* PdfViewer — rich PDF preview/edit via `pdfjs-dist`.
|
|
4
|
+
*
|
|
5
|
+
* Lazy-imports `pdfjs-dist/legacy/build/pdf` (~600 KB; the worker is
|
|
6
|
+
* loaded as a separate script). Renders pages on-demand to canvases:
|
|
7
|
+
* an IntersectionObserver attached to the page placeholders kicks off
|
|
8
|
+
* `page.render()` only for visible pages — opening a 500-page PDF
|
|
9
|
+
* doesn't paint 500 canvases up front.
|
|
10
|
+
*
|
|
11
|
+
* Features:
|
|
12
|
+
* - prev/next page jump (also typing the page number)
|
|
13
|
+
* - zoom in/out + fit-width
|
|
14
|
+
* - search box (case-insensitive, full document text scan)
|
|
15
|
+
* - download button
|
|
16
|
+
* - annotation toggle: enables form field editing via the AnnotationLayer
|
|
17
|
+
* - "Save annotations" — `pdf.saveDocument()` → POST to `pdfSaveUrl`
|
|
18
|
+
*
|
|
19
|
+
* Falls back to the host's native `<object>` mounting when pdfjs isn't
|
|
20
|
+
* installed (the parent PreviewModal handles that branch).
|
|
21
|
+
*/
|
|
22
|
+
import { computed, onBeforeUnmount, onMounted, ref, watch, nextTick } from 'vue';
|
|
23
|
+
import { fetchViewerArrayBuffer } from '../composables/useViewerFetch';
|
|
24
|
+
|
|
25
|
+
const props = defineProps<{
|
|
26
|
+
url: string;
|
|
27
|
+
filePath: string;
|
|
28
|
+
mime?: string;
|
|
29
|
+
ext: string;
|
|
30
|
+
pdfWorkerUrl?: string;
|
|
31
|
+
pdfSaveUrl?: string;
|
|
32
|
+
t?: (key: string) => string;
|
|
33
|
+
authHeaders?: () => Record<string, string>;
|
|
34
|
+
authCredentials?: RequestCredentials;
|
|
35
|
+
}>();
|
|
36
|
+
|
|
37
|
+
const emit = defineEmits<{
|
|
38
|
+
(e: 'fallback'): void;
|
|
39
|
+
}>();
|
|
40
|
+
|
|
41
|
+
const containerEl = ref<HTMLDivElement | null>(null);
|
|
42
|
+
const error = ref<string | null>(null);
|
|
43
|
+
const loading = ref(true);
|
|
44
|
+
const totalPages = ref(0);
|
|
45
|
+
const currentPage = ref(1);
|
|
46
|
+
const scale = ref(1);
|
|
47
|
+
const annotationsOn = ref(false);
|
|
48
|
+
const saveStatus = ref<'idle' | 'saving' | 'saved' | 'error'>('idle');
|
|
49
|
+
const searchQuery = ref('');
|
|
50
|
+
const searchResults = ref(0);
|
|
51
|
+
|
|
52
|
+
let pdfjs: any = null;
|
|
53
|
+
let pdfDoc: any = null;
|
|
54
|
+
const pageRenderState: Map<number, 'pending' | 'rendered'> = new Map();
|
|
55
|
+
const pageEls: Map<number, HTMLDivElement> = new Map();
|
|
56
|
+
let observer: IntersectionObserver | null = null;
|
|
57
|
+
let renderToken = 0;
|
|
58
|
+
|
|
59
|
+
async function ensurePdfjs(): Promise<any | null> {
|
|
60
|
+
if (pdfjs) return pdfjs;
|
|
61
|
+
try {
|
|
62
|
+
// Use the legacy build for broader browser compatibility and
|
|
63
|
+
// simpler worker bootstrap (no module worker requirement).
|
|
64
|
+
const mod = await import(/* @vite-ignore */ 'pdfjs-dist/legacy/build/pdf');
|
|
65
|
+
pdfjs = mod.default ?? mod;
|
|
66
|
+
if (pdfjs.GlobalWorkerOptions) {
|
|
67
|
+
const version = pdfjs.version || '4.0.379';
|
|
68
|
+
pdfjs.GlobalWorkerOptions.workerSrc =
|
|
69
|
+
props.pdfWorkerUrl ||
|
|
70
|
+
`https://cdn.jsdelivr.net/npm/pdfjs-dist@${version}/legacy/build/pdf.worker.min.js`;
|
|
71
|
+
}
|
|
72
|
+
return pdfjs;
|
|
73
|
+
} catch {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// useNativeViewer flips the renderer to <embed type="application/pdf">
|
|
79
|
+
// when pdfjs.getDocument throws (worker fetch blocked, CDN
|
|
80
|
+
// unreachable, or pdfjs not installed). All evergreen browsers ship a
|
|
81
|
+
// built-in PDF viewer at the embed level, so this is a graceful
|
|
82
|
+
// fallback rather than the "open in new tab" lifeboat the toolbar
|
|
83
|
+
// already exposes.
|
|
84
|
+
const useNativeViewer = ref(false);
|
|
85
|
+
|
|
86
|
+
async function load(): Promise<void> {
|
|
87
|
+
loading.value = true;
|
|
88
|
+
error.value = null;
|
|
89
|
+
searchResults.value = 0;
|
|
90
|
+
pageRenderState.clear();
|
|
91
|
+
pageEls.clear();
|
|
92
|
+
if (containerEl.value) containerEl.value.innerHTML = '';
|
|
93
|
+
if (observer) {
|
|
94
|
+
observer.disconnect();
|
|
95
|
+
observer = null;
|
|
96
|
+
}
|
|
97
|
+
if (pdfDoc) {
|
|
98
|
+
try { pdfDoc.destroy?.(); } catch { /* ignore */ }
|
|
99
|
+
pdfDoc = null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const myToken = ++renderToken;
|
|
103
|
+
|
|
104
|
+
const lib = await ensurePdfjs();
|
|
105
|
+
if (myToken !== renderToken) return;
|
|
106
|
+
if (!lib) {
|
|
107
|
+
// pdfjs-dist absent → use the browser's built-in PDF viewer via
|
|
108
|
+
// <embed>. error.value stays null so the template renders the
|
|
109
|
+
// native preview without an angry banner.
|
|
110
|
+
useNativeViewer.value = true;
|
|
111
|
+
loading.value = false;
|
|
112
|
+
emit('fallback');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
try {
|
|
117
|
+
const buf = await fetchViewerArrayBuffer({
|
|
118
|
+
url: props.url,
|
|
119
|
+
headers: props.authHeaders?.() ?? {},
|
|
120
|
+
credentials: props.authCredentials,
|
|
121
|
+
});
|
|
122
|
+
if (myToken !== renderToken) return;
|
|
123
|
+
|
|
124
|
+
pdfDoc = await lib.getDocument({
|
|
125
|
+
data: buf,
|
|
126
|
+
enableXfa: true,
|
|
127
|
+
}).promise;
|
|
128
|
+
|
|
129
|
+
if (myToken !== renderToken) return;
|
|
130
|
+
totalPages.value = pdfDoc.numPages;
|
|
131
|
+
|
|
132
|
+
await nextTick();
|
|
133
|
+
buildPagePlaceholders();
|
|
134
|
+
setupObserver();
|
|
135
|
+
} catch (err) {
|
|
136
|
+
// Worker fetch blocked (CDN/CSP), unsupported runtime, etc. —
|
|
137
|
+
// hand off to the browser's PDF viewer instead of a dead error
|
|
138
|
+
// panel. Captures the original message in case operators want
|
|
139
|
+
// it via DevTools.
|
|
140
|
+
if (typeof console !== 'undefined') {
|
|
141
|
+
console.warn('[filex/PdfViewer] pdfjs failed, using native embed:', err);
|
|
142
|
+
}
|
|
143
|
+
useNativeViewer.value = true;
|
|
144
|
+
} finally {
|
|
145
|
+
loading.value = false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function buildPagePlaceholders(): void {
|
|
150
|
+
if (!containerEl.value) return;
|
|
151
|
+
for (let i = 1; i <= totalPages.value; i++) {
|
|
152
|
+
const wrap = document.createElement('div');
|
|
153
|
+
wrap.className = 'filex-viewer-pdf__page';
|
|
154
|
+
wrap.dataset.page = String(i);
|
|
155
|
+
wrap.style.position = 'relative';
|
|
156
|
+
wrap.style.margin = '12px auto';
|
|
157
|
+
wrap.style.background = '#fff';
|
|
158
|
+
wrap.style.boxShadow = '0 4px 16px rgba(0, 0, 0, 0.3)';
|
|
159
|
+
wrap.style.minHeight = '600px';
|
|
160
|
+
wrap.style.width = '816px';
|
|
161
|
+
containerEl.value.appendChild(wrap);
|
|
162
|
+
pageEls.set(i, wrap);
|
|
163
|
+
pageRenderState.set(i, 'pending');
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function setupObserver(): void {
|
|
168
|
+
observer = new IntersectionObserver(
|
|
169
|
+
(entries) => {
|
|
170
|
+
for (const entry of entries) {
|
|
171
|
+
const el = entry.target as HTMLDivElement;
|
|
172
|
+
const num = Number(el.dataset.page);
|
|
173
|
+
if (entry.isIntersecting) {
|
|
174
|
+
if (pageRenderState.get(num) === 'pending') {
|
|
175
|
+
pageRenderState.set(num, 'rendered');
|
|
176
|
+
renderPage(num).catch(() => {
|
|
177
|
+
pageRenderState.set(num, 'pending');
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
if (entry.intersectionRatio > 0.5) {
|
|
181
|
+
currentPage.value = num;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
},
|
|
186
|
+
{ root: containerEl.value, threshold: [0, 0.5, 1] },
|
|
187
|
+
);
|
|
188
|
+
for (const el of pageEls.values()) {
|
|
189
|
+
observer.observe(el);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function renderPage(pageNum: number): Promise<void> {
|
|
194
|
+
if (!pdfDoc) return;
|
|
195
|
+
const wrap = pageEls.get(pageNum);
|
|
196
|
+
if (!wrap) return;
|
|
197
|
+
const page = await pdfDoc.getPage(pageNum);
|
|
198
|
+
const viewport = page.getViewport({ scale: scale.value * 1.5 });
|
|
199
|
+
wrap.style.width = `${viewport.width / 1.5}px`;
|
|
200
|
+
wrap.style.minHeight = `${viewport.height / 1.5}px`;
|
|
201
|
+
|
|
202
|
+
const canvas = document.createElement('canvas');
|
|
203
|
+
canvas.width = viewport.width;
|
|
204
|
+
canvas.height = viewport.height;
|
|
205
|
+
canvas.style.width = '100%';
|
|
206
|
+
canvas.style.height = 'auto';
|
|
207
|
+
canvas.style.display = 'block';
|
|
208
|
+
wrap.innerHTML = '';
|
|
209
|
+
wrap.appendChild(canvas);
|
|
210
|
+
|
|
211
|
+
const ctx = canvas.getContext('2d');
|
|
212
|
+
if (!ctx) return;
|
|
213
|
+
await page.render({ canvasContext: ctx, viewport }).promise;
|
|
214
|
+
|
|
215
|
+
if (annotationsOn.value && pdfjs?.AnnotationLayer) {
|
|
216
|
+
const annoDiv = document.createElement('div');
|
|
217
|
+
annoDiv.className = 'filex-viewer-pdf__anno';
|
|
218
|
+
annoDiv.style.position = 'absolute';
|
|
219
|
+
annoDiv.style.inset = '0';
|
|
220
|
+
annoDiv.style.transformOrigin = '0 0';
|
|
221
|
+
wrap.appendChild(annoDiv);
|
|
222
|
+
try {
|
|
223
|
+
const annotations = await page.getAnnotations({ intent: 'display' });
|
|
224
|
+
pdfjs.AnnotationLayer.render({
|
|
225
|
+
viewport: viewport.clone({ dontFlip: true }),
|
|
226
|
+
div: annoDiv,
|
|
227
|
+
annotations,
|
|
228
|
+
page,
|
|
229
|
+
renderForms: true,
|
|
230
|
+
});
|
|
231
|
+
} catch {
|
|
232
|
+
/* ignore — best-effort annotation render */
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function rerenderAll(): Promise<void> {
|
|
238
|
+
for (const [num, state] of pageRenderState.entries()) {
|
|
239
|
+
if (state === 'rendered') {
|
|
240
|
+
pageRenderState.set(num, 'pending');
|
|
241
|
+
const el = pageEls.get(num);
|
|
242
|
+
if (el && observer) {
|
|
243
|
+
const rect = el.getBoundingClientRect();
|
|
244
|
+
const root = containerEl.value?.getBoundingClientRect();
|
|
245
|
+
if (root && rect.bottom > root.top && rect.top < root.bottom) {
|
|
246
|
+
pageRenderState.set(num, 'rendered');
|
|
247
|
+
renderPage(num).catch(() => {
|
|
248
|
+
pageRenderState.set(num, 'pending');
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function gotoPage(n: number): void {
|
|
257
|
+
if (n < 1 || n > totalPages.value) return;
|
|
258
|
+
const el = pageEls.get(n);
|
|
259
|
+
el?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
260
|
+
currentPage.value = n;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function next(): void {
|
|
264
|
+
gotoPage(currentPage.value + 1);
|
|
265
|
+
}
|
|
266
|
+
function prev(): void {
|
|
267
|
+
gotoPage(currentPage.value - 1);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function zoomIn(): void {
|
|
271
|
+
scale.value = Math.min(4, scale.value * 1.25);
|
|
272
|
+
rerenderAll();
|
|
273
|
+
}
|
|
274
|
+
function zoomOut(): void {
|
|
275
|
+
scale.value = Math.max(0.25, scale.value / 1.25);
|
|
276
|
+
rerenderAll();
|
|
277
|
+
}
|
|
278
|
+
function fitWidth(): void {
|
|
279
|
+
if (!containerEl.value || !pdfDoc) return;
|
|
280
|
+
pdfDoc
|
|
281
|
+
.getPage(1)
|
|
282
|
+
.then((p: any) => {
|
|
283
|
+
const v = p.getViewport({ scale: 1 });
|
|
284
|
+
const containerWidth = (containerEl.value?.clientWidth ?? 800) - 48;
|
|
285
|
+
scale.value = Math.max(0.25, Math.min(4, containerWidth / v.width));
|
|
286
|
+
rerenderAll();
|
|
287
|
+
})
|
|
288
|
+
.catch(() => {});
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function toggleAnnotations(): void {
|
|
292
|
+
annotationsOn.value = !annotationsOn.value;
|
|
293
|
+
rerenderAll();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
async function saveAnnotations(): Promise<void> {
|
|
297
|
+
if (!props.pdfSaveUrl || !pdfDoc) return;
|
|
298
|
+
saveStatus.value = 'saving';
|
|
299
|
+
try {
|
|
300
|
+
const data: Uint8Array = await pdfDoc.saveDocument();
|
|
301
|
+
const base64 = btoa(String.fromCharCode(...data));
|
|
302
|
+
const headers: Record<string, string> = {
|
|
303
|
+
'Content-Type': 'application/json',
|
|
304
|
+
...(props.authHeaders?.() ?? {}),
|
|
305
|
+
};
|
|
306
|
+
const res = await fetch(props.pdfSaveUrl, {
|
|
307
|
+
method: 'POST',
|
|
308
|
+
headers,
|
|
309
|
+
credentials: props.authCredentials || 'same-origin',
|
|
310
|
+
body: JSON.stringify({ path: props.filePath, base64 }),
|
|
311
|
+
});
|
|
312
|
+
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
|
313
|
+
saveStatus.value = 'saved';
|
|
314
|
+
setTimeout(() => {
|
|
315
|
+
if (saveStatus.value === 'saved') saveStatus.value = 'idle';
|
|
316
|
+
}, 2500);
|
|
317
|
+
} catch (err) {
|
|
318
|
+
saveStatus.value = 'error';
|
|
319
|
+
error.value = err instanceof Error ? err.message : 'save failed';
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
async function search(): Promise<void> {
|
|
324
|
+
if (!pdfDoc) return;
|
|
325
|
+
const q = searchQuery.value.trim().toLowerCase();
|
|
326
|
+
if (!q) {
|
|
327
|
+
searchResults.value = 0;
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
let count = 0;
|
|
331
|
+
for (let i = 1; i <= totalPages.value; i++) {
|
|
332
|
+
const page = await pdfDoc.getPage(i);
|
|
333
|
+
const content = await page.getTextContent();
|
|
334
|
+
const text = content.items.map((it: any) => it.str).join(' ').toLowerCase();
|
|
335
|
+
let from = 0;
|
|
336
|
+
while ((from = text.indexOf(q, from)) !== -1) {
|
|
337
|
+
count++;
|
|
338
|
+
from += q.length;
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
searchResults.value = count;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const pageLabel = computed(() => {
|
|
345
|
+
const m = (props.t && props.t('viewer.page_n_of_m')) || 'Page {n} of {m}';
|
|
346
|
+
return m
|
|
347
|
+
.replace('{n}', String(currentPage.value))
|
|
348
|
+
.replace('{m}', String(totalPages.value));
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
onMounted(load);
|
|
352
|
+
onBeforeUnmount(() => {
|
|
353
|
+
renderToken++;
|
|
354
|
+
if (observer) {
|
|
355
|
+
observer.disconnect();
|
|
356
|
+
observer = null;
|
|
357
|
+
}
|
|
358
|
+
if (pdfDoc) {
|
|
359
|
+
try { pdfDoc.destroy?.(); } catch { /* ignore */ }
|
|
360
|
+
pdfDoc = null;
|
|
361
|
+
}
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
watch(() => props.url, load);
|
|
365
|
+
|
|
366
|
+
function tt(key: string, fallback: string): string {
|
|
367
|
+
return props.t ? props.t(key) : fallback;
|
|
368
|
+
}
|
|
369
|
+
</script>
|
|
370
|
+
|
|
371
|
+
<template>
|
|
372
|
+
<div class="filex-viewer-pdf">
|
|
373
|
+
<div class="filex-viewer-pdf__bar">
|
|
374
|
+
<button type="button" class="filex-viewer-btn" :disabled="currentPage <= 1" @click="prev">‹</button>
|
|
375
|
+
<input
|
|
376
|
+
type="number"
|
|
377
|
+
class="filex-viewer-pdf__page-input"
|
|
378
|
+
:min="1"
|
|
379
|
+
:max="totalPages || 1"
|
|
380
|
+
:value="currentPage"
|
|
381
|
+
@change="(ev) => gotoPage(Number((ev.target as HTMLInputElement).value))"
|
|
382
|
+
/>
|
|
383
|
+
<span class="filex-viewer-pdf__total">/ {{ totalPages }}</span>
|
|
384
|
+
<button type="button" class="filex-viewer-btn" :disabled="currentPage >= totalPages" @click="next">›</button>
|
|
385
|
+
<span class="filex-viewer-spacer" />
|
|
386
|
+
<button type="button" class="filex-viewer-btn" @click="zoomOut" :title="tt('viewer.zoom_out', 'Zoom out')">−</button>
|
|
387
|
+
<span class="filex-viewer-pdf__zoom">{{ Math.round(scale * 100) }}%</span>
|
|
388
|
+
<button type="button" class="filex-viewer-btn" @click="zoomIn" :title="tt('viewer.zoom_in', 'Zoom in')">+</button>
|
|
389
|
+
<button type="button" class="filex-viewer-btn" @click="fitWidth" :title="tt('viewer.fit_width', 'Fit width')">↔</button>
|
|
390
|
+
<input
|
|
391
|
+
v-model="searchQuery"
|
|
392
|
+
type="search"
|
|
393
|
+
class="filex-viewer-pdf__search"
|
|
394
|
+
:placeholder="tt('viewer.search', 'Search')"
|
|
395
|
+
@keydown.enter="search"
|
|
396
|
+
/>
|
|
397
|
+
<span v-if="searchResults > 0" class="filex-viewer-pdf__hits">{{ searchResults }}</span>
|
|
398
|
+
<button
|
|
399
|
+
type="button"
|
|
400
|
+
class="filex-viewer-btn"
|
|
401
|
+
:class="{ 'is-active': annotationsOn }"
|
|
402
|
+
@click="toggleAnnotations"
|
|
403
|
+
>
|
|
404
|
+
{{ annotationsOn ? tt('viewer.annotations_on', 'Annot: on') : tt('viewer.annotations_off', 'Annot: off') }}
|
|
405
|
+
</button>
|
|
406
|
+
<button
|
|
407
|
+
v-if="pdfSaveUrl"
|
|
408
|
+
type="button"
|
|
409
|
+
class="filex-viewer-btn"
|
|
410
|
+
:disabled="saveStatus === 'saving'"
|
|
411
|
+
@click="saveAnnotations"
|
|
412
|
+
>
|
|
413
|
+
{{
|
|
414
|
+
saveStatus === 'saving'
|
|
415
|
+
? '⏳'
|
|
416
|
+
: saveStatus === 'saved'
|
|
417
|
+
? '✓'
|
|
418
|
+
: tt('viewer.save_annotations', 'Save')
|
|
419
|
+
}}
|
|
420
|
+
</button>
|
|
421
|
+
</div>
|
|
422
|
+
<div class="filex-viewer-pdf__pane">
|
|
423
|
+
<!-- Browser-native PDF viewer fallback. Triggered when pdfjs
|
|
424
|
+
is missing or its worker can't load (CDN blocked / CSP). -->
|
|
425
|
+
<embed
|
|
426
|
+
v-if="useNativeViewer"
|
|
427
|
+
:src="props.url"
|
|
428
|
+
type="application/pdf"
|
|
429
|
+
class="filex-viewer-pdf__embed"
|
|
430
|
+
/>
|
|
431
|
+
<div v-else-if="error" class="filex-viewer-fallback">
|
|
432
|
+
<span class="filex-viewer-fallback__icon">📕</span>
|
|
433
|
+
<p>{{ error }}</p>
|
|
434
|
+
</div>
|
|
435
|
+
<div v-else-if="loading" class="filex-viewer-fallback">
|
|
436
|
+
<span class="filex-viewer-fallback__icon">⏳</span>
|
|
437
|
+
<p>{{ tt('viewer.loading', 'Loading…') }}</p>
|
|
438
|
+
</div>
|
|
439
|
+
<div ref="containerEl" v-show="!loading && !error" class="filex-viewer-pdf__pages" />
|
|
440
|
+
</div>
|
|
441
|
+
<div v-if="!loading && !error && totalPages > 0" class="filex-viewer-pdf__footer">
|
|
442
|
+
{{ pageLabel }}
|
|
443
|
+
</div>
|
|
444
|
+
</div>
|
|
445
|
+
</template>
|
|
446
|
+
|
|
447
|
+
<style scoped>
|
|
448
|
+
.filex-viewer-pdf {
|
|
449
|
+
display: flex;
|
|
450
|
+
flex-direction: column;
|
|
451
|
+
width: 100%;
|
|
452
|
+
height: 100%;
|
|
453
|
+
min-height: 70vh;
|
|
454
|
+
background: var(--fe-bg, #fff);
|
|
455
|
+
}
|
|
456
|
+
.filex-viewer-pdf__embed {
|
|
457
|
+
width: 100%;
|
|
458
|
+
height: 100%;
|
|
459
|
+
min-height: 70vh;
|
|
460
|
+
border: 0;
|
|
461
|
+
}
|
|
462
|
+
.filex-viewer-pdf__bar {
|
|
463
|
+
display: flex;
|
|
464
|
+
align-items: center;
|
|
465
|
+
gap: 6px;
|
|
466
|
+
padding: 8px 12px;
|
|
467
|
+
background: var(--fe-bg-elev, #f7f8fa);
|
|
468
|
+
border-bottom: 1px solid var(--fe-border, #e2e6ed);
|
|
469
|
+
font-size: 13px;
|
|
470
|
+
flex-wrap: wrap;
|
|
471
|
+
}
|
|
472
|
+
.filex-viewer-pdf__page-input {
|
|
473
|
+
width: 60px;
|
|
474
|
+
padding: 4px 6px;
|
|
475
|
+
border: 1px solid var(--fe-border, #e2e6ed);
|
|
476
|
+
border-radius: 4px;
|
|
477
|
+
background: var(--fe-bg, #fff);
|
|
478
|
+
color: inherit;
|
|
479
|
+
text-align: center;
|
|
480
|
+
font: inherit;
|
|
481
|
+
font-size: 12px;
|
|
482
|
+
}
|
|
483
|
+
.filex-viewer-pdf__total {
|
|
484
|
+
font-size: 12px;
|
|
485
|
+
color: var(--fe-text-muted, #5a6475);
|
|
486
|
+
}
|
|
487
|
+
.filex-viewer-pdf__zoom {
|
|
488
|
+
min-width: 50px;
|
|
489
|
+
text-align: center;
|
|
490
|
+
font-variant-numeric: tabular-nums;
|
|
491
|
+
font-size: 12px;
|
|
492
|
+
}
|
|
493
|
+
.filex-viewer-pdf__search {
|
|
494
|
+
width: 160px;
|
|
495
|
+
padding: 4px 8px;
|
|
496
|
+
border: 1px solid var(--fe-border, #e2e6ed);
|
|
497
|
+
border-radius: 4px;
|
|
498
|
+
background: var(--fe-bg, #fff);
|
|
499
|
+
color: inherit;
|
|
500
|
+
font: inherit;
|
|
501
|
+
font-size: 12px;
|
|
502
|
+
}
|
|
503
|
+
.filex-viewer-pdf__hits {
|
|
504
|
+
font-size: 11px;
|
|
505
|
+
background: var(--fe-bg-selected, #dfe8ff);
|
|
506
|
+
color: var(--fe-primary, #3b82f6);
|
|
507
|
+
padding: 2px 6px;
|
|
508
|
+
border-radius: 8px;
|
|
509
|
+
}
|
|
510
|
+
.filex-viewer-spacer { flex: 1; }
|
|
511
|
+
.filex-viewer-pdf__pane {
|
|
512
|
+
flex: 1;
|
|
513
|
+
overflow: auto;
|
|
514
|
+
padding: 16px;
|
|
515
|
+
background: #2a2d33;
|
|
516
|
+
}
|
|
517
|
+
.filex-viewer-pdf__pages {
|
|
518
|
+
display: flex;
|
|
519
|
+
flex-direction: column;
|
|
520
|
+
align-items: center;
|
|
521
|
+
}
|
|
522
|
+
.filex-viewer-pdf__footer {
|
|
523
|
+
padding: 6px 12px;
|
|
524
|
+
background: var(--fe-bg-elev, #f7f8fa);
|
|
525
|
+
border-top: 1px solid var(--fe-border, #e2e6ed);
|
|
526
|
+
font-size: 11px;
|
|
527
|
+
color: var(--fe-text-muted, #5a6475);
|
|
528
|
+
text-align: center;
|
|
529
|
+
}
|
|
530
|
+
.filex-viewer-fallback {
|
|
531
|
+
text-align: center;
|
|
532
|
+
padding: 32px;
|
|
533
|
+
color: #c8cdd6;
|
|
534
|
+
margin: auto;
|
|
535
|
+
}
|
|
536
|
+
.filex-viewer-fallback__icon {
|
|
537
|
+
font-size: 48px;
|
|
538
|
+
display: block;
|
|
539
|
+
margin-bottom: 12px;
|
|
540
|
+
}
|
|
541
|
+
.filex-viewer-btn {
|
|
542
|
+
border: 1px solid var(--fe-border, #e2e6ed);
|
|
543
|
+
background: var(--fe-bg, #fff);
|
|
544
|
+
color: var(--fe-text, #1a1e27);
|
|
545
|
+
padding: 4px 10px;
|
|
546
|
+
border-radius: 4px;
|
|
547
|
+
cursor: pointer;
|
|
548
|
+
font: inherit;
|
|
549
|
+
font-size: 12px;
|
|
550
|
+
}
|
|
551
|
+
.filex-viewer-btn:hover:not(:disabled) {
|
|
552
|
+
background: var(--fe-bg-hover, #edf0f5);
|
|
553
|
+
}
|
|
554
|
+
.filex-viewer-btn:disabled {
|
|
555
|
+
opacity: 0.4;
|
|
556
|
+
cursor: not-allowed;
|
|
557
|
+
}
|
|
558
|
+
.filex-viewer-btn.is-active {
|
|
559
|
+
background: var(--fe-bg-selected, #dfe8ff);
|
|
560
|
+
border-color: var(--fe-primary, #3b82f6);
|
|
561
|
+
}
|
|
562
|
+
</style>
|