feat: pdf file preview (#4753)
also fixes s3 missing content-length header
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,4 +6,5 @@ frontend/src/routes/test.svelte
|
||||
CaddyfileRemoteMalo
|
||||
*.swp
|
||||
**/.idea/
|
||||
.direnv
|
||||
.direnv
|
||||
.vscode
|
||||
3
.vscode/settings.json
vendored
3
.vscode/settings.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"python.analysis.typeCheckingMode": "basic"
|
||||
}
|
||||
@@ -12,6 +12,8 @@ use object_store::azure::MicrosoftAzureBuilder;
|
||||
use object_store::ObjectStore;
|
||||
#[cfg(feature = "parquet")]
|
||||
use object_store::{aws::AmazonS3Builder, ClientOptions};
|
||||
#[cfg(feature = "parquet")]
|
||||
use reqwest::header::HeaderMap;
|
||||
use serde::{Deserialize, Serialize};
|
||||
#[cfg(feature = "parquet")]
|
||||
use std::sync::Arc;
|
||||
@@ -251,9 +253,15 @@ pub async fn build_s3_client(s3_resource_ref: &S3Resource) -> error::Result<Arc<
|
||||
s3_resource.path_style,
|
||||
s3_resource.bucket.clone(),
|
||||
);
|
||||
|
||||
let mut store_builder = AmazonS3Builder::new()
|
||||
.with_client_options(ClientOptions::new().with_timeout_disabled()) // TODO: make it configurable maybe
|
||||
.with_client_options(
|
||||
ClientOptions::new()
|
||||
.with_timeout_disabled()
|
||||
.with_default_headers(HeaderMap::from_iter(vec![(
|
||||
"Accept-Encoding".parse().unwrap(),
|
||||
"".parse().unwrap(),
|
||||
)])),
|
||||
) // TODO: make it configurable maybe
|
||||
.with_region(s3_resource.region)
|
||||
.with_bucket_name(s3_resource.bucket)
|
||||
.with_endpoint(endpoint);
|
||||
@@ -306,7 +314,14 @@ fn build_azure_blob_client(
|
||||
let blob_resource = azure_blob_resource_ref.clone();
|
||||
|
||||
let mut store_builder = MicrosoftAzureBuilder::new()
|
||||
.with_client_options(ClientOptions::new().with_timeout_disabled()) // TODO: make it configurable maybe
|
||||
.with_client_options(
|
||||
ClientOptions::new()
|
||||
.with_timeout_disabled()
|
||||
.with_default_headers(HeaderMap::from_iter(vec![(
|
||||
"Accept-Encoding".parse().unwrap(),
|
||||
"".parse().unwrap(),
|
||||
)])),
|
||||
) // TODO: make it configurable maybe
|
||||
.with_account(blob_resource.account_name)
|
||||
.with_container_name(blob_resource.container_name);
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
import { convertJsonToCsv } from './table/tableUtils'
|
||||
import Tooltip from './Tooltip.svelte'
|
||||
import HighlightTheme from './HighlightTheme.svelte'
|
||||
import PdfViewer from './display/PdfViewer.svelte'
|
||||
|
||||
export let result: any
|
||||
export let requireHtmlApproval = false
|
||||
@@ -71,6 +72,7 @@
|
||||
| 'markdown'
|
||||
| 'map'
|
||||
| 'nondisplayable'
|
||||
| 'pdf'
|
||||
| undefined
|
||||
|
||||
$: resultKind = inferResultKind(result)
|
||||
@@ -199,6 +201,8 @@
|
||||
return 'html'
|
||||
} else if (keys.length == 1 && keys[0] === 'map') {
|
||||
return 'map'
|
||||
} else if (keys.length == 1 && keys[0] === 'pdf') {
|
||||
return 'pdf'
|
||||
} else if (keys.length == 1 && keys[0] === 'file') {
|
||||
return 'file'
|
||||
} else if (
|
||||
@@ -528,6 +532,13 @@
|
||||
src="data:image/gif;base64,{contentOrRootString(result.gif)}"
|
||||
/>
|
||||
</div>
|
||||
{:else if !forceJson && resultKind === 'pdf'}
|
||||
<div class="h-96 mt-2">
|
||||
<PdfViewer
|
||||
allowFullscreen
|
||||
source="data:application/pdf;base64,{contentOrRootString(result.pdf)}"
|
||||
/>
|
||||
</div>
|
||||
{:else if !forceJson && resultKind === 'plain'}<div class="h-full text-2xs"
|
||||
><pre>{typeof result === 'string' ? result : result?.['result']}</pre>{#if !noControls}
|
||||
<div class="flex">
|
||||
@@ -667,6 +678,14 @@
|
||||
(result.storage ? `&storage=${result.storage}` : '')}
|
||||
/>
|
||||
</div>
|
||||
{:else if result?.s3?.endsWith('.pdf')}
|
||||
<div class="h-96 mt-2">
|
||||
<PdfViewer
|
||||
allowFullscreen
|
||||
source={`/api/w/${workspaceId}/job_helpers/load_image_preview?file_key=${result.s3}` +
|
||||
(result.storage ? `&storage=${result.storage}` : '')}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
@@ -735,6 +754,14 @@
|
||||
>open image preview <ArrowDownFromLine />
|
||||
</button>
|
||||
{/if}
|
||||
{:else if s3object?.s3?.endsWith('.pdf')}
|
||||
<div class="h-96 mt-2">
|
||||
<PdfViewer
|
||||
allowFullscreen
|
||||
source={`/api/w/${workspaceId}/job_helpers/load_image_preview?file_key=${s3object.s3}` +
|
||||
(s3object.storage ? `&storage=${s3object.storage}` : '')}
|
||||
/>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
import TableSimple from './TableSimple.svelte'
|
||||
import ConfirmationModal from './common/confirmationModal/ConfirmationModal.svelte'
|
||||
import FileUploadModal from './common/fileUpload/FileUploadModal.svelte'
|
||||
import PdfViewer from './display/PdfViewer.svelte'
|
||||
|
||||
let deletionModalOpen = false
|
||||
let fileDeletionInProgress = false
|
||||
@@ -245,6 +246,16 @@
|
||||
contentPreview: filePreviewContent,
|
||||
contentType: filePreviewRaw.content_type
|
||||
}
|
||||
if (fileMetadata) {
|
||||
fileMetadata.mimeType =
|
||||
((fileKey.endsWith('.png') ||
|
||||
fileKey.endsWith('.jpg') ||
|
||||
fileKey.endsWith('.jpeg') ||
|
||||
fileKey.endsWith('.webp')) &&
|
||||
'Image') ||
|
||||
(fileKey.endsWith('.pdf') && 'PDF') ||
|
||||
filePreview.contentType
|
||||
}
|
||||
}
|
||||
filePreviewLoading = false
|
||||
fileInfoLoading = false
|
||||
@@ -616,6 +627,13 @@
|
||||
alt="S3 preview"
|
||||
/>
|
||||
</div>
|
||||
{:else if fileMetadata?.fileKey.endsWith('.pdf')}
|
||||
<div class="w-full h-[950px] border">
|
||||
<PdfViewer
|
||||
source={`/api/w/${$workspaceStore}/job_helpers/load_image_preview?file_key=${fileMetadata.fileKey}` +
|
||||
(storage ? `&storage=${storage}` : '')}
|
||||
/>
|
||||
</div>
|
||||
{:else if filePreviewLoading}
|
||||
<div class="flex h-6 items-center text-tertiary mb-4">
|
||||
<Loader2 size={12} class="animate-spin mr-1" /> File preview loading
|
||||
|
||||
@@ -1,18 +1,12 @@
|
||||
<script lang="ts">
|
||||
import { Download, Loader2, MoveHorizontal, ZoomIn, ZoomOut } from 'lucide-svelte'
|
||||
import { getDocument, type PDFDocumentProxy, type PDFPageProxy } from 'pdfjs-dist'
|
||||
import 'pdfjs-dist/build/pdf.worker.entry'
|
||||
import { getContext } from 'svelte'
|
||||
import { fade } from 'svelte/transition'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { throttle } from '../../../../utils'
|
||||
import { Button } from '../../../common'
|
||||
import { findGridItem, initOutput } from '../../editor/appUtils'
|
||||
import type { AppViewerContext, ComponentCustomCSS, RichConfigurations } from '../../types'
|
||||
import { initCss } from '../../utils'
|
||||
import InputValue from '../helpers/InputValue.svelte'
|
||||
import InitializeComponent from '../helpers/InitializeComponent.svelte'
|
||||
import ResolveStyle from '../helpers/ResolveStyle.svelte'
|
||||
import PdfViewer from '$lib/components/display/PdfViewer.svelte'
|
||||
|
||||
export let id: string
|
||||
export let configuration: RichConfigurations
|
||||
@@ -27,133 +21,7 @@
|
||||
})
|
||||
|
||||
let source: string | ArrayBuffer | undefined = undefined
|
||||
let wrapper: HTMLDivElement | undefined = undefined
|
||||
let error: string | undefined = undefined
|
||||
let doc: PDFDocumentProxy | undefined = undefined
|
||||
let pages: PDFPageProxy[] = []
|
||||
let zoom: number | undefined = undefined
|
||||
let controlsWidth: number | undefined = undefined
|
||||
let controlsHeight: number | undefined = undefined
|
||||
let pageNumber = 1
|
||||
|
||||
$: if (source == '') {
|
||||
resetDoc()
|
||||
error = 'Set the "Source" attribute of the PDF component'
|
||||
}
|
||||
$: zoom && handleZoom()
|
||||
$: wrapper && loadDocument(source)
|
||||
$: wideView = controlsWidth && controlsWidth > 450
|
||||
|
||||
async function resetDoc() {
|
||||
await doc?.destroy()
|
||||
doc = undefined
|
||||
}
|
||||
|
||||
function handleZoom() {
|
||||
if (zoom && wrapper) {
|
||||
try {
|
||||
renderPdf(false)
|
||||
} catch (err) {
|
||||
error = err?.message ?? (typeof err === 'string' ? err : 'Error loading PDF')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadDocument(src: string | ArrayBuffer | undefined) {
|
||||
if (!src) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
outputs.loading.set(true)
|
||||
await resetDoc()
|
||||
doc = await getDocument(src).promise
|
||||
pageNumber = 1
|
||||
await renderPdf(false, false)
|
||||
error = undefined
|
||||
} catch (err) {
|
||||
await resetDoc()
|
||||
error = err?.message ?? (typeof err === 'string' ? err : 'Error loading PDF')
|
||||
console.log(err)
|
||||
}
|
||||
outputs.loading.set(false)
|
||||
}
|
||||
|
||||
async function renderPdf(scaleToViewport = true, resizing = false) {
|
||||
if (!(doc && wrapper && zoom)) {
|
||||
return
|
||||
}
|
||||
const scrollPosition = wrapper.scrollTop / wrapper.scrollHeight
|
||||
if (!resizing) {
|
||||
pages = []
|
||||
}
|
||||
const nextPages: typeof pages = []
|
||||
const nextChildren: HTMLCanvasElement[] = []
|
||||
const { width } = wrapper.getBoundingClientRect()
|
||||
let scale = zoom / 100
|
||||
if (scaleToViewport) {
|
||||
const firstViewport = (await doc.getPage(1)).getViewport({ scale: 1 })
|
||||
// Rounded to the first integer that is a multiple of 10 and is less than the viewport width
|
||||
zoom = Math.floor((width / firstViewport.width) * 10) * 10
|
||||
scale = zoom / 100
|
||||
}
|
||||
|
||||
for (let i = 0; i < doc.numPages; i++) {
|
||||
const canvas = document.createElement('canvas')
|
||||
const canvasContext = canvas.getContext('2d')
|
||||
if (!canvasContext) {
|
||||
console.warn('Could not get canvas context for PDF page ' + i)
|
||||
continue
|
||||
}
|
||||
const page = await doc.getPage(i + 1)
|
||||
nextPages.push(page)
|
||||
const viewport = page.getViewport({ scale })
|
||||
canvas.height = viewport.height
|
||||
canvas.width = viewport.width
|
||||
canvas.classList.add('mx-auto', 'my-4', 'shadow-sm')
|
||||
await page.render({ canvasContext, viewport }).promise
|
||||
nextChildren.push(canvas)
|
||||
}
|
||||
while (wrapper.firstChild) {
|
||||
wrapper.removeChild(wrapper.firstChild)
|
||||
}
|
||||
pages = [...nextPages]
|
||||
wrapper.append(...nextChildren)
|
||||
wrapper.scrollTo({
|
||||
top: scrollPosition * wrapper.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
function scrollToPage(page: number) {
|
||||
page = pageNumber = minMax(page, 1, pages.length)
|
||||
const offset = (wrapper?.children.item(page - 1) as HTMLCanvasElement | null)?.offsetTop
|
||||
if (!offset) {
|
||||
return
|
||||
}
|
||||
// controlsHeight + 2px border + half of the top margin
|
||||
const padding = (controlsHeight ? controlsHeight + 2 : 0) + 8
|
||||
wrapper?.scrollTo({
|
||||
top: offset - padding
|
||||
})
|
||||
}
|
||||
|
||||
const throttledScroll = throttle(onScroll, 400)
|
||||
function onScroll() {
|
||||
if (!wrapper) {
|
||||
return
|
||||
}
|
||||
const THRESHOLD = 50
|
||||
let scrollPosition = wrapper.scrollTop + THRESHOLD + (controlsHeight ?? 0)
|
||||
let page = 1
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const canvas = wrapper.children.item(i) as HTMLCanvasElement | null
|
||||
if (scrollPosition < (canvas?.offsetTop ?? wrapper.scrollHeight)) {
|
||||
break
|
||||
}
|
||||
page = i + 1
|
||||
}
|
||||
pageNumber = page
|
||||
}
|
||||
|
||||
function syncZoomValue() {
|
||||
const gridItem = findGridItem($app, id)
|
||||
|
||||
@@ -166,28 +34,6 @@
|
||||
$app = $app
|
||||
}
|
||||
|
||||
async function downloadPdf() {
|
||||
if (!doc) {
|
||||
return
|
||||
}
|
||||
const data = await doc.saveDocument()
|
||||
const url = URL.createObjectURL(new Blob([data.buffer]))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'document.pdf'
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function minMax(value: number, min: number, max: number) {
|
||||
if (value < min) {
|
||||
return min
|
||||
} else if (value > max) {
|
||||
return max
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
let css = initCss($app.css?.pdfcomponent, customCss)
|
||||
</script>
|
||||
|
||||
@@ -207,133 +53,30 @@
|
||||
<InitializeComponent {id} />
|
||||
|
||||
{#if render}
|
||||
<div class="relative flex flex-col w-full h-full bg-gray-100 component-wrapper">
|
||||
{#if source && zoom}
|
||||
{#if pages?.length}
|
||||
<div
|
||||
bind:clientWidth={controlsWidth}
|
||||
bind:clientHeight={controlsHeight}
|
||||
class="flex flex-row w-full justify-between overflow-x-auto bg-surface border-b mx-auto py-1"
|
||||
>
|
||||
<div class="flex justify-start items-center px-2 text-secondary text-sm">
|
||||
<Button
|
||||
on:click={() => zoom && (zoom -= 10)}
|
||||
disabled={!doc}
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="border"
|
||||
title="Zoom out"
|
||||
aria-label="Zoom out"
|
||||
btnClasses="!rounded-r-none !px-2"
|
||||
>
|
||||
<ZoomOut size={16} />
|
||||
</Button>
|
||||
{#if wideView}
|
||||
<Button
|
||||
on:click={() => (zoom = 100)}
|
||||
disabled={!doc}
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="border"
|
||||
title="Reset zoom"
|
||||
aria-label="Reset zoom"
|
||||
btnClasses="!w-[50px] !font-medium !rounded-none !border-l-0 !px-1"
|
||||
>
|
||||
{zoom.toFixed(0)}%
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
on:click={() => renderPdf(true, true)}
|
||||
disabled={!doc}
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="border"
|
||||
title="Scale to viewport"
|
||||
aria-label="Scale to viewport"
|
||||
btnClasses="!rounded-none !border-l-0 !px-2"
|
||||
>
|
||||
<MoveHorizontal size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
on:click={() => zoom && (zoom += 10)}
|
||||
disabled={!doc}
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="border"
|
||||
title="Zoom in"
|
||||
aria-label="Zoom in"
|
||||
btnClasses="!rounded-l-none !px-2 !border-l-0"
|
||||
>
|
||||
<ZoomIn size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="center-center px-2 text-secondary text-sm">
|
||||
<input
|
||||
on:input={({ currentTarget }) => {
|
||||
scrollToPage(currentTarget.valueAsNumber)
|
||||
}}
|
||||
min="1"
|
||||
max={pages.length}
|
||||
value={pageNumber}
|
||||
disabled={!doc}
|
||||
type="number"
|
||||
class="!w-[45px] !px-1 !py-0"
|
||||
/>
|
||||
<span class="whitespace-nowrap pl-1">
|
||||
/ {pages.length}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-end items-center px-2 text-secondary text-sm">
|
||||
<Button
|
||||
on:click={downloadPdf}
|
||||
disabled={!doc}
|
||||
size="xs"
|
||||
color="light"
|
||||
title="Download PDF"
|
||||
aria-label="Download PDF"
|
||||
btnClasses="!font-medium !px-2"
|
||||
>
|
||||
<Download size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
out:fade={{ duration: 200 }}
|
||||
class="absolute inset-0 center-center flex-col text-center text-sm bg-surface text-secondary"
|
||||
>
|
||||
<Loader2 class="animate-spin mb-2" />
|
||||
Loading PDF
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
bind:this={wrapper}
|
||||
on:scroll={throttledScroll}
|
||||
class={twMerge(
|
||||
'w-full h-full overflow-auto',
|
||||
css?.container?.class ?? '',
|
||||
'bg-gray-100',
|
||||
'wm-pdf'
|
||||
)}
|
||||
style={css?.container?.style ?? ''}
|
||||
/>
|
||||
{/if}
|
||||
{#if $mode !== 'preview' && $selectedComponent?.includes(id)}
|
||||
<button
|
||||
class="fixed z-10 bottom-0 left-0 px-2 py-0.5 bg-indigo-500/90
|
||||
<div class="relative w-full h-full bg-gray-100 component-wrapper">
|
||||
<PdfViewer
|
||||
{source}
|
||||
{zoom}
|
||||
class={css?.container?.class}
|
||||
style={css?.container?.style}
|
||||
on:loading={() => {
|
||||
outputs.loading.set(true)
|
||||
}}
|
||||
on:loaded={() => {
|
||||
outputs.loading.set(false)
|
||||
}}
|
||||
>
|
||||
<svelte:fragment slot="extra-button">
|
||||
{#if $mode !== 'preview' && $selectedComponent?.includes(id)}
|
||||
<button
|
||||
class="fixed z-10 bottom-0 left-0 px-2 py-0.5 bg-indigo-500/90
|
||||
hover:bg-indigo-500 focus:bg-indigo-500 duration-200 text-white text-2xs"
|
||||
on:click={() => syncZoomValue()}
|
||||
>
|
||||
Sync zoom value
|
||||
</button>
|
||||
{/if}
|
||||
{#if error}
|
||||
<div
|
||||
class="absolute inset-0 z-20 center-center
|
||||
bg-gray-100 text-center text-secondary text-sm"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
on:click={() => syncZoomValue()}
|
||||
>
|
||||
Sync zoom value
|
||||
</button>
|
||||
{/if}
|
||||
</svelte:fragment>
|
||||
</PdfViewer>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
331
frontend/src/lib/components/display/PdfViewer.svelte
Normal file
331
frontend/src/lib/components/display/PdfViewer.svelte
Normal file
@@ -0,0 +1,331 @@
|
||||
<script lang="ts">
|
||||
import { Download, Expand, Loader2, MoveHorizontal, Shrink, ZoomIn, ZoomOut } from 'lucide-svelte'
|
||||
import {
|
||||
getDocument,
|
||||
type PDFDocumentProxy,
|
||||
type PDFPageProxy,
|
||||
GlobalWorkerOptions
|
||||
} from 'pdfjs-dist'
|
||||
|
||||
GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.mjs', import.meta.url).href
|
||||
|
||||
// import { getContext } from 'svelte'
|
||||
import { fade } from 'svelte/transition'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { throttle } from '../../utils'
|
||||
import { Button } from '../common'
|
||||
import { createEventDispatcher } from 'svelte'
|
||||
|
||||
export let source: string | ArrayBuffer | undefined = undefined
|
||||
export let zoom: number | undefined = 100
|
||||
export let extraButton: any | undefined = undefined
|
||||
export let allowFullscreen = false
|
||||
let fullscreen = false
|
||||
let wrapper: HTMLDivElement | undefined = undefined
|
||||
let error: string | undefined = undefined
|
||||
let doc: PDFDocumentProxy | undefined = undefined
|
||||
let pages: PDFPageProxy[] = []
|
||||
|
||||
let controlsWidth: number | undefined = undefined
|
||||
let controlsHeight: number | undefined = undefined
|
||||
let pageNumber = 1
|
||||
|
||||
$: if (source == '') {
|
||||
resetDoc()
|
||||
error = 'Set the "Source" attribute of the PDF component'
|
||||
}
|
||||
$: zoom && handleZoom()
|
||||
$: wrapper && loadDocument(source)
|
||||
$: wideView = controlsWidth && controlsWidth > 450
|
||||
|
||||
async function resetDoc() {
|
||||
await doc?.destroy()
|
||||
doc = undefined
|
||||
}
|
||||
|
||||
function handleZoom() {
|
||||
if (zoom && wrapper) {
|
||||
try {
|
||||
renderPdf(false)
|
||||
} catch (err) {
|
||||
error = err?.message ?? (typeof err === 'string' ? err : 'Error loading PDF')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dispatch = createEventDispatcher()
|
||||
|
||||
async function loadDocument(src: string | ArrayBuffer | undefined) {
|
||||
console.log('loading documnet')
|
||||
if (!src) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
dispatch('loading')
|
||||
await resetDoc()
|
||||
doc = await getDocument(src).promise
|
||||
console.log('got doc')
|
||||
pageNumber = 1
|
||||
await renderPdf(false, false)
|
||||
console.log('rendered pdf')
|
||||
error = undefined
|
||||
} catch (err) {
|
||||
await resetDoc()
|
||||
error = err?.message ?? (typeof err === 'string' ? err : 'Error loading PDF')
|
||||
console.log(err)
|
||||
}
|
||||
dispatch('loaded')
|
||||
}
|
||||
|
||||
async function renderPdf(scaleToViewport = true, resizing = false) {
|
||||
if (!(doc && wrapper && zoom)) {
|
||||
return
|
||||
}
|
||||
const scrollPosition = wrapper.scrollTop / wrapper.scrollHeight
|
||||
if (!resizing) {
|
||||
pages = []
|
||||
}
|
||||
const nextPages: typeof pages = []
|
||||
const nextChildren: HTMLCanvasElement[] = []
|
||||
const { width } = wrapper.getBoundingClientRect()
|
||||
let scale = zoom / 100
|
||||
if (scaleToViewport) {
|
||||
const firstViewport = (await doc.getPage(1)).getViewport({ scale: 1 })
|
||||
// Rounded to the first integer that is a multiple of 10 and is less than the viewport width
|
||||
zoom = Math.floor((width / firstViewport.width) * 10) * 10
|
||||
scale = zoom / 100
|
||||
}
|
||||
|
||||
for (let i = 0; i < doc.numPages; i++) {
|
||||
const canvas = document.createElement('canvas')
|
||||
const canvasContext = canvas.getContext('2d')
|
||||
if (!canvasContext) {
|
||||
console.warn('Could not get canvas context for PDF page ' + i)
|
||||
continue
|
||||
}
|
||||
const page = await doc.getPage(i + 1)
|
||||
nextPages.push(page)
|
||||
const viewport = page.getViewport({ scale })
|
||||
canvas.height = viewport.height
|
||||
canvas.width = viewport.width
|
||||
canvas.classList.add('mx-auto', 'my-4', 'shadow-sm')
|
||||
await page.render({ canvasContext, viewport }).promise
|
||||
nextChildren.push(canvas)
|
||||
}
|
||||
while (wrapper.firstChild) {
|
||||
wrapper.removeChild(wrapper.firstChild)
|
||||
}
|
||||
pages = [...nextPages]
|
||||
wrapper.append(...nextChildren)
|
||||
wrapper.scrollTo({
|
||||
top: scrollPosition * wrapper.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
function scrollToPage(page: number) {
|
||||
page = pageNumber = minMax(page, 1, pages.length)
|
||||
const offset = (wrapper?.children.item(page - 1) as HTMLCanvasElement | null)?.offsetTop
|
||||
if (!offset) {
|
||||
return
|
||||
}
|
||||
// controlsHeight + 2px border + half of the top margin
|
||||
const padding = (controlsHeight ? controlsHeight + 2 : 0) + 8
|
||||
wrapper?.scrollTo({
|
||||
top: offset - padding
|
||||
})
|
||||
}
|
||||
|
||||
const throttledScroll = throttle(onScroll, 400)
|
||||
function onScroll() {
|
||||
if (!wrapper) {
|
||||
return
|
||||
}
|
||||
const THRESHOLD = 50
|
||||
let scrollPosition = wrapper.scrollTop + THRESHOLD + (controlsHeight ?? 0)
|
||||
let page = 1
|
||||
for (let i = 0; i < pages.length; i++) {
|
||||
const canvas = wrapper.children.item(i) as HTMLCanvasElement | null
|
||||
if (scrollPosition < (canvas?.offsetTop ?? wrapper.scrollHeight)) {
|
||||
break
|
||||
}
|
||||
page = i + 1
|
||||
}
|
||||
pageNumber = page
|
||||
}
|
||||
|
||||
async function downloadPdf() {
|
||||
if (!doc) {
|
||||
return
|
||||
}
|
||||
const data = await doc.saveDocument()
|
||||
const url = URL.createObjectURL(new Blob([data.buffer]))
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = 'document.pdf'
|
||||
link.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
function minMax(value: number, min: number, max: number) {
|
||||
if (value < min) {
|
||||
return min
|
||||
} else if (value > max) {
|
||||
return max
|
||||
}
|
||||
return value
|
||||
}
|
||||
</script>
|
||||
|
||||
<div
|
||||
class={twMerge('flex flex-col w-full h-full', fullscreen ? 'fixed inset-0 z-[9999]' : 'relative')}
|
||||
>
|
||||
{#if source && zoom}
|
||||
{#if pages?.length}
|
||||
<div
|
||||
bind:clientWidth={controlsWidth}
|
||||
bind:clientHeight={controlsHeight}
|
||||
class="flex flex-row w-full justify-between overflow-x-auto bg-surface border-b mx-auto py-1"
|
||||
>
|
||||
<div class="flex justify-start items-center px-2 text-secondary text-sm">
|
||||
<Button
|
||||
on:click={() => zoom && (zoom -= 10)}
|
||||
disabled={!doc}
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="border"
|
||||
title="Zoom out"
|
||||
aria-label="Zoom out"
|
||||
btnClasses="!rounded-r-none !px-2"
|
||||
>
|
||||
<ZoomOut size={16} />
|
||||
</Button>
|
||||
{#if wideView}
|
||||
<Button
|
||||
on:click={() => (zoom = 100)}
|
||||
disabled={!doc}
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="border"
|
||||
title="Reset zoom"
|
||||
aria-label="Reset zoom"
|
||||
btnClasses="!w-[50px] !font-medium !rounded-none !border-l-0 !px-1"
|
||||
>
|
||||
{zoom.toFixed(0)}%
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
on:click={() => renderPdf(true, true)}
|
||||
disabled={!doc}
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="border"
|
||||
title="Scale to viewport"
|
||||
aria-label="Scale to viewport"
|
||||
btnClasses="!rounded-none !border-l-0 !px-2"
|
||||
>
|
||||
<MoveHorizontal size={16} />
|
||||
</Button>
|
||||
<Button
|
||||
on:click={() => zoom && (zoom += 10)}
|
||||
disabled={!doc}
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="border"
|
||||
title="Zoom in"
|
||||
aria-label="Zoom in"
|
||||
btnClasses="!rounded-l-none !px-2 !border-l-0"
|
||||
>
|
||||
<ZoomIn size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
<div class="center-center px-2 text-secondary text-sm">
|
||||
<input
|
||||
on:input={({ currentTarget }) => {
|
||||
scrollToPage(currentTarget.valueAsNumber)
|
||||
}}
|
||||
min="1"
|
||||
max={pages.length}
|
||||
value={pageNumber}
|
||||
disabled={!doc}
|
||||
type="number"
|
||||
class="!w-[45px] !px-1 !py-0"
|
||||
/>
|
||||
<span class="whitespace-nowrap pl-1">
|
||||
/ {pages.length}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex justify-end items-center px-2 text-secondary text-sm gap-1">
|
||||
{#if allowFullscreen}
|
||||
<Button
|
||||
on:click={downloadPdf}
|
||||
disabled={!doc}
|
||||
size="xs"
|
||||
color="light"
|
||||
title="Download PDF"
|
||||
aria-label="Download PDF"
|
||||
btnClasses="!font-medium !px-2"
|
||||
>
|
||||
<Download size={14} />
|
||||
</Button>
|
||||
{/if}
|
||||
<Button
|
||||
on:click={() => (fullscreen = !fullscreen)}
|
||||
disabled={!doc}
|
||||
size="xs"
|
||||
color="light"
|
||||
title="Toggle fullscreen"
|
||||
aria-label="Toggle fullscreen"
|
||||
btnClasses="!font-medium !px-2"
|
||||
>
|
||||
{#if fullscreen}
|
||||
<Shrink size={14} />
|
||||
{:else}
|
||||
<Expand size={14} />
|
||||
{/if}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
out:fade={{ duration: 200 }}
|
||||
class="absolute inset-0 center-center flex-col text-center text-sm bg-surface text-secondary"
|
||||
>
|
||||
<Loader2 class="animate-spin mb-2" />
|
||||
Loading PDF
|
||||
</div>
|
||||
{/if}
|
||||
<div
|
||||
bind:this={wrapper}
|
||||
on:scroll={throttledScroll}
|
||||
class={twMerge(
|
||||
'w-full h-full overflow-auto',
|
||||
$$props.class ?? '',
|
||||
// css?.container?.class ?? '',
|
||||
'bg-gray-100',
|
||||
'wm-pdf'
|
||||
)}
|
||||
style={$$props.style ?? ''}
|
||||
/>
|
||||
{/if}
|
||||
{#if extraButton}
|
||||
<svelte:component this={extraButton} />
|
||||
{/if}
|
||||
<!-- {#if $mode !== 'preview' && $selectedComponent?.includes(id)}
|
||||
<button
|
||||
class="fixed z-10 bottom-0 left-0 px-2 py-0.5 bg-indigo-500/90
|
||||
hover:bg-indigo-500 focus:bg-indigo-500 duration-200 text-white text-2xs"
|
||||
on:click={() => syncZoomValue()}
|
||||
>
|
||||
Sync zoom value
|
||||
</button>
|
||||
{/if} -->
|
||||
<slot name="extra-button" />
|
||||
{#if error}
|
||||
<div
|
||||
class="absolute inset-0 z-20 center-center
|
||||
bg-gray-100 text-center text-secondary text-sm"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Reference in New Issue
Block a user