mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-23 08:00:45 +00:00
feat(frontend): Add app PDF viewer (#1254)
* feat(frontend): Add app PDF viewer (wip) * fix(frontend): Handle file upload * fix(frontend): Handle multi page pdf * feat(frontend): Add pdf page numbering * feat(frontend): Add more pdf viewer controls * save * fix(frontend): Pdf loading * fix(frontend): Resize PDF in small window * fix(frontend): Minor fixes * feat(frontend): Add pdf zoom configuration * fix wip * save * bg color * save progress * pdf scaling * feat(frontend): fix zoom synchro * fix(frontend): Pdf scroll tracking * fix(frontend): Double scrollbar * nits * fixes --------- Co-authored-by: Faton Ramadani <faton.ramadani14@gmail.com> Co-authored-by: Ruben Fiszel <ruben@rubenfiszel.com>
This commit is contained in:
Generated
+940
File diff suppressed because it is too large
Load Diff
@@ -36,6 +36,7 @@
|
||||
"ol": "^7.2.2",
|
||||
"openapi-typescript-codegen": "^0.23.0",
|
||||
"path-browserify": "^1.0.1",
|
||||
"pdfjs-dist": "^3.4.120",
|
||||
"postcss": "^8.4.18",
|
||||
"postcss-load-config": "^4.0.1",
|
||||
"prettier": "^2.8.3",
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
<script lang="ts">
|
||||
import { getContext } from 'svelte'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
import { getDocument, type PDFDocumentProxy, type PDFPageProxy } from 'pdfjs-dist'
|
||||
import 'pdfjs-dist/build/pdf.worker.entry'
|
||||
import type { AppInput } from '../../inputType'
|
||||
import type { AppEditorContext, ComponentCustomCSS } from '../../types'
|
||||
import { concatCustomCss } from '../../utils'
|
||||
import InputValue from '../helpers/InputValue.svelte'
|
||||
import { throttle } from '../../../../utils'
|
||||
import { Button } from '../../../common'
|
||||
import { Download, Loader2, MoveHorizontal, ZoomIn, ZoomOut } from 'lucide-svelte'
|
||||
import { fade } from 'svelte/transition'
|
||||
import { findGridItem } from '../../editor/appUtils'
|
||||
|
||||
export let id: string
|
||||
export let configuration: Record<string, AppInput>
|
||||
export const staticOutputs: string[] = ['loading']
|
||||
export let customCss: ComponentCustomCSS<'container'> | undefined = undefined
|
||||
|
||||
const { app, mode, selectedComponent } = getContext<AppEditorContext>('AppEditorContext')
|
||||
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// debugger
|
||||
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)
|
||||
|
||||
if (gridItem && gridItem.data.configuration.zoom.value !== zoom) {
|
||||
gridItem.data.configuration.zoom.value = zoom
|
||||
}
|
||||
|
||||
$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
|
||||
}
|
||||
|
||||
$: css = concatCustomCss($app.css?.pdfcomponent, customCss)
|
||||
</script>
|
||||
|
||||
<InputValue {id} input={configuration.source} bind:value={source} />
|
||||
<InputValue {id} input={configuration.zoom} bind:value={zoom} />
|
||||
|
||||
<div class="relative w-full h-full bg-gray-100">
|
||||
{#if source && zoom}
|
||||
{#if pages?.length}
|
||||
<div
|
||||
bind:clientWidth={controlsWidth}
|
||||
bind:clientHeight={controlsHeight}
|
||||
class="fixed flex {$mode !== 'preview'
|
||||
? 'w-[calc(100%-2px)] top-[1px]'
|
||||
: 'w-full top-0'} {wideView
|
||||
? 'justify-center gap-14'
|
||||
: '!justify-between'} overflow-x-auto bg-white border mx-auto py-1"
|
||||
>
|
||||
<div class="flex justify-start items-center px-2 text-gray-600 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-gray-600 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-gray-600 text-sm">
|
||||
<Button
|
||||
on:click={downloadPdf}
|
||||
disabled={!doc}
|
||||
size="xs"
|
||||
color="light"
|
||||
variant="border"
|
||||
title="Download PDF"
|
||||
aria-label="Download PDF"
|
||||
btnClasses="!font-medium !px-2"
|
||||
>
|
||||
{#if wideView}
|
||||
<span class="mr-1"> Download </span>
|
||||
{/if}
|
||||
<Download size={16} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div
|
||||
out:fade={{ duration: 200 }}
|
||||
class="absolute inset-0 center-center flex-col text-center text-sm bg-white text-gray-600"
|
||||
>
|
||||
<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')}
|
||||
style="padding-top: {controlsHeight ?? 0}px; {css?.container?.style ?? ''}"
|
||||
/>
|
||||
{/if}
|
||||
{#if $mode !== 'preview' && $selectedComponent === 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-gray-600 text-sm"
|
||||
>
|
||||
{error}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -6,6 +6,7 @@ export { default as AppHtml } from './AppHtml.svelte'
|
||||
export { default as AppIcon } from './AppIcon.svelte'
|
||||
export { default as AppImage } from './AppImage.svelte'
|
||||
export { default as AppMap } from './AppMap.svelte'
|
||||
export { default as AppPdf } from './AppPdf.svelte'
|
||||
export { default as AppPieChart } from './AppPieChart.svelte'
|
||||
export { default as AppScatterChart } from './AppScatterChart.svelte'
|
||||
export { default as AppText } from './AppText.svelte'
|
||||
|
||||
@@ -17,6 +17,10 @@
|
||||
|
||||
$: if (input && !deepEqual(input, lastInput)) {
|
||||
lastInput = JSON.parse(JSON.stringify(input))
|
||||
// Needed because of file uploads
|
||||
if (input?.['value'] instanceof ArrayBuffer) {
|
||||
lastInput.value = input?.['value']
|
||||
}
|
||||
}
|
||||
|
||||
const { worldStore } = getContext<AppEditorContext>('AppEditorContext')
|
||||
|
||||
@@ -37,7 +37,8 @@
|
||||
AppAggridTable,
|
||||
AppDrawer,
|
||||
AppMap,
|
||||
AppSplitpanes
|
||||
AppSplitpanes,
|
||||
AppPdf
|
||||
} from '../../components'
|
||||
import AppMultiSelect from '../../components/inputs/AppMultiSelect.svelte'
|
||||
|
||||
@@ -386,7 +387,19 @@
|
||||
customCss={component.customCss}
|
||||
/>
|
||||
{:else if component.type === 'mapcomponent'}
|
||||
<AppMap {...component} bind:staticOutputs={$staticOutputs[component.id]} />
|
||||
<AppMap
|
||||
configuration={component.configuration}
|
||||
id={component.id}
|
||||
customCss={component.customCss}
|
||||
bind:staticOutputs={$staticOutputs[component.id]}
|
||||
/>
|
||||
{:else if component.type === 'pdfcomponent'}
|
||||
<AppPdf
|
||||
configuration={component.configuration}
|
||||
id={component.id}
|
||||
customCss={component.customCss}
|
||||
bind:staticOutputs={$staticOutputs[component.id]}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -31,7 +31,8 @@ import {
|
||||
SidebarClose,
|
||||
MapPin,
|
||||
FlipHorizontal,
|
||||
FlipVertical
|
||||
FlipVertical,
|
||||
FileText
|
||||
} from 'lucide-svelte'
|
||||
import type { BaseAppComponent } from '../../types'
|
||||
|
||||
@@ -91,6 +92,7 @@ export type VerticalSplitPanesComponent = BaseComponent<'verticalsplitpanescompo
|
||||
export type HorizontalSplitPanesComponent = BaseComponent<'horizontalsplitpanescomponent'> & {
|
||||
panes: number[]
|
||||
}
|
||||
export type PdfComponent = BaseComponent<'pdfcomponent'>
|
||||
|
||||
export type AppComponent = BaseAppComponent &
|
||||
(
|
||||
@@ -129,6 +131,7 @@ export type AppComponent = BaseAppComponent &
|
||||
| MapComponent
|
||||
| VerticalSplitPanesComponent
|
||||
| HorizontalSplitPanesComponent
|
||||
| PdfComponent
|
||||
)
|
||||
|
||||
export type AppComponentDimensions = `${IntRange<
|
||||
@@ -912,9 +915,7 @@ Hello \${ctx.username}
|
||||
type: 'static',
|
||||
fieldType: 'array',
|
||||
subFieldType: 'text',
|
||||
value: [
|
||||
"Foo", "Bar"
|
||||
]
|
||||
value: ['Foo', 'Bar']
|
||||
},
|
||||
placeholder: {
|
||||
type: 'static',
|
||||
@@ -1528,5 +1529,35 @@ Hello \${ctx.username}
|
||||
panes: [50, 50],
|
||||
numberOfSubgrids: 2
|
||||
}
|
||||
},
|
||||
pdfcomponent: {
|
||||
name: 'PDF',
|
||||
icon: FileText,
|
||||
dims: '3:8-8:12',
|
||||
data: {
|
||||
id: '',
|
||||
type: 'pdfcomponent',
|
||||
componentInput: undefined,
|
||||
configuration: {
|
||||
source: {
|
||||
type: 'static',
|
||||
value: '/dummy.pdf',
|
||||
fieldType: 'text',
|
||||
fileUpload: {
|
||||
accept: 'application/pdf',
|
||||
convertTo: 'buffer'
|
||||
}
|
||||
},
|
||||
zoom: {
|
||||
fieldType: 'number',
|
||||
type: 'static',
|
||||
value: 100
|
||||
}
|
||||
},
|
||||
customCss: {
|
||||
container: { class: '', style: '' }
|
||||
} as const,
|
||||
card: false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ const display: ComponentSet = {
|
||||
'plotlycomponent',
|
||||
'scatterchartcomponent',
|
||||
'timeseriescomponent',
|
||||
'pdfcomponent',
|
||||
'displaycomponent'
|
||||
]
|
||||
} as const
|
||||
|
||||
@@ -184,7 +184,7 @@ export function removeItemAll<T>(arr: T[], value: T) {
|
||||
}
|
||||
|
||||
export async function isOwner(path: string, user: UserExt, workspace: string): Promise<boolean> {
|
||||
if (user.is_admin && ((workspace == 'starter' || workspace == 'admin') && user.is_super_admin)) {
|
||||
if (user.is_admin && (workspace == 'starter' || workspace == 'admin') && user.is_super_admin) {
|
||||
return true
|
||||
} else if (workspace == 'starter' || workspace == 'admin') {
|
||||
return false
|
||||
@@ -255,8 +255,8 @@ export function allTrue(dict: { [id: string]: boolean }): boolean {
|
||||
}
|
||||
|
||||
function subtractSeconds(date: Date, seconds: number): Date {
|
||||
date.setSeconds(date.getSeconds() - seconds);
|
||||
return date;
|
||||
date.setSeconds(date.getSeconds() - seconds)
|
||||
return date
|
||||
}
|
||||
|
||||
export function forLater(scheduledString: string): boolean {
|
||||
@@ -484,7 +484,7 @@ export function scriptPathToHref(path: string): string {
|
||||
export async function getScriptByPath(path: string): Promise<{
|
||||
content: string
|
||||
language: SupportedLanguage
|
||||
schema: any,
|
||||
schema: any
|
||||
description: string
|
||||
}> {
|
||||
if (path.startsWith('hub/')) {
|
||||
@@ -549,7 +549,6 @@ export async function loadHubFlows() {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function loadHubApps() {
|
||||
try {
|
||||
const apps = (await AppService.listHubApps()).apps ?? []
|
||||
@@ -583,7 +582,6 @@ export function flowToHubUrl(flow: Flow): URL {
|
||||
return url
|
||||
}
|
||||
|
||||
|
||||
export function appToHubUrl(staticApp: any): URL {
|
||||
const url = new URL('https://hub.windmill.dev/apps/add')
|
||||
url.searchParams.append('app', encodeState(staticApp))
|
||||
@@ -631,7 +629,9 @@ export function scriptLangToEditorLang(
|
||||
}
|
||||
|
||||
export async function copyToClipboard(value?: string, sendToast = true): Promise<boolean> {
|
||||
if (!value) { return false }
|
||||
if (!value) {
|
||||
return false
|
||||
}
|
||||
|
||||
let success = false
|
||||
if (navigator?.clipboard) {
|
||||
@@ -660,14 +660,39 @@ export function capitalize(word: string): string {
|
||||
}
|
||||
|
||||
export function addWhitespaceBeforeCapitals(word?: string): string {
|
||||
if (!word) { return '' }
|
||||
if (!word) {
|
||||
return ''
|
||||
}
|
||||
return word.replace(/([A-Z])/g, ' $1').trim()
|
||||
}
|
||||
|
||||
export function isCloudHosted(): boolean {
|
||||
return (get(page)?.url?.hostname == 'app.windmill.dev')
|
||||
return get(page)?.url?.hostname == 'app.windmill.dev'
|
||||
}
|
||||
|
||||
export function isObject(obj: any) {
|
||||
return typeof obj === 'object'
|
||||
}
|
||||
|
||||
export function debounce(func: (...args: any[]) => any, wait: number) {
|
||||
let timeout: any
|
||||
return function (...args: any[]) {
|
||||
// @ts-ignore
|
||||
const context = this
|
||||
clearTimeout(timeout)
|
||||
timeout = setTimeout(() => func.apply(context, args), wait)
|
||||
}
|
||||
}
|
||||
|
||||
export function throttle<T>(func: (...args: any[]) => T, wait: number) {
|
||||
let timeout: any
|
||||
return function (...args: any[]) {
|
||||
if (!timeout) {
|
||||
timeout = setTimeout(() => {
|
||||
timeout = null
|
||||
// @ts-ignore
|
||||
func.apply(this, args)
|
||||
}, wait)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
Reference in New Issue
Block a user