<template> <div class="cw-block cw-block-document"> <courseware-default-block :block="block" :canEdit="canEdit" :isTeacher="isTeacher" :preview="false" @showEdit="initCurrentData" @storeEdit="storeBlock" @closeEdit="initCurrentData" > <template #content> <div class="cw-pdf-main-container"> <template v-if="hasFile"> <div v-if="currentTitle !== ''" class="cw-block-title"> {{ currentTitle }} </div> <div class="cw-pdf-toolbar"> <div class="cw-pdf-toolbar-left"> <div class="cw-pdf-toc"> <button class="undecorated" :class="{ active: pdfTOCDisplay }" :title="$gettext('Inhaltsverzeichnis')" :aria-pressed="pdfTOCDisplay ? 'true' : 'false'" @click="toggleTOCViewer" > <studip-icon shape="table-of-contents" :role=" pdfTOC.length === 0 ? 'inactive' : pdfTOCDisplay ? 'info_alt' : 'clickable' " :size="18" class="text-bottom" /> </button> </div> <div class="cw-pdf-search-toggle-btn"> <button class="undecorated" :class="{ active: showPdfSearchBox }" :title="$gettext('Suche')" :aria-pressed="showPdfSearchBox ? 'true' : 'false'" @click="togglePdfSearchBox" > <studip-icon shape="search" :role="showPdfSearchBox ? 'info_alt' : 'clickable'" :size="18" class="text-bottom" /> </button> </div> <div class="cw-pdf-search-box" v-show="showPdfSearchBox"> <input ref="pdfSearchInput" type="text" v-model="pdfSearch" @change="doSearchInPdf" /> <div class="cw-pdf-search-navs" v-if="pdfSearchFoundNums > 1"> <button class="undecorated" @click="prevPdfSearch" :title="$gettext('Letzte')"> <studip-icon shape="arr_1left" :role="pdfSearchFoundSelectedIndex === 0 ? 'inactive' : 'clickable'" :size="18" class="text-bottom" /> </button> <button class="undecorated" @click="nextPdfSearch" :title="$gettext('Nächste')"> <studip-icon shape="arr_1right" :role=" pdfSearchFoundSelectedIndex === pdfSearchFoundNums - 1 ? 'inactive' : 'clickable' " :size="18" class="text-bottom" /> </button> </div> <span class="cw-pdf-search-num" v-if="pdfSearchFoundNums > 0"> {{ pdfSearchFoundSelectedIndex + 1 }} / {{ pdfSearchFoundNums }} {{ $gettext('Treffer') }} </span> </div> <div class="cw-pdf-page-nav"> <button class="undecorated" @click="prevPage" :title="$gettext('Eine Seite zurück')" > <studip-icon shape="arr_1up" :role="pageNum - 1 === 0 ? 'inactive' : 'clickable'" :size="18" class="text-bottom" /> </button> <button class="undecorated" @click="nextPage" :title="$gettext('Eine Seite vor')"> <studip-icon shape="arr_1down" :role="pageNum === pageCount ? 'inactive' : 'clickable'" :size="18" class="text-bottom" /> </button> <input type="text" ref="pageNumInput" class="cw-pdf-page-num" :aria-label="$gettext('Seite')" :value="pageNum" @change="updatePageNum" /> <span> {{ $gettext('von') }} {{ pageCount }} </span> </div> </div> <div class="cw-pdf-toolbar-middle"> <div class="cw-pdf-zoom-buttons"> <button class="undecorated" @click="zoomIn" :title="$gettext('Vergrößern')"> <studip-icon shape="add" :size="18" class="text-bottom" /> </button> <button class="undecorated" @click="zoomOut" :title="$gettext('Verkleinern')"> <studip-icon shape="remove" :size="18" class="text-bottom" /> </button> <select v-model="currentScale" :aria-label="$gettext('Zoom')" @change="updateZoom"> <option v-show="false" :value="currentScale">{{ formattedZoom }}%</option> <option v-for="(value, index) in scaleValues" :key="index" :value="value.scale"> {{ value.name }} </option> </select> </div> <div class="cw-pdf-rotate"> <button class="undecorated" @click="doRotatePdf" :title="$gettext('Drehen')"> <studip-icon shape="rotate-right" :size="18" class="text-bottom" /> </button> </div> </div> <div class="cw-pdf-toolbar-right"> <div class="cw-pdf-handtool"> <button class="undecorated" :class="{ active: pdfHandTool }" :title="$gettext('Hand-Werkzeug')" :aria-pressed="pdfHandTool ? 'true' : 'false'" @click="toggleHandTool" > <studip-icon shape="hand" :role="pdfHandTool ? 'info_alt' : 'clickable'" :size="18" class="text-bottom" /> </button> </div> <div class="cw-pdf-download"> <a v-if="downloadable === 'true'" :href="currentUrl" download :title="$gettext('Speichern')" > <studip-icon shape="download" :size="18" class="text-bottom" /> </a> </div> </div> </div> <div class="cw-pdf-outer-container" ref="outerContainer"> <div class="cw-pdf-content"> <span class="sr-only" aria-live="polite">{{ srMessage }}</span> <div class="cw-pdf-sidebar" v-show="pdfTOCDisplay"> <ul class="cw-pdf-toc-list"> <CoursewarePDFTableOfContent v-for="(item, index) in pdfTOC" :item="item" :key="index" @tocPageNav="tocPageNav" /> </ul> </div> <div ref="container" class="cw-pdf-viewer-container" :class="{ 'hand-cursor-grab': pdfHandTool, grabbing: pdfGrabbing, 'has-error': pdfError, }" v-dragscroll="pdfHandTool" @mousedown="handleMouseDown" @mouseup="handleMouseUp" > <div class="pdfViewer" /> </div> </div> <div v-show="pdfError" class="cw-pdf-error-page"> <courseware-companion-box mood="sad" :msgCompanion="$gettext('Es gab einen Fehler. Bitte versuchen Sie es erneut!')" > </courseware-companion-box> </div> <div ref="fakeContainer" class="cw-pdf-viewer-fake-container"> <div class="pdfViewer" /> </div> </div> </template> </div> </template> <template v-if="canEdit" #edit> <form class="default" @submit.prevent=""> <label> {{ $gettext('Überschrift') }} <input type="text" v-model="currentTitle" /> </label> <label> {{ $gettext('Datei') }} <studip-file-chooser v-model="currentFileId" selectable="file" :courseId="context.id" :userId="userId" :isDocument="true" :excludedCourseFolderTypes="excludedCourseFolderTypes" /> </label> <label> {{ $gettext('Download-Icon anzeigen') }} <select v-model="currentDownloadable"> <option value="true">{{ $gettext('Ja') }}</option> <option value="false">{{ $gettext('Nein') }}</option> </select> </label> <label> {{ $gettext('Dateityp') }} <select v-model="currentDocType"> <option value="pdf">PDF</option> </select> </label> </form> </template> <template #info> <p>{{ $gettext('Informationen zum Dokument-Block') }}</p> </template> </courseware-default-block> </div> </template> <script> import BlockComponents from './block-components.js'; import blockMixin from '@/vue/mixins/courseware/block.js'; import CoursewarePDFTableOfContent from './CoursewarePDFTableOfContent.vue'; import { getDocument } from 'pdfjs-dist'; import { DefaultAnnotationLayerFactory, DefaultTextLayerFactory, DefaultXfaLayerFactory, DefaultStructTreeLayerFactory, PDFFindController, PDFLinkService, PDFPageView, PDFViewer, EventBus, } from 'pdfjs-dist/web/pdf_viewer.js'; // pdfjsWorker must be imported! import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.entry'; import { dragscroll } from 'vue-dragscroll'; import { mapActions, mapGetters } from 'vuex'; import 'pdfjs-dist/web/pdf_viewer.css'; export default { name: 'courseware-document-block', mixins: [blockMixin], components: Object.assign(BlockComponents, { CoursewarePDFTableOfContent }), directives: { dragscroll, }, props: { block: Object, canEdit: Boolean, isTeacher: Boolean, }, data() { return { currentTitle: '', currentFileId: '', currentFile: {}, currentDownloadable: '', currentDocType: '', pdfError: false, pdfBasePage: null, pdfPage: null, pdfTextContent: null, pdfHandTool: false, pdfGrabbing: false, pdfTextLayer: null, pdfAnnotationLayer: null, pdfAnnotation: false, pdfRotate: 0, pdfViewer: null, pdfEventBus: null, pdfLinkService: null, pdfFindController: null, pdfDoc: null, pdfLoadingTask: null, pdfSearch: '', pdfSearchMatchesMapping: [], pdfSearchFoundNums: 0, pdfSearchFoundSelectedIndex: 0, pdfSearchHighlightedList: [], showPdfSearchBox: false, pdfTOC: [], pdfTOCDisplay: false, pageNum: 1, pageCount: 0, scale: 1, baseScale: 1, currentScale: 1, file: null, srMessage: '', }; }, computed: { ...mapGetters({ fileRefById: 'file-refs/byId', }), title() { return this.block?.attributes?.payload?.title; }, fileDownloadable() { return this.currentDownloadable === 'true'; }, downloadable() { return this.block?.attributes?.payload?.downloadable ?? 'true'; }, fileId() { return this.block?.attributes?.payload?.file_id; }, docType() { return this.block?.attributes?.payload?.doc_type; }, currentUrl() { if (this.currentFile?.meta) { return this.currentFile.meta['download-url']; } else { return ''; } }, hasFile() { return this.currentFileId !== ''; }, formattedZoom() { return Number.parseInt(this.scale * 100, 10); }, scaleValues() { const defaultValues = [ { name: '25%', scale: 0.25 }, { name: '50%', scale: 0.5 }, { name: '75%', scale: 0.75 }, { name: '100%', scale: 1.0 }, { name: '150%', scale: 1.5 }, { name: '200%', scale: 2.0 }, { name: '300%', scale: 3.0 }, ]; return defaultValues.concat([{ name: this.$gettext('volle Breite'), scale: this.baseScale }]); }, }, watch: { scale(newValue) { let overflow = newValue > this.baseScale ? 'auto' : 'hidden'; let container = this.$refs.container; container.style.overflow = overflow; this.currentScale = newValue; }, pageNum(newValue) { this.resetPdfViewer(); }, showPdfSearchBox() { this.resetPdfSearch(); }, currentFileId(newId) { if (newId) { this.currentFile = this.fileRefById({ id: newId }); } } }, mounted() { if (this.block.id) { this.loadFileRefs(this.block.id).then((response) => { this.file = response[0]; this.currentFile = this.file; this.initPdfTask(); }); } this.initCurrentData(); }, methods: { ...mapActions({ updateBlock: 'updateBlockInContainer', loadFileRefs: 'loadFileRefs', companionWarning: 'companionWarning', }), initCurrentData() { this.currentTitle = this.title; this.currentDownloadable = this.downloadable; this.currentFileId = this.fileId; this.currentDocType = this.docType; }, initPdfTask() { if (this.currentUrl) { let view = this; view.pdfEventBus = new EventBus(); view.pdfLoadingTask = getDocument(this.currentUrl).promise; view.pdfLoadingTask.__PDFDocumentLoadingTask = true; // Link Service view.pdfLinkService = new PDFLinkService({ eventBus: view.pdfEventBus, }); // Find Controller view.pdfFindController = new PDFFindController({ eventBus: view.pdfEventBus, linkService: view.pdfLinkService, }); // Annotation Layer view.pdfAnnotationLayer = new DefaultAnnotationLayerFactory(); // Text Layer view.pdfTextLayer = new DefaultTextLayerFactory(); // Load Pdf Document view.loadPdfDocument(); // Handle search results. view.pdfEventBus.on('updatetextlayermatches', ({ source, pageIndex }) => { if (view.pdfViewer.pdfPage._pageIndex == pageIndex) { setTimeout(() => { view.handleSearchMatches(); }, 260); } }); } }, loadPdfDocument() { if (this.pdfLoadingTask) { let view = this; view.pdfLoadingTask.then((pdfDocument) => { view.pdfDoc = pdfDocument; view.pageCount = pdfDocument.numPages; // get table of contents if any. view.loadPdfTOC(); // Rendering PDF viewer view.loadPdfViewer(); view.pdfLinkService.setDocument(view.pdfDoc, null); view.pdfFindController.setDocument(view.pdfDoc); }); } }, loadPdfTOC() { if (this.pdfDoc) { let view = this; view.pdfTOC = []; // Get the tree outline view.pdfDoc.getOutline().then((outline) => { if (outline) { view.pdfTOC = outline; } }); } }, loadPdfViewer() { if (this.pdfDoc) { let view = this; this.pdfError = false; let container = this.$refs.container; let outerContainer = this.$refs.outerContainer; let fakeContainer = this.$refs.fakeContainer; this.pdfDoc .getPage(parseInt(view.pageNum)) .then((pdfPage) => { view.pdfPage = pdfPage; const width = outerContainer.offsetWidth; view.baseScale = (width / pdfPage.view[2] / 1.33).toFixed(2); view.scale = view.baseScale; // Creating the page view with default parameters. let defaultViewport = pdfPage.getViewport({ scale: 1.0, }); view.pdfBasePage = new PDFViewer({ container: fakeContainer, eventBus: view.pdfEventBus, findController: view.pdfFindController, }); let pdfPageViewOptions = { container: container, id: view.pageNum, scale: view.scale, defaultViewport: defaultViewport, eventBus: view.pdfEventBus, findController: view.pdfFindController, textHighlighterFactory: view.pdfBasePage, xfaLayerFactory: view.pdfDoc.isPureXfa ? new DefaultXfaLayerFactory() : null, structTreeLayerFactory: new DefaultStructTreeLayerFactory(), }; if (view.pdfHandTool === false) { pdfPageViewOptions.textLayerFactory = view.pdfTextLayer; pdfPageViewOptions.annotationLayerFactory = view.pdfAnnotationLayer; } else { pdfPageViewOptions.textLayerMode = 0; pdfPageViewOptions.annotationMode = 0; } // Force annotation to be disabled. if (!this.pdfAnnotation && pdfPageViewOptions?.annotationLayerFactory) { pdfPageViewOptions.annotationLayerFactory = null; pdfPageViewOptions.annotationMode = 0; } view.pdfViewer = new PDFPageView(pdfPageViewOptions); // Associates the actual page with the view, and drawing it view.pdfViewer.setPdfPage(view.pdfPage); // Set LinkService viewer view.pdfLinkService.setViewer(view.pdfViewer); view.renderPage(); }) .catch((err) => { console.log(err); outerContainer.style.minHeight = '350px'; view.pdfError = true; }); } }, renderPage() { if (this.pdfViewer) { this.updatePdfViewer(); this.pdfViewer.draw(); if (!this.pdfHandTool) { this.pdfViewer.textLayer.findController = this.pdfFindController; } if (this.pdfPage) { this.pdfPage.getTextContent().then((textContent) => { this.pdfTextContent = textContent; }); } if (this.pdfSearchMatchesMapping.length) { this.pdfSearchDisplayHandler(); } } }, resetPdfViewer() { this.pdfViewer.destroy(); let container = this.$refs.container; while (!container.lastChild.classList.contains('pdfViewer')) { container.removeChild(container.lastChild); } this.loadPdfViewer(); }, updatePdfViewer(resetScale = false) { let updateArgs = { scale: resetScale ? 1 : this.scale, rotation: this.pdfRotate, }; this.pdfViewer.update(updateArgs); }, prevPage() { if (this.pageNum <= 1) { return; } this.pageNum--; }, nextPage() { if (this.pageNum >= this.pdfDoc.numPages) { return; } this.pageNum++; }, goToPage(page) { const pageNum = Number.parseInt(page, 10); if (pageNum < 1 || pageNum > this.pdfDoc.numPages) { return; } this.pageNum = pageNum; }, tocPageNav(dest) { let view = this; let destObj = dest.find( (ref) => typeof ref === 'object' && ref !== null && Number.isInteger(ref.num) && ref.num >= 0 && Number.isInteger(ref.gen) && ref.gen >= 0 ); if (destObj) { view.pdfDoc.getPageIndex(destObj).then((pageIndex) => { view.goToPage(pageIndex + 1); }); } }, updatePageNum() { let pageNumInput = this.$refs.pageNumInput; let value = Number.parseInt(pageNumInput.value, 10); if (Number.isInteger(value) && value > 0 && value <= Number.parseInt(this.pageCount, 10)) { this.pageNum = value; } else { pageNumInput.value = this.pageNum; } }, doRotatePdf() { let rotationDegs = [0, 90, 180, 270, 360]; let index = rotationDegs.indexOf(this.pdfRotate); let nextIndex = index + 1 >= rotationDegs.length ? 0 : index + 1; let nextDeg = rotationDegs[nextIndex]; this.pdfRotate = nextDeg; this.renderPage(); this.updateSrMessage(this.$gettext('gedreht')); }, zoomIn() { this.scale = this.scale < 4 ? ((this.scale * 10 + 1) / 10).toFixed(1) : this.scale; this.renderPage(); this.updateSrMessage(this.$gettext('vergrößert')); }, zoomOut() { this.scale = this.scale > 0.1 ? ((this.scale * 10 - 1) / 10).toFixed(1) : this.scale; this.renderPage(); this.updateSrMessage(this.$gettext('verkleinert')); }, updateZoom(e) { const value = e.target.value; if (this.scale === value) { return; } this.scale = value; this.renderPage(); this.updateSrMessage(this.$gettext('Zoom Stufe ausgweählt')); }, toggleHandTool() { this.pdfHandTool = !this.pdfHandTool; this.resetPdfViewer(); this.showPdfSearchBox = false; }, handleHandToolDisplay(event) { this.pdfGrabbing = event.type === 'mousedown'; }, handleMouseDown(e) { this.handleHandToolDisplay(e); }, handleMouseUp(e) { this.handleHandToolDisplay(e); }, togglePdfSearchBox() { this.showPdfSearchBox = this.pdfHandTool ? false : !this.showPdfSearchBox; if (this.showPdfSearchBox) { this.$nextTick(() => { this.$refs.pdfSearchInput.focus(); }); } }, handleSearchMatches() { let view = this; let allMatches = view.pdfFindController.pageMatches; let totalMatches = 0; let searchSelectIndex = 0; let matchesPageCount = 0; view.pdfSearchMatchesMapping = []; for (let pageIndex = 0; pageIndex < view.pageCount; pageIndex++) { let pageNum = pageIndex + 1; let pageMatches = allMatches[pageIndex]; totalMatches += pageMatches.length; if (pageMatches.length) { matchesPageCount++; } for (let i in pageMatches) { let matchIndex = parseInt(i, 10); let mappingObj = { selectIndex: searchSelectIndex, matchIndex: matchIndex, pageNum: pageNum, }; view.pdfSearchMatchesMapping.push(mappingObj); searchSelectIndex++; } } // Find next match if there the current page has nothing. if ( view.pdfSearchFoundSelectedIndex === 0 && view.pdfViewer.pdfPage._pageIndex > 0 && matchesPageCount > 0 ) { let nextMapped = view.pdfSearchMatchesMapping.filter( (map) => map.pageNum >= view.pdfViewer.pdfPage._pageIndex + 1 ); if (nextMapped.length) { view.pdfSearchFoundSelectedIndex = nextMapped[0].selectIndex; } } view.pdfSearchFoundNums = totalMatches; view.pdfSearchDisplayHandler(); }, doSearchInPdf() { let findObj = { type: '', query: this.pdfSearch, phraseSearch: true, caseSensitive: false, entireWord: true, highlightAll: true, findPrevious: false, matchDiacritics: false, }; this.pdfEventBus.dispatch('find', findObj); }, prevPdfSearch() { if (this.pdfSearchFoundSelectedIndex === 0) { return; } this.pdfSearchFoundSelectedIndex--; this.pdfSearchDisplayHandler(); }, nextPdfSearch() { if (this.pdfSearchFoundSelectedIndex === this.pdfSearchFoundNums - 1) { return; } this.pdfSearchFoundSelectedIndex++; this.pdfSearchDisplayHandler(); }, pdfSearchDisplayHandler() { // Go to page based on selected index. let pageMatches = this.pdfSearchMatchesMapping.filter( (map) => map.selectIndex === this.pdfSearchFoundSelectedIndex ); if (pageMatches.length) { let matchObj = pageMatches[0]; // A timeout of > 250ms is needed when page is changed! let highlightRenderTimeout = 0; if (matchObj.pageNum !== this.pageNum) { this.goToPage(matchObj.pageNum); highlightRenderTimeout = 260; } setTimeout(() => { this.setPdfSearchHighlighted(); this.scrollToSearchFounds(matchObj.matchIndex); }, highlightRenderTimeout); } }, scrollToSearchFounds(matchIndex) { if (this.pdfSearchHighlightedList?.length) { let selectedSpan = this.pdfSearchHighlightedList[matchIndex]; if (selectedSpan) { selectedSpan.classList.add('selected'); selectedSpan.scrollIntoView({ behavior: 'smooth', block: 'center' }); } } }, setPdfSearchHighlighted() { if (this.pdfViewer?.textLayer?.textDivs) { let textDivs = this.pdfViewer.textLayer.textDivs; let highlightedSpans = []; for (let textSpan of textDivs) { if (textSpan?.children) { let children = [...textSpan.children]; for (let child of children) { if (child.nodeName == 'SPAN' && child.classList.contains('highlight')) { child.classList.remove('selected'); highlightedSpans.push(child); } } } } // Sort the array based on the top of the span. highlightedSpans.sort((current, next) => { let currentTop = parseInt(current.parentNode.style.top, 10); let nextTop = parseInt(next.parentNode.style.top, 10); return currentTop > nextTop; }); this.pdfSearchHighlightedList = highlightedSpans; } }, resetPdfSearch() { this.pdfSearch = ''; this.pdfSearchFoundNums = 0; this.pdfSearchFoundSelectedIndex = 0; this.pdfSearchHighlightedList = []; this.pdfSearchMatchesMapping = []; this.doSearchInPdf(); }, toggleTOCViewer() { if (this.pdfTOC.length) { this.pdfTOCDisplay = !this.pdfTOCDisplay; } else { this.pdfTOCDisplay = false; } }, storeBlock() { if (this.currentFile === undefined) { this.companionWarning({ info: this.$gettext('Bitte wählen Sie eine Datei aus.'), }); return false; } else { let attributes = {}; attributes.payload = {}; attributes.payload.title = this.currentTitle; attributes.payload.file_id = this.currentFile.id; attributes.payload.downloadable = this.currentDownloadable.toString(); attributes.payload.doc_type = this.currentDocType; this.updateBlock({ attributes: attributes, blockId: this.block.id, containerId: this.block.relationships.container.data.id, }); } }, updateSrMessage(message) { this.srMessage = ''; this.srMessage = message; }, }, }; </script> <style scoped lang="scss"> @import '../../../../assets/stylesheets/scss/courseware/blocks/document.scss'; </style>