feat: catalog rotate

master
丁锐 2021-12-03 12:10:47 +08:00
parent e27e783b17
commit c97ed8e9f2
7 changed files with 341 additions and 99 deletions

File diff suppressed because one or more lines are too long

View File

@ -1,6 +1,6 @@
<template> <template>
<div class="pdf-viewer" @contextmenu="handlePreventDefault"> <div class="pdf-viewer" @contextmenu="handlePreventDefault">
<div class="pdf-viewer__header"> <div class="pdf-viewer__header" :class="{ 'not-ready': !isReady }">
<ViewerPageSelector <ViewerPageSelector
:total="total" :total="total"
:page.sync="page" :page.sync="page"
@ -9,6 +9,7 @@
:rotate.sync="rotate" :rotate.sync="rotate"
:isFullpage="isFullpage" :isFullpage="isFullpage"
:filename="filename" :filename="filename"
:isReady="isReady"
@toggleFullpage="handleToggleFullpage" @toggleFullpage="handleToggleFullpage"
@update:zoom="handleUpdateZoom" @update:zoom="handleUpdateZoom"
@toggleCatalog="handleToggleCatalog" @toggleCatalog="handleToggleCatalog"
@ -18,9 +19,16 @@
</div> </div>
<div class="pdf-viewer__body"> <div class="pdf-viewer__body">
<div class="loading-mask" v-if="isLoading"> <div class="loading-mask" v-if="isLoading || isRendering">
<slot name="loading"> <slot name="loading">
<div class="loading-content">Loading {{dotText}}</div> <div class="loading-content" v-if="isLoading">
{{ loadingContent }}
</div>
</slot>
<slot name="rendering">
<div class="rendering-content" v-if="isRendering">
{{ renderingContent }}
</div>
</slot> </slot>
</div> </div>
<Viewer <Viewer
@ -36,6 +44,7 @@
:filename.sync="filename" :filename.sync="filename"
@update:zoom="handleUpdateZoom" @update:zoom="handleUpdateZoom"
@update:isLoading="handleUpdateLoadingState" @update:isLoading="handleUpdateLoadingState"
@update:isRendering="handleUpdateRenderingState"
@password-requested="handlePasswordRequest" @password-requested="handlePasswordRequest"
@loaded="handleLoaded" @loaded="handleLoaded"
@loading-failed="handleLoadingFailed" @loading-failed="handleLoadingFailed"
@ -83,6 +92,7 @@ export default {
data() { data() {
return { return {
isLoading: true, isLoading: true,
isRendering: false,
page: 1, page: 1,
total: 1, total: 1,
catalogVisible: true, catalogVisible: true,
@ -94,8 +104,20 @@ export default {
} }
}, },
computed: { computed: {
isReady() {
return !this.isLoading && !this.isRendering
},
status() {
return [this.isLoading, this.isRendering]
},
loadingContent() {
return `${this.loadingText || 'Loading'} ${this.dotText}`
},
renderingContent() {
return `${this.renderingText || 'Rendering'} ${this.dotText}`
},
dotText() { dotText() {
const len = this.seconds % 3 + 1 const len = (this.seconds % 3) + 1
const dot = '.' const dot = '.'
return dot.repeat(len) return dot.repeat(len)
@ -106,9 +128,36 @@ export default {
} }
}, },
}, },
watch: {
status: {
handler(n) {
const [isLoading, isRendering] = n
const startTimer = () => {
this._timer && clearInterval(this._timer)
this.seconds = 0
this._timer = setInterval(() => {
this.seconds += 1
}, 500)
}
if (isLoading) {
if (this.$slots.loading) return
startTimer()
} else if (isRendering) {
if (this.$slots.rendering) return
startTimer()
}
},
},
deep: true,
},
methods: { methods: {
handleDownload() { handleDownload() {
this.$emit('download', this.source) this.$emit('download', {
src: this.source,
filename: this.filename,
})
}, },
handlePrint() { handlePrint() {
this.$refs.viewer.print() this.$refs.viewer.print()
@ -134,22 +183,16 @@ export default {
this.total = total this.total = total
this.$emit('loaded', params) this.$emit('loaded', params)
this.isLoading = false
}, },
handleDocumentRender() { handleDocumentRender() {
this.isLoading = false
this.$emit('rendered') this.$emit('rendered')
}, },
handleUpdateRenderingState(isRendering) {
this.isRendering = isRendering
},
handleUpdateLoadingState(isLoading) { handleUpdateLoadingState(isLoading) {
this.isLoading = isLoading this.isLoading = isLoading
if (this.$slots.loading) return
this._timer && clearInterval(this._timer)
if (isLoading) {
this.seconds = 0
this._timer = setInterval(() => {
this.seconds += 1
}, 500)
}
}, },
handlePasswordRequest({ callback, retry }) { handlePasswordRequest({ callback, retry }) {
// TODO: slot dialog ? // TODO: slot dialog ?
@ -207,6 +250,17 @@ export default {
padding: 0 16px; padding: 0 16px;
box-shadow: 0px 3px 10px 2px black; box-shadow: 0px 3px 10px 2px black;
z-index: 999; z-index: 999;
&.not-ready {
position: relative;
&::after {
content: '';
height: 100%;
width: 100%;
display: inline-block;
position: absolute;
z-index: 1;
}
}
} }
&__body { &__body {
@ -222,7 +276,9 @@ export default {
height: 100%; height: 100%;
width: 100%; width: 100%;
pointer-events: none; pointer-events: none;
.loading-content { background: #a9a9a9;
.loading-content,
.rendering-content {
height: 100%; height: 100%;
width: 100%; width: 100%;
display: flex; display: flex;

View File

@ -0,0 +1,15 @@
.rotate-wrapper {
display: inline-block;
position: relative;
width: 100%;
.image {
position: absolute;
width: 100%;
}
&::after {
content: '';
width: var(--content-width);
height: var(--content-height);
display: inline-block;
}
}

View File

@ -0,0 +1,79 @@
<template>
<div class="rotate-wrapper" :style="loaded && contentStyle">
<!-- TODO: slot content (MutationObserver) -->
<img
v-if="src"
class="image"
ref="img"
:src="src"
:style="imageStyle"
@load="handleLoaded"
/>
</div>
</template>
<script>
export default {
name: 'RotateWrapper',
props: {
src: String,
rotateDeg: Number,
duration: {
type: Number,
default: 0,
},
},
data() {
return {
loaded: false,
imageRect: {},
}
},
computed: {
contentStyle() {
return {
'--content-width': `${this.imageRect.width}px`,
'--content-height': `${this.imageRect.height}px`,
}
},
imageStyle() {
return {
transform: `rotate(${this.rotateDeg}deg)`,
transitionDuration: `${this.duration}ms`,
marginTop:
this.imageRect.width > this.imageRect.height
? `-${(this.imageRect.width - this.imageRect.height) / 2}px`
: 0,
}
},
},
watch: {
async rotateDeg() {
await this.$nextTick()
this._timer = setInterval(() => {
this.updateImageRect()
}, 20)
setTimeout(() => {
clearInterval(this._timer)
}, this.duration + 20)
},
},
methods: {
handleLoaded() {
this.loaded = true
this.updateImageRect()
},
updateImageRect() {
const { width, height } = this.$refs.img.getBoundingClientRect()
this.imageRect = {
width,
height,
}
},
},
}
</script>
<style lang="scss" scoped>
@import './RotateWrapper.scss';
</style>

View File

@ -17,7 +17,7 @@
&.visible { &.visible {
margin-left: 0; margin-left: 0;
} }
.catalog-content { .catalog-container {
padding-bottom: 20px; padding-bottom: 20px;
.catalog-item { .catalog-item {
color: white; color: white;
@ -34,14 +34,18 @@
height: 14px; height: 14px;
line-height: 14px; line-height: 14px;
} }
.canvas { &__content {
* {
width: 110px; width: 110px;
// height: 152px; // height: 152px;
opacity: 0.8; opacity: 0.8;
transform: var(--item-transform);
}
&.active { &.active {
img {
box-shadow: 0 0 0 4px rgb(84, 201, 255); box-shadow: 0 0 0 4px rgb(84, 201, 255);
opacity: 1; opacity: 1;
}
&:hover { &:hover {
opacity: 1; opacity: 1;
} }
@ -49,6 +53,8 @@
} }
} }
} }
}
}
&.viewer { &.viewer {
flex: 1; flex: 1;
.viewer-content { .viewer-content {
@ -56,20 +62,25 @@
.viewer-item { .viewer-item {
padding-bottom: 20px; padding-bottom: 20px;
font-size: 0; font-size: 0;
display: flex; // display: flex;
justify-content: center; // justify-content: center;
align-items: center; // align-items: center;
width: 100%; width: 100%;
page-break-after: always; page-break-after: always;
.canvas { > div {
transition: transform 300ms ease;
flex-shrink: 0;
box-shadow: 0px 0px 7px 6px rgba($color: #000000, $alpha: 0.25);
margin: 0 auto;
.placeholder {
width: 100%;
white-space: nowrap; white-space: nowrap;
// margin: 0 auto; // margin: 0 auto;
// display: block; // display: block;
margin: initial; margin: initial;
max-width: initial; max-width: initial;
// margin-bottom: 20px; // margin-bottom: 20px;
box-shadow: 0px 0px 7px 6px rgba($color: #000000, $alpha: 0.25); }
transition: transform 300ms ease;
} }
} }
} }
@ -96,7 +107,11 @@
.viewer-item { .viewer-item {
page-break-after: always!important; page-break-after: always!important;
padding: 0!important; padding: 0!important;
.canvas { > div {
width: 100% !important;
transform: rotate(0)!important;
box-shadow: none!important;
.placeholder {
width: 100% !important; width: 100% !important;
box-shadow: none!important; box-shadow: none!important;
} }
@ -104,6 +119,7 @@
} }
} }
} }
}
} }
@media screen and (max-width: 800px) { @media screen and (max-width: 800px) {
.viewer-container { .viewer-container {

View File

@ -1,5 +1,5 @@
<template> <template>
<IFrame :css="$options.style" ref="iframe"> <IFrame :css="$options.viewerStyle" ref="iframe">
<div class="viewer-container" ref="container"> <div class="viewer-container" ref="container">
<div <div
class="scroller catalog" class="scroller catalog"
@ -8,21 +8,22 @@
visible: catalogVisible, visible: catalogVisible,
}" }"
> >
<div class="catalog-content" ref="catalogContent"> <div class="catalog-container">
<div <div
ref="catalogItem" ref="catalogItem"
v-for="page in pages" v-for="page in pages"
class="catalog-item" class="catalog-item"
:key="page" :key="page"
> >
<div class="test" :style="thumbnailItemStyle"> <div>
<canvas <div
ref="catalogCanvas" ref="catalogItemContent"
class="canvas" class="catalog-item__content"
:class="activePage === page && 'active'" :class="activePage === page && 'active'"
:style="thumbnailStyle"
@click="handleSwitchPage(page)" @click="handleSwitchPage(page)"
/> >
<RotateWrapper :src="imageList[page - 1]" :rotateDeg="rotate" />
</div>
</div> </div>
<div class="catalog-index"> <div class="catalog-index">
{{ page }} {{ page }}
@ -38,12 +39,22 @@
> >
<div class="viewer-content" ref="viewerContent"> <div class="viewer-content" ref="viewerContent">
<div <div
ref="viewerItem"
v-for="page in pages" v-for="page in pages"
:key="page" :key="page"
class="viewer-item" class="viewer-item"
:style="viewerItemStyle"
> >
<canvas ref="viewerCanvas" class="canvas" :style="viewerStyle" /> <div :style="viewerStyle">
<span class="placeholder"></span>
</div>
<!-- TODO: use rotate wrapper -->
<!-- <RotateWrapper
:src="viewerImageList[page - 1]"
:rotateDeg="rotate"
:style="viewerStyle"
:duration="200"
/> -->
</div> </div>
</div> </div>
</div> </div>
@ -56,13 +67,22 @@ import * as PDF from 'pdfjs-dist/es5/build/pdf.js'
import PDFWorker from 'pdfjs-dist/es5/build/pdf.worker.js' import PDFWorker from 'pdfjs-dist/es5/build/pdf.worker.js'
import IFrame from '../IFrame/IFrame.vue' import IFrame from '../IFrame/IFrame.vue'
import throttle from '../../utils/throttle' import throttle from '../../utils/throttle'
import style from '!!css-loader!!sass-loader!./Viewer.scss' import viewerStyle from '!!css-loader!!sass-loader!./Viewer.scss'
import getPageBlobList from './getPageBlobList.js'
import RotateWrapper from '../RotateWrapper/RotateWrapper.vue'
import rotateWrapperStyle from '!!css-loader!!sass-loader!../RotateWrapper/RotateWrapper.scss'
PDF.GlobalWorkerOptions.workerPort = new PDFWorker() PDF.GlobalWorkerOptions.workerPort = new PDFWorker()
const MARGIN_OFFSET = 20 const MARGIN_OFFSET = 20
const NORMAL_RATIO = 2 const NORMAL_RATIO = 2
const replacePlaceholder = (container, target) => {
const placeholder = container.querySelector('.placeholder')
placeholder.replaceWith(target)
}
export default { export default {
name: 'Viewer', name: 'Viewer',
props: { props: {
@ -77,9 +97,11 @@ export default {
required: true, required: true,
}, },
}, },
style: style.toString(), viewerStyle: viewerStyle.toString(),
rotateWrapperStyle: rotateWrapperStyle.toString(),
components: { components: {
IFrame, IFrame,
RotateWrapper,
}, },
data() { data() {
return { return {
@ -87,6 +109,8 @@ export default {
viewerContentHeight: 0, viewerContentHeight: 0,
viewportHeight: 0, viewportHeight: 0,
viewportWidth: 0, viewportWidth: 0,
imageList: [],
viewerImageList: [],
} }
}, },
computed: { computed: {
@ -124,11 +148,6 @@ export default {
} }
: {} : {}
}, },
thumbnailStyle() {
return {
transform: `rotate(${this.rotate}deg)`,
}
},
}, },
watch: { watch: {
isFullpage(n, o) { isFullpage(n, o) {
@ -163,6 +182,7 @@ export default {
this.render() this.render()
}, },
mounted() { mounted() {
this.$refs.iframe.appendStyle(this.$options.rotateWrapperStyle)
// TODO: element resize replace window resize with Observe // TODO: element resize replace window resize with Observe
this.handleResize = throttle(() => { this.handleResize = throttle(() => {
this.viewportHeight = this.$refs.container.clientHeight this.viewportHeight = this.$refs.container.clientHeight
@ -260,47 +280,43 @@ export default {
return return
} }
try { try {
this.$emit('update:isRendering', true)
await this.$nextTick() await this.$nextTick()
await Promise.all( const blobs = await getPageBlobList(this.pages, this.pdf)
this.pages.map(async (pageNum, i) => {
const page = await this.pdf.getPage(pageNum)
const pageWidth = page.view[2]
const containerWidth = this.$el.clientWidth
const targetWidth = containerWidth * 0.9
const scale = targetWidth / pageWidth
// const scale = Math.ceil(this.$el.clientWidth / page.view[2]) + 1
const viewport = page.getViewport({ const getImage = blobData => {
scale: scale, const image = document.createElement('img')
}) image.className = 'placeholder'
// render viewer image.src = URL.createObjectURL(blobData.blob)
const viewerCanvas = this.$refs.viewerCanvas[i]
viewerCanvas.width = viewport.width
viewerCanvas.height = viewport.height
const renderViewer = page.render({ return image
canvasContext: viewerCanvas.getContext('2d'), }
viewport,
}).promise
// render catalog this.imageList = []
const catalogScale = 110 / pageWidth const promiseList = blobs.map((blobData, idx) => {
const catalogViewport = page.getViewport({ const image = getImage(blobData)
scale: catalogScale, this.imageList = [...this.imageList, image.src]
}) // const catalogImg = image.cloneNode()
const catalogCanvas = this.$refs.catalogCanvas[i] // replacePlaceholder(this.$refs.catalogItemContent[idx], catalogImg)
catalogCanvas.width = catalogViewport.width const viewerImg = image.cloneNode()
catalogCanvas.height = catalogViewport.height this.viewerImageList = [...this.viewerImageList, viewerImg.src]
replacePlaceholder(this.$refs.viewerItem[idx], viewerImg)
const renderCatalog = page.render({ // const catalogLoaded = new Promise(resolve => {
canvasContext: catalogCanvas.getContext('2d'), // catalogImg.onload = resolve
viewport: catalogViewport, // })
const viewerLoaded = new Promise(resolve => {
viewerImg.onload = resolve
}) })
await Promise.all([renderViewer, renderCatalog])
return Promise.all([/*catalogLoaded, */ viewerLoaded])
}) })
)
await Promise.all(promiseList)
this.$emit('rendered') this.$emit('rendered')
this.$emit('update:isRendering', false)
await this.$nextTick() await this.$nextTick()
this.viewerContentHeight = this.$refs.viewerContent.clientHeight this.viewerContentHeight = this.$refs.viewerContent.clientHeight
@ -315,7 +331,7 @@ export default {
}, },
syncViewerOffset(page) { syncViewerOffset(page) {
if (this.isScrolling) return if (this.isScrolling) return
this.$refs.viewerCanvas[page - 1].scrollIntoView() this.$refs.viewerItem[page - 1].scrollIntoView()
}, },
syncCatalogOffset(page) { syncCatalogOffset(page) {
const target = this.$refs.catalogItem[page - 1] const target = this.$refs.catalogItem[page - 1]

View File

@ -0,0 +1,57 @@
const getPageBlobList = async (pages, pdf) => {
const container = getContainer()
const promises = pages.map(pageNum => {
// eslint-disable-next-line no-async-promise-executor
return new Promise(async (resolve, reject) => {
try {
const page = await pdf.getPage(pageNum)
renderPage(page, container).then(resolve)
} catch (e) {
reject(e)
}
})
})
const data = await Promise.all(promises)
container.remove()
return data
}
const renderPage = async (page, container) => {
// This gives us the page's dimensions at full scale
const viewport = page.getViewport({
scale: 3,
})
// We'll create a canvas for each page to draw it on
const canvas = document.createElement('canvas')
canvas.style.display = 'block'
container.appendChild(canvas)
const context = canvas.getContext('2d')
canvas.height = viewport.height
canvas.width = viewport.width
// Draw it on the canvas
await page.render({ canvasContext: context, viewport }).promise
return new Promise(resolve =>
canvas.toBlob(result =>
resolve({ blob: result, height: viewport.height, width: viewport.width })
)
)
}
const getContainer = () => {
const container = document.createElement('div')
container.style.visibility = 'hidden'
container.style.display = 'none'
document.body.appendChild(container)
return container
}
export default getPageBlobList