app/javascript/vue/components/pdf/pdfViewer.vue
<template>
<div
data-panel-name="pdfviewer"
data-panel-open="false"
class="slide-panel slide-document"
:style="styleWidth"
>
<div class="slide-panel-header flex-separate">
<span>PDF Document viewer</span>
<a
v-if="state.documentUrl"
class="margin-medium-right"
:href="state.documentUrl"
:data-pdf-source-id="state.sourceId"
download
>
Download
</a>
</div>
<resize-handle
side="left"
:size="state.width"
@resize="setWidth"
/>
<div>
<div class="toolbar-pdf">
<button
id="prevbutton"
type="button"
:disabled="page <= 1"
@click="setPage(showPage - 1)"
/>
<button
id="nextbutton"
type="button"
:disabled="page >= state.numPages"
@click="setPage(showPage + 1)"
/>
<button
id="zoominbutton"
type="button"
@click="setScale(state.scale + 1)"
/>
<button
id="zoomoutbutton"
type="button"
@click="setScale(state.scale - 1)"
/>
<input
type="number"
class="toolbarField pageNumber"
title="Page"
:value="state.displayPage"
:min="1"
:max="state.numPages"
@keydown.enter="setPage(Number($event.target.value))"
/>
<span
id="numPages"
class="toolbarLabel"
/>
</div>
<div
class="slide-panel-content"
id="pdfViewerContainer"
>
<div
id="viewer"
class="pdfViewer"
>
<template v-if="state.pdfDocument">
<pdf-viewer
v-for="i in state.numPages"
:key="i"
:src="state.pdfDocument"
:id="i"
:page="i"
:scale="state.scale"
>
<template #loading> Loading content here... </template>
</pdf-viewer>
</template>
<h2
v-else
id="pdfEmptyMessage"
>
Select a document from Pinboard
</h2>
</div>
</div>
<div class="slide-panel-circle-icon">
<div class="slide-panel-description">PDF Document viewer</div>
</div>
</div>
</div>
</template>
<script setup>
import PdfViewer from './components/pdfComponent'
import ResizeHandle from '../resizeHandle'
import IndexedDBStorage from '@/storage/indexddb.js'
import { ajaxCall } from '@/helpers'
import { getCurrentProjectId } from '@/helpers/project.js'
import { getCurrentUserId } from '@/helpers/user.js'
import { blobToArrayBuffer } from '@/helpers/files.js'
import { createLoadingTask } from './components/pdfLibraryComponents'
import {
computed,
watch,
reactive,
onMounted,
onUnmounted,
nextTick,
ref
} from 'vue'
const styleWidth = computed(() => ({
width: state.width !== 400 ? `${state.width}px` : ''
}))
const showPage = computed({
get() {
return state.displayPage
},
set(value) {
state.displayPage = Number(value)
page.value = Number(value)
}
})
const state = reactive({
displayPage: 1,
numPages: 0,
pdfDocument: undefined,
errors: [],
scale: 1,
eventLoadPDFName: 'pdfViewer:load',
width: 400,
cursorPosition: undefined,
noTrigger: false,
checkScroll: undefined,
documentUrl: undefined,
loadingPdf: false,
sourceId: undefined
})
const page = ref(1)
const textCopy = ref('')
const viewerActive = ref(false)
onMounted(() => {
eventListeners()
isOpenInStorage()
document.addEventListener('turbolinks:load', (_) => {
document.removeEventListener(state.eventLoadPDFName, handlePdfLoadEvent)
})
})
onUnmounted(() => {
document.removeEventListener('mouseover', loadPDF)
state.pdfDocument?.destroy()
})
watch(page, (p) => {
if (state.noTrigger) {
state.noTrigger = false
} else {
if (p > 0 && p <= state.numPages) {
const containerPosition =
Math.abs(document.querySelector('#viewer').getBoundingClientRect().y) +
120
const currentPage = document.getElementById(p)
const nextPageElement = document.getElementById(p + 1)
if (
containerPosition <= findPos(currentPage) ||
p === 1 ||
(nextPageElement && containerPosition >= findPos(nextPageElement))
) {
currentPage.scrollIntoView()
}
}
}
})
watch(textCopy, (newVal) => {
document
.querySelector('[data-panel-name="pinboard"]')
.setAttribute('data-clipboard', newVal)
})
watch(viewerActive, async (newVal) => {
const pdfStored = await IndexedDBStorage.get('Pdf', getUserAndProjectIds())
if (pdfStored) {
pdfStored.isOpen = newVal
IndexedDBStorage.put('Pdf', pdfStored)
}
})
const isOpenInStorage = async () => {
const pdfStored = await IndexedDBStorage.get('Pdf', getUserAndProjectIds())
if (pdfStored?.isOpen) {
getPdf(pdfStored.url)
openPanel()
}
}
const setWidth = (width) => {
if (width < window.innerWidth) {
state.width = width
}
}
const setPage = (value) => {
if (value > state.numPages) {
showPage.value = state.numPages
} else if (value < 1) {
showPage.value = 1
} else {
showPage.value = value
}
}
const setScale = (value) => {
state.scale = value > 0 ? value : 1
}
async function getPdf(url) {
const pdfStored = await IndexedDBStorage.get('Pdf', getUserAndProjectIds())
const isAlreadyStored = pdfStored?.url === url
let pdfBuffer
try {
pdfBuffer = isAlreadyStored ? pdfStored.pdfBuffer : await downloadPdf(url)
} catch (e) {
TW.workbench.alert.create(e, 'error')
return
}
state.documentUrl = url
state.loadingPdf = true
if (!isAlreadyStored) {
savePdfInStorage(url, pdfBuffer, true)
}
state.pdfDocument?.destroy()
state.pdfDocument = createLoadingTask({ data: pdfBuffer })
state.pdfDocument.then((pdf) => {
state.loadingPdf = false
state.numPages = pdf.numPages
const changePage = (_) => {
const count = Number(state.numPages)
let i = 1
if (count > 1) {
const containerPosition =
Math.abs(
document.querySelector('#viewer').getBoundingClientRect().y
) + 120
do {
const currentElement = document.getElementById(i)
const nextElement = document.getElementById(i + 1)
if (
containerPosition >= findPos(currentElement) &&
containerPosition <= findPos(nextElement)
) {
state.displayPage = i
}
i++
} while (i < count)
if (containerPosition >= findPos(document.getElementById(i))) {
state.displayPage = i
}
}
}
document.querySelector('#pdfViewerContainer').onscroll = changePage
})
}
const findPos = (obj) => obj.offsetTop
const getUserAndProjectIds = () => {
const userId = getCurrentUserId()
const projectId = getCurrentProjectId()
return `${userId}-${projectId}`
}
const savePdfInStorage = (url, pdfBuffer, isOpen) => {
IndexedDBStorage.put('Pdf', {
userAndProjectId: getUserAndProjectIds(),
url,
pdfBuffer: structuredClone(pdfBuffer),
isOpen
})
}
const openPanel = () => {
viewerActive.value = true
TW.views.shared.slideout.closePanel('pinboard')
TW.views.shared.slideout.openPanel('pdfviewer')
}
const eventListeners = () => {
document.addEventListener(state.eventLoadPDFName, handlePdfLoadEvent)
document.addEventListener('onSlidePanelClose', (event) => {
if (event.detail.name === 'pdfviewer') {
setWidth(400)
viewerActive.value = false
}
})
document.addEventListener('onSlidePanelOpen', (event) => {
if (event.detail.name === 'pdfviewer') {
viewerActive.value = true
}
})
document.body.addEventListener('click', (event) => {
const name = event.target.nodeName
if (name === 'INPUT' || name === 'TEXTAREA') {
if (viewerActive.value) {
if (event.target.selectionStart === event.target.selectionEnd) {
state.cursorPosition = event.target.selectionStart
}
}
}
})
document.querySelector('#viewer').addEventListener('mouseup', () => {
textCopy.value = getSelectedText()
})
document.addEventListener('dblclick', (event) => {
const name = event.target.nodeName
if (name === 'INPUT' || name === 'TEXTAREA') {
if (viewerActive.value) {
const inputText = event.target.value
event.target.value = insertStringInPosition(
inputText,
textCopy.value,
state.cursorPosition
)
}
}
})
}
const handlePdfLoadEvent = (event) => {
loadPDF(event)
openPanel()
}
const getSelectedText = () => {
if (window.getSelection) {
return window.getSelection().toString()
} else if (document.selection) {
return document.selection.createRange().text
}
return ''
}
const loadPDF = (event) => {
if (state.loadingPdf) return
showPage.value = 1
state.numPages = 0
state.pdfDocument = undefined
state.sourceId = event.detail.sourceId
nextTick(() => {
getPdf(event.detail.url)
})
}
async function downloadPdf(url) {
try {
const { body } = await ajaxCall('get', url, { responseType: 'blob' })
return await blobToArrayBuffer(body)
} catch (error) {
if (error.request.status === 404) {
throw new Error('PDF file not found.')
} else {
throw new Error(error)
}
}
}
</script>
<script>
export default {
name: 'PdfSlideout'
}
</script>