1、简要描述
林大大来啦~最近在搞pdf文件的预览、缩放、拖动和截图,才发现其实要比普通图片的缩放拖动截图麻烦的多。
本篇重点:pdf的截图所使用的矩形框是怎么画出来的呢?想使用的童鞋参考我另一篇博客~
普通图片
预览、缩放、拖动:
将图片通过img标签预览,然后加入v-drag指令实现拖拽,通过transform: scale 来实现缩放
截图:
先绘制矩形框,这个我下面着重讲一下,然后获取到矩形框的x,y以及宽度高度,然后在canvas里面进行drawImage,最后导出图片就行了
想了解一下图片的预览、缩放、拖动和截图,可以参考我的另一篇博客,链接如下:
拖动还是用的v-drag,预览、缩放、用的pdfjs-dist, 截图用的html2canvas,pdf想要截图的话,主要是canvas和svg截图,但是我试了,svg相关的截图都是按照整个dom来截的,无法实现对应坐标下的局部截图,所以就考虑常规canvas截图
流程:pdf转canvas再drawImage截图
2、使用插件及相关核心知识
1、pdfjs-dist
版本: 2.2.228, 这个版本目前来看bug少一些
pnpm install pdfjs-dist@2.2.228
2、html2canvas
版本 : ~1.4.1 这个依赖版本没啥要求,默认就行了
pnpm install html2canvas
3、v-drag
v-drag 为自定义拖拽指令,用于一般dom在父节点范围内拖动,想 了解的在我另一篇博客查看
4、绘制矩形框
在父级dom下,当鼠标mousedown时开始矩形框绘制,mousemove时候进行矩形框宽高的改变,mouseup时,完成矩形框绘制
图例:
这部分重点其实当父级监听到onmousedown时,去获取鼠标clientX和clientY相当于当前父级的坐标,mouseEvent就是对dom的监听函数,参数type可以设置为true/false 方便你监听/取消监听
dom.getBoundingClientRect() : 获取dom的坐标及相关属性,这个不多说,就是获取他离window的边距的像素距离
x0 = e.clientX - originPos.x: 获取当前矩形框起始点的x坐标
y0 = e.clientY - originPos.y: 获取当前矩形框起始点的y坐标
startNew: 内有x、y、w、w四个数据,分别是起始点(左上角坐标)和宽高,当有宽高时,说明已经绘制了矩形框了,再次点击四个顶点10px的距离的话,会被判定为对矩形框的编辑拉伸。
mouseEvent(type) {
const mask = document.getElementById('parent-dom') // 父级盒子
if (type) {
let x0 = 0
let y0 = 0
let originPos = {}
mask.onmousedown = e => {
originPos = mask.getBoundingClientRect()
x0 = e.clientX - originPos.x
y0 = e.clientY - originPos.y
let editFlag = false
const difference = 10 // 模糊距离,当前点击像素点位置在这范围内都算是该点
if (this.startNew.x > 0 && this.startNew.y > 0) {
// 当前点击点在右上角difference px像素以内
const neX = x0 <= this.startNew.x + this.startNew.w + difference && x0 >= this.startNew.x + this.startNew.w - difference
const neY = y0 >= this.startNew.y - difference && y0 <= this.startNew.y + difference
// 当前点击点在左上角difference px像素以内
const nwX = x0 >= this.startNew.x - difference && x0 <= this.startNew.x + difference
const nwY = y0 >= this.startNew.y - difference && y0 <= this.startNew.y + difference
// 当前点击点在左下角difference px像素以内
const swX = x0 >= this.startNew.x - difference && x0 <= this.startNew.x + difference
const swY = y0 <= this.startNew.y + this.startNew.h + difference && y0 >= this.startNew.y + this.startNew.h - difference
// 当前点击点在右下角difference px像素以内
const seX = x0 <= this.startNew.x + this.startNew.w + difference && x0 >= this.startNew.x + this.startNew.w - difference
const seY = y0 <= this.startNew.y + this.startNew.h + difference && y0 >= this.startNew.y + this.startNew.h - difference
if (neX && neY) {
x0 = this.startNew.x
y0 = this.startNew.y + this.startNew.h
editFlag = true
} else if (nwX && nwY) {
x0 = this.startNew.x + this.startNew.w
y0 = this.startNew.y + this.startNew.h
editFlag = true
} else if (swX && swY) {
x0 = this.startNew.x + this.startNew.w
y0 = this.startNew.y
editFlag = true
} else if (seX && seY) {
x0 = this.startNew.x
y0 = this.startNew.y
editFlag = true
}
if (editFlag) {
mask.onmousemove = e => {
this.moveCode(e, originPos, x0, y0)
}
}
// 当前点击点在矩形框difference px之外 则就行保存操作
if (x0 > (this.startNew.x + this.startNew.w + difference) || x0 < (this.startNew.x - difference) || y0 > (this.startNew.y + this.startNew.h + difference) || y0 < (this.startNew.y - difference)) {
// 点击鼠标左键
if (e.button === 0) {
// 点击鼠标右键 退出截图
} else if (e.button === 2) {
}
}
} else {
this.startNew = {
x: x0,
y: y0,
w: 0,
h: 0
}
if (!mask.onmousemove) {
mask.onmousemove = e => {
this.moveCode(e, originPos, x0, y0)
}
}
}
}
mask.onmousemove = e => {
this.moveCode(e, originPos, x0, y0)
}
mask.onmouseup = e => {
// 点击鼠标右键 退出截图
if (e.button === 2) {
// 点击左键,有矩形框的话,就完成截图
} else {
if (this.startNew.w > 0) { // 因为作业批改项目也遇到这问题,所以当有实际绘制距离才认为是截图完成
mask.onmousemove = null
mask.style.cursor = ''
}
}
}
} else {
mask.onmousedown = null
mask.onmousemove = null
mask.onmouseup = null
}
}
mousemove过程中边界方向判定方法:
这部分我写出来很麻烦的,花了好多时间。。主要是想实现矩形框的随意拉伸
/**
* mousemove逻辑处理
* @param {*} e
* @param {*} originPos
* @param {*} x0
* @param {*} y0
*/
moveCode(e, originPos, x0, y0) {
const x1 = e.clientX - originPos.x
const y1 = e.clientY - originPos.y
// 第一象限
if (x1 >= x0 && y1 < y0) {
// x 越界处理
this.startNew.x = x0
// y 越界处理
this.startNew.y = e.clientY <= originPos.top ? 0 : y1
this.startNew.w = e.clientX >= originPos.right ? originPos.width - x0 : x1 - x0
this.startNew.h = e.clientY <= originPos.top ? y0 : y0 - y1
// 第二象限
} else if (x1 < x0 && y1 < y0) {
// x 越界处理
this.startNew.x = e.clientX <= originPos.left ? 0 : x1
// y 越界处理
this.startNew.y = e.clientY <= originPos.top ? 0 : y1
this.startNew.w = e.clientX <= originPos.left ? x0 : x0 - x1
this.startNew.h = e.clientY <= originPos.top ? y0 : y0 - y1
// 第三象限
} else if (x1 < x0 && y1 >= y0) {
// x 越界处理
this.startNew.x = e.clientX <= originPos.left ? 0 : x1
// y 越界处理
this.startNew.y = y0
this.startNew.w = e.clientX <= originPos.left ? x0 : x0 - x1
this.startNew.h = e.clientY >= originPos.bottom ? originPos.height - y0 : y1 - y0
// 第四象限
} else if (x1 >= x0 && y1 >= y0) {
// x 越界处理
this.startNew.x = x0
// y 越界处理
this.startNew.y = y0
this.startNew.w = e.clientX >= originPos.right ? originPos.width - x0 : x1 - x0
this.startNew.h = e.clientY >= originPos.bottom ? originPos.height - y0 : y1 - y0
}
}
3、源码
图例:
1、页面结构
index.vue: 主界面代码
preview.vue: 预览代码(核心)
clipRect.vue: 截图矩形框代码
footer.vue: 主界面底部代码
header.vue: 主界面顶部代码
2、定义需要的数据
index.vue
curClickArea: 1, // 当前点击的是正文栏还是答案栏 1 => 正文, 2 => 答案
clipStatus: 0, // 是否处于截图状态 0 => 否, 1 => 是(已就绪),2 => 是(正在截图), 3 => 是(完成截图)
mode: 0, // 0 => 双栏, 1 => 正文页, 2 => 答案页
canPaint: true, // 当前是否能进行绘制, true => mousedown 触发绘制,false => mousedown 保存
previewList: [
{
name: '正文页',
id: 1,
pdfUrl: 'xxx.pdf'
},
{
name: '答案页',
id: 2,
pdfUrl: 'xxx.pdf'
}
],
startNew: {} // 绘制坐标及宽高
preview.vue
defaultPage: 1, // pdfjs-dist 展示第一页时会倒置,所以我下面逻辑判断第一页时进行部分操作
firstPaint: false, // 只读pdf对象 首次是否绘制完毕
firstPaint1: false, // 可编辑pdf对象 首次是否绘制完毕
defaultCanvas: null, // 当前正准备截图的canvas,数据取自对应页码只读pdf对象
pageControl: { // 页码参数
curPage: 1, // 当前页码
pages: 0, // 总共页码
scale: 100, // 转换后缩放比例, 默认为100, 最大缩放 2,最小缩放 0.2
beforeScale: 1, // 转换前缩放比例
rotation: 0 // 当前旋转参数
},
pdfDoc: null, // 只读pdf对象
pdfDoc1: null, // 可编辑pdf对象
draggable: null // 外层拖动dom
3、文件源码
index.vue
<template>
<div class="preview-problem">
<header-ai
:mode="mode"
:clip-status="clipStatus"
:expend="expend"
@mode="modeChange"
@btn="btnChange"
v-on="$listeners"
/>
<div class="preview-body">
<preview
v-for="item in previewList"
v-show="modeWatch(item.id)"
:ref="`preview${item.id}`"
:key="item.id"
:item="item"
:mode="mode"
:offset="startNew"
:clip-status="clipStatus"
:can-clip="curClickArea===item.id"
:class="{'preview-out-box': mode===0}"
@file="fileUpload"
/>
<div
v-if="clipStatus"
id="mask"
class="mask"
:style="{'backgroundColor': clipStatus === 1 ? 'rgba(0, 0, 0, 0.4)' : ''}"
>
<div class="mask-title">
<span>截图</span>
<span @click="clipPhoto(2)">关闭</span>
</div>
</div>
</div>
</div>
</template>
<script>
import HeaderAi from './component/header.vue'
import Preview from './preview.vue'
export default {
name: 'PreviewProblem',
components: {
HeaderAi,
Preview
},
props: {
expend: {
type: Number,
default: 2
}
},
data() {
return {
curClickArea: 1, // 当前点击的是正文栏还是答案栏 1 => 正文, 2 => 答案
clipStatus: 0, // 是否处于截图状态 0 => 否, 1 => 是(已就绪),2 => 是(正在截图), 3 => 是(完成截图)
mode: 0, // 0 => 双栏, 1 => 正文页, 2 => 答案页
canPaint: true, // 当前是否能进行绘制, true => mousedown 触发绘制,false => mousedown 保存
previewList: [
{
name: '正文页',
id: 1,
pdfUrl: 'xxx.pdf'
},
{
name: '答案页',
id: 2,
pdfUrl: 'xxx.pdf'
}
],
startNew: {} // 绘制坐标及宽高
}
},
mounted() {
this.$nextTick(() => {
window.addEventListener('keydown', this.screensEvent)
})
},
beforeDestroy() {
window.removeEventListener('keydown', this.screensEvent)
this.mouseEvent(false)
},
methods: {
fileUpload(file) {
console.log(file);
},
modeWatch(id) {
// 当从展开到缩小 并且处于双栏时, 只展示正文页
if (this.expend === 1 && this.mode === 0) {
if (id === 1) {
return true
} else {
return false
}
}
return this.mode === 0 || this.mode === id
},
/**
* 当前分栏模式改变
* @param {*} val
*/
modeChange(val) {
this.mode = val
},
btnChange(val) {
if (val === 'clip') {
this.clipPhoto(0)
}
},
/**
* 截图操作 0 => 开始截图 1 => 保存截图 2 => 退出截图 3 => 撤销当次截图
* @param {*} type
*/
clipPhoto(type) {
switch (type) {
case 0:
this.startClip()
break
// case 1:
// this.saveClip()
// break
case 2:
this.closeClip()
break
case 3:
this.cancelClip()
break
}
},
/**
* 截图快捷键
* @param {*} e
*/
screensEvent(e) {
if ((e.altKey || e.ctrlKey) && [81, 83, 87, 90].includes(e.keyCode)) {
e.preventDefault();
let typeCode = ''
if (e.altKey && e.keyCode === 81) typeCode = 0 // 截图快捷操作:alt + q
if (e.altKey && e.keyCode === 87) typeCode = 4 // 截图快捷操作:alt + w 粘贴
else if (e.ctrlKey && e.keyCode === 90) typeCode = 3 // 撤销当前截图操作:ctrl + z
if (typeCode || typeCode === 0) {
this.clipPhoto(typeCode)
}
}
return false
},
/**
* 开始截图
*/
startClip() {
this.clipStatus = 1
this.$nextTick(() => {
document.oncontextmenu = e => { // 阻止右键默认事件
e.preventDefault()
}
this.mouseEvent(true)
})
},
/**
* 撤销当前截图
*/
cancelClip() {
this.startNew.x = -9999 // 重置坐标点
this.startNew.y = -9999
this.startNew.w = 0
this.startNew.h = 0
this.clipStatus = 1 // 将状态置回就绪
},
/**
* 关闭截图
*/
closeClip() {
this.cancelClip()
this.clipStatus = 0
this.mouseEvent(false) // 销毁鼠标事件
setTimeout(() => { // 必须要加延时,不然右键退出截图状态时会立即恢复默认事件
document.oncontextmenu = null // 恢复右键默认事件
}, 100)
},
mouseEvent(type) {
const mask = document.getElementById('mask')
if (type) {
let x0 = 0
let y0 = 0
let originPos = {}
mask.onmousedown = e => {
this.clipStatus = 2
originPos = mask.getBoundingClientRect()
x0 = e.clientX - originPos.x
y0 = e.clientY - originPos.y
if (this.mode !== 0) {
this.curClickArea = this.mode
} else {
if (x0 < (mask.offsetWidth / 2)) { // 当点击x坐标小于蒙层一半时,我们认为点击的是左侧正文部分,否则为右侧答案部分
this.curClickArea = 1
} else {
this.curClickArea = 2
}
}
let editFlag = false
const difference = 10 // 模糊距离,当前点击像素点位置在这范围内都算是该点
if (this.startNew.x > 0 && this.startNew.y > 0) {
// 当前点击点在右上角difference px像素以内
const neX = x0 <= this.startNew.x + this.startNew.w + difference && x0 >= this.startNew.x + this.startNew.w - difference
const neY = y0 >= this.startNew.y - difference && y0 <= this.startNew.y + difference
// 当前点击点在左上角difference px像素以内
const nwX = x0 >= this.startNew.x - difference && x0 <= this.startNew.x + difference
const nwY = y0 >= this.startNew.y - difference && y0 <= this.startNew.y + difference
// 当前点击点在左下角difference px像素以内
const swX = x0 >= this.startNew.x - difference && x0 <= this.startNew.x + difference
const swY = y0 <= this.startNew.y + this.startNew.h + difference && y0 >= this.startNew.y + this.startNew.h - difference
// 当前点击点在右下角difference px像素以内
const seX = x0 <= this.startNew.x + this.startNew.w + difference && x0 >= this.startNew.x + this.startNew.w - difference
const seY = y0 <= this.startNew.y + this.startNew.h + difference && y0 >= this.startNew.y + this.startNew.h - difference
if (neX && neY) {
x0 = this.startNew.x
y0 = this.startNew.y + this.startNew.h
editFlag = true
} else if (nwX && nwY) {
x0 = this.startNew.x + this.startNew.w
y0 = this.startNew.y + this.startNew.h
editFlag = true
} else if (swX && swY) {
x0 = this.startNew.x + this.startNew.w
y0 = this.startNew.y
editFlag = true
} else if (seX && seY) {
x0 = this.startNew.x
y0 = this.startNew.y
editFlag = true
}
if (editFlag) {
mask.onmousemove = e => {
this.moveCode(e, originPos, x0, y0)
}
}
// 当前点击点在矩形框difference px之外 则就行保存操作
if (x0 > (this.startNew.x + this.startNew.w + difference) || x0 < (this.startNew.x - difference) || y0 > (this.startNew.y + this.startNew.h + difference) || y0 < (this.startNew.y - difference)) {
// 点击鼠标左键
if (e.button === 0) {
// 点击鼠标右键 退出截图
} else if (e.button === 2) {
this.clipPhoto(2)
}
}
} else {
this.startNew = {
x: x0,
y: y0,
w: 0,
h: 0
}
if (!mask.onmousemove) {
mask.onmousemove = e => {
this.moveCode(e, originPos, x0, y0)
}
}
}
}
mask.onmousemove = e => {
this.moveCode(e, originPos, x0, y0)
}
mask.onmouseup = e => {
// 点击鼠标右键 退出截图
if (e.button === 2) {
this.clipPhoto(2)
// 点击左键,有矩形框的话,就完成截图
} else {
if (this.startNew.w > 0) { // 因为作业批改项目也遇到这问题,所以当有实际绘制距离才认为是截图完成
this.clipStatus = 3
mask.onmousemove = null
mask.style.cursor = ''
}
}
}
} else {
mask.onmousedown = null
mask.onmousemove = null
mask.onmouseup = null
}
},
/**
* mousemove逻辑处理
* @param {*} e
* @param {*} originPos
* @param {*} x0
* @param {*} y0
*/
moveCode(e, originPos, x0, y0) {
const x1 = e.clientX - originPos.x
const y1 = e.clientY - originPos.y
// 第一象限
if (x1 >= x0 && y1 < y0) {
// x 越界处理
this.startNew.x = x0
// y 越界处理
this.startNew.y = e.clientY <= originPos.top ? 0 : y1
this.startNew.w = e.clientX >= originPos.right ? originPos.width - x0 : x1 - x0
this.startNew.h = e.clientY <= originPos.top ? y0 : y0 - y1
// 第二象限
} else if (x1 < x0 && y1 < y0) {
// x 越界处理
this.startNew.x = e.clientX <= originPos.left ? 0 : x1
// y 越界处理
this.startNew.y = e.clientY <= originPos.top ? 0 : y1
this.startNew.w = e.clientX <= originPos.left ? x0 : x0 - x1
this.startNew.h = e.clientY <= originPos.top ? y0 : y0 - y1
// 第三象限
} else if (x1 < x0 && y1 >= y0) {
// x 越界处理
this.startNew.x = e.clientX <= originPos.left ? 0 : x1
// y 越界处理
this.startNew.y = y0
this.startNew.w = e.clientX <= originPos.left ? x0 : x0 - x1
this.startNew.h = e.clientY >= originPos.bottom ? originPos.height - y0 : y1 - y0
// 第四象限
} else if (x1 >= x0 && y1 >= y0) {
// x 越界处理
this.startNew.x = x0
// y 越界处理
this.startNew.y = y0
this.startNew.w = e.clientX >= originPos.right ? originPos.width - x0 : x1 - x0
this.startNew.h = e.clientY >= originPos.bottom ? originPos.height - y0 : y1 - y0
}
}
}
}
</script>
<style lang="scss" scoped>
.preview-problem{
height: 100%;
background-color: #3E3E3E;
.preview-body{
height: calc(100% - 40px);
display: flex;
flex-flow: row nowrap;
position: relative;
overflow: hidden;
.preview-out-box:nth-of-type(1) {
border-right: 2px solid #3E3E3E;
}
.mask{
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
border: 2px solid #FFAE0D;
cursor: crosshair;
.mask-title{
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-between;
height: 18px;
line-height: 18px;
padding: 0 10px;
background-color: #FFAE0D;
position: absolute;
left: 0;
top: 0;
width: 100%;
z-index: 2;
span{
color: #fff;
&:last-child{
cursor: pointer;
}
}
}
}
}
}
</style>
preview.vue
<template>
<div ref="preview" class="preview">
<div
:id="`img-box${item.id}`"
v-drag
class="img-box"
@mousewheel="(e) => scaleDom(e, 'wheel')"
>
<canvas
:id="`see-canvas-${item.id}`"
class="see-canvas"
/>
<canvas
:id="`edit-canvas-${item.id}`"
/>
</div>
<clip-rect
:id="item.id"
:offset="offset"
:mode="mode"
v-bind="$attrs"
/>
<footer-ai
:mode="item.id"
:page="pageControl"
@scaleControl="scaleControl"
@pageChange="pageChange"
/>
</div>
</template>
<script>
import PDFJS from 'pdfjs-dist';
import workerSrc from 'pdfjs-dist/build/pdf.worker.entry';
PDFJS.workerSrc = workerSrc;
import html2canvas from 'html2canvas';
import FooterAi from './component/footer.vue'
import ClipRect from './component/clipRect.vue'
export default {
name: 'Preview',
components: {
FooterAi,
ClipRect
},
inheritAttrs: false,
props: {
/**
* 当前分栏的详情
*/
item: {
type: Object,
default: () => {}
},
/**
* 绘制矩形框坐标对象(基于整个蒙层)
*/
offset: {
type: Object,
default: () => null
},
/**
* 当前模式
*/
mode: {
type: Number,
default: 0
}
},
data() {
return {
defaultPage: 1, // pdfjs-dist 展示第一页时会倒置,所以我下面逻辑判断第一页时进行部分操作
firstPaint: false, // 只读pdf对象 首次是否绘制完毕
firstPaint1: false, // 可编辑pdf对象 首次是否绘制完毕
defaultCanvas: null, // 当前正准备截图的canvas,数据取自对应页码只读pdf对象
pageControl: { // 页码参数
curPage: 1, // 当前页码
pages: 0, // 总共页码
scale: 100, // 转换后缩放比例, 默认为100, 最大缩放 2,最小缩放 0.2
beforeScale: 1, // 转换前缩放比例
rotation: 0 // 当前旋转参数
},
pdfDoc: null, // 只读pdf对象
pdfDoc1: null, // 可编辑pdf对象
draggable: null // 外层拖动dom
}
},
mounted() {
this.$nextTick(() => {
this.draggable = document.getElementById(`img-box${this.item.id}`)
this.loadFile(this.item.pdfUrl)
})
},
methods: {
/**
* canvas以canvas为底图绘制截图
* 这一部分其实计算当前矩形框在原始比例图中坐标比较麻烦
*/
clip() {
const w = this.offset.w / this.pageControl.beforeScale // 100比例下的矩形框宽度
const h = this.offset.h / this.pageControl.beforeScale // 100比例下的矩形框高度
const dragX = this.draggable.offsetLeft // 父盒子dom拖动位移left
const dragY = this.draggable.offsetTop // 父盒子dom拖动位移top
let x = 0
const y = (this.offset.y - dragY) / this.pageControl.beforeScale
if (this.mode === 0 && this.item.id === 2) { // 双栏模式下的答案页 矩形框的x点坐标
x = (this.offset.x - this.$el.offsetWidth - dragX) / this.pageControl.beforeScale
} else {
x = (this.offset.x - dragX) / this.pageControl.beforeScale
}
const scale = window.devicePixelRatio < 3 ? window.devicePixelRatio : 2 // 当前设备物理像素分辨率,正常默认的都为1
html2canvas(this.defaultCanvas, {
width: this.defaultCanvas.width,
height: this.defaultCanvas.height,
scale: scale, // 处理模糊问题
useCORS: true // 开启跨域,这个是必须的
}).then(canvas => {
const tmpCanvas = document.createElement('canvas')
const tmpContext = tmpCanvas.getContext('2d')
tmpCanvas.width = w
tmpCanvas.height = h
tmpContext.drawImage(canvas, x, y, w, h, 0, 0, w, h)
tmpCanvas.toBlob((blob) => console.log(URL.createObjectURL(blob)))
tmpCanvas.toBlob((blob) => {
this.$emit('file', this.blob2file(blob))
}, 'image/png', 1)
})
},
/**
* 只读pdf的对应页码数据渲染
* @param {*} num
*/
renderPage(num) {
// 返回单页内容实例(页面索引)
this.pdfDoc.getPage(num).then((page) => {
// canvas 绘制 PDF
const canvas = document.getElementById(`see-canvas-${this.item.id}`)
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.mozImageSmoothingEnabled = false;
ctx.webkitImageSmoothingEnabled = false;
ctx.msImageSmoothingEnabled = false;
ctx.imageSmoothingEnabled = false;
const dpr = window.devicePixelRatio || 1
const bsr = ctx.webkitBackingStorePixelRatio ||
ctx.mozBackingStorePixelRatio ||
ctx.msBackingStorePixelRatio ||
ctx.oBackingStorePixelRatio ||
ctx.backingStorePixelRatio || 1
const ratio = dpr / bsr
// 下面这一段if 其实算是官方的bug,因为首页pdf会倒置,然后再调用renderPage就一切正常了,所以只有代码先放大再缩小,才能让pdf正放
if (num === this.defaultPage && !this.firstPaint) {
const viewport = page.getViewport({ scale: 1.1 });// 这是让pdf文件的大小等于视口的大小
canvas.width = viewport.width * ratio
canvas.height = viewport.height * ratio// 这里会进行压缩,解决模糊问题
const renderContext = {
canvasContext: ctx,
viewport: viewport,
transform: [ratio, 0, 0, ratio, 0, 0]// 这里会进行放大,解决模糊问题
}
page.render(renderContext).promise.then(() => {
const viewport1 = page.getViewport({ scale: 1 });// 这是让pdf文件的大小等于视口的大小
canvas.width = viewport1.width * ratio
canvas.height = viewport1.height * ratio// 这里会进行压缩,解决模糊问题
const renderContext1 = {
canvasContext: ctx,
viewport: viewport1,
transform: [ratio, 0, 0, ratio, 0, 0]// 这里会进行放大,解决模糊问题
}
page.render(renderContext1).promise.then(() => {
this.defaultCanvas = canvas // 设置默认scale为1的canvas,方便后面以此为基准来截图
this.firstPaint = true
})
})
} else {
const viewport = page.getViewport({ scale: 1 });// 这是让pdf文件的大小等于视口的大小
canvas.width = viewport.width * ratio
canvas.height = viewport.height * ratio// 这里会进行压缩,解决模糊问题
const renderContext = {
canvasContext: ctx,
viewport: viewport,
transform: [ratio, 0, 0, ratio, 0, 0]// 这里会进行放大,解决模糊问题
}
page.render(renderContext).promise.then(() => {
this.defaultCanvas = canvas
})
}
})
},
/**
* 可编辑pdf的对应页码数据渲染
* @param {*} num
*/
realRenderPage(num) {
// 返回单页内容实例(页面索引)
this.pdfDoc1.getPage(num).then((page) => {
// canvas 绘制 PDF
const canvas = document.getElementById(`edit-canvas-${this.item.id}`)
const ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height)
ctx.mozImageSmoothingEnabled = false;
ctx.webkitImageSmoothingEnabled = false;
ctx.msImageSmoothingEnabled = false;
ctx.imageSmoothingEnabled = false;
const dpr = window.devicePixelRatio || 1
const bsr = ctx.webkitBackingStorePixelRatio ||
ctx.mozBackingStorePixelRatio ||
ctx.msBackingStorePixelRatio ||
ctx.oBackingStorePixelRatio ||
ctx.backingStorePixelRatio || 1
const ratio = dpr / bsr
if (num === this.defaultPage && !this.firstPaint1) {
const viewport = page.getViewport({ scale: 1.1 });// 这是让pdf文件的大小等于视口的大小
canvas.width = viewport.width * ratio
canvas.height = viewport.height * ratio// 这里会进行压缩,解决模糊问题
const renderContext = {
canvasContext: ctx,
viewport: viewport,
transform: [ratio, 0, 0, ratio, 0, 0]// 这里会进行放大,解决模糊问题
}
page.render(renderContext).promise.then(() => {
const viewport1 = page.getViewport({ scale: 1 });// 这是让pdf文件的大小等于视口的大小
canvas.width = viewport1.width * ratio
canvas.height = viewport1.height * ratio// 这里会进行压缩,解决模糊问题
const renderContext1 = {
canvasContext: ctx,
viewport: viewport1,
transform: [ratio, 0, 0, ratio, 0, 0]// 这里会进行放大,解决模糊问题
}
page.render(renderContext1).promise.then(() => {
this.firstPaint1 = true
})
})
} else {
const viewport = page.getViewport({ scale: this.pageControl.beforeScale });// 这是让pdf文件的大小等于视口的大小
canvas.width = viewport.width * ratio
canvas.height = viewport.height * ratio// 这里会进行压缩,解决模糊问题
const renderContext = {
canvasContext: ctx,
viewport: viewport,
transform: [ratio, 0, 0, ratio, 0, 0]// 这里会进行放大,解决模糊问题
}
page.render(renderContext)
}
})
},
/**
* 获取整个pdf文档
* 其实为啥会渲染两次呢,因为需要一个原始比例的图,作为截图的基准,因为pdf的page.render后的canvas是随着你viewport参数的scale来生成的,且后续canvas任何修改都会模糊
* @param {*} url
*/
loadFile(url) {
PDFJS.getDocument({
url,
cMapPacked: true
}).promise.then((pdf) => {
this.pdfDoc = pdf
this.pdfDoc1 = pdf
this.pageControl.pages = this.pdfDoc.numPages
this.pageControl.curPage = 1
this.renderPage(this.pageControl.curPage)
this.realRenderPage(this.pageControl.curPage)
}, (err) => {
if (err.name === 'MissingPDFException') {
this.$message.warn('无效的PDF链接')
}
})
},
/**
* 分页操作
* @param {*} item
*/
pageChange(item) {
this.$nextTick(() => {
this.pageControl.curPage = item.page
this.renderPage(this.pageControl.curPage)
this.realRenderPage(this.pageControl.curPage)
})
},
/**
* 当前缩放比例控制
* @param {*} val
*/
scaleControl(val) {
if (val) {
this.scaleDom(val, 'click')
} else {
this.pageControl.scale = 100
this.pageControl.beforeScale = 1
this.renderPage(this.pageControl.curPage)
this.realRenderPage(this.pageControl.curPage)
}
},
/**
* 鼠标滑轮事件
* @param {*} e
*/
scaleDom(e, type = 'wheel') {
let scaleReal = this.pageControl.beforeScale
const size = type === 'wheel' ? e.wheelDelta / 1200 : parseFloat(e / 10)
scaleReal += size;
if (scaleReal >= 0.18 && scaleReal <= 2) { // 不能直接取 0.2, 因为浏览器不同,可能每次size都不能刚好为 0.1
this.pageControl.beforeScale = Number(scaleReal.toFixed(2))
this.pageControl.scale = Number((Number(scaleReal.toFixed(2)) * 100).toFixed(0))
this.renderPage(this.pageControl.curPage)
this.realRenderPage(this.pageControl.curPage)
}
},
/**
* 随机id
*/
uuid() {
let d = new Date().getTime();
const uuid = 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
const r = (d + Math.random() * 16) % 16 | 0;
d = Math.floor(d / 16);
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
});
return uuid
},
/**
* canvas转base64
* @param {*} blob
* @param {*} type
* @param {*} name
*/
blob2file(blob, type = 'png', name = '') {
const fileName = name || this.uuid() + '.' + type
const file = new File([blob], fileName, { type: blob.type, lastModified: Date.now() })
return file
}
}
}
</script>
<style lang="scss" scoped>
.preview{
position: relative;
flex: 1 0 50%;
background-color: #606266;
overflow: hidden;
.img-box{
height: 100%;
position: absolute;
.see-canvas{
position: absolute;
left: -9999px;
}
}
}
</style>
clipRect.vue
<template>
<div :id="`rectBox-${id}`" class="rect-page">
<div v-if="clipStatus===3" class="out-rect">
<span
v-for="item in controls"
:key="item.value"
class="item"
@click="controlChange(item)"
>
{{ item.label }}
</span>
</div>
</div>
</template>
<script>
export default {
props: {
/**
* 矩形框坐标及宽高
*/
offset: {
type: Object,
default: () => null
},
/**
* 截图状态
*/
clipStatus: {
type: Number,
default: 0
},
/**
* 当前分栏id
*/
id: {
type: Number,
default: 0
},
/**
* 当前分栏模式
*/
mode: {
type: Number,
default: 0
},
/**
* 是否能进行截图绘制矩形框
*/
canClip: {
type: Boolean,
default: false
}
},
data() {
return {
rect: null, // 矩形dom
controls: [
{
value: 0,
label: '识别'
},
{
value: 1,
label: '裁剪'
}
]
}
},
watch: {
offset: {
handler(val) {
this.$nextTick(() => {
this.initCanvas()
if (this.canClip) { // 当前区域为绘制区时,矩形框所有坐标参数按照父组件传递来依次绘制
if (this.mode === 0 && this.id === 2) { // 当截图为 两栏,点击为 答案,则修改矩形框x点坐标
this.rect.style.left = val.x - this.$parent.$el.offsetWidth + 'px'
} else {
this.rect.style.left = val.x + 'px'
}
this.rect.style.top = val.y + 'px'
this.posChange(val)
} else { // 当前区域为非绘制区时,就将矩形框所有坐标参数重置掉
this.rect.style.left = -9999 + 'px'
this.rect.style.top = -9999 + 'px'
this.rect.style.width = 0
this.rect.style.height = 0
}
})
},
deep: true
}
},
methods: {
/**
* 识别、截图功能控制
* @param {*} item
*/
controlChange(item) {
if (item.value) {
// 裁剪
} else {
// 识别
}
// 通用都是要剪切截图
this.$parent.clip()
},
/**
* 当前位置控制
* @param {*} offset
*/
posChange(offset) {
this.rect.style.width = offset.w + 'px'
this.rect.style.height = offset.h + 'px'
},
/**
* 初始化canvas
*/
initCanvas() {
if (!this.rect) {
this.rect = document.getElementById(`rectBox-${this.id}`)
}
}
}
}
</script>
<style lang="scss" scoped>
.rect-page{
position: absolute;
left: -9999px;
top: 0;
border: 1px solid #FFAE0D;
box-shadow: 0 0 0 1999px rgba(0, 0, 0, .4);
cursor: default;
.rect-box {
width: 100%;
height: 100%;
}
.out-rect{
background-color: rgba(0, 0, 0, .5);
text-align: center;
padding: 4px 8px;
cursor: pointer;
position: absolute;
bottom: -29px;
right: 0;
width: 100px;
display: flex;
justify-content: space-between;
z-index: 1;
.item{
width: 40px;
height: 20px;
line-height: 20px;
text-align: center;
color: #fff;
&:hover{
color: #000;
background-color: #fff;
}
}
}
}
</style>
footer.vue
<template>
<div class="footer-ai">
<article>{{ mode | modefilter }}</article>
<div class="page-select">
<div :class="['select-btn', {'select-btn-disable': banPrev}]">
<i
:class="['el-icon-arrow-left', {'ban-btn':banPrev}]"
@click="handlePage('prev')"
/>
</div>
<span class="qti-number">
<el-input
v-model="currentPage"
size="mini"
class="ctrl-input"
type="number"
@change="(page) => handlePage('input', page)"
/>
<span>/ {{ page.pages }}页</span>
</span>
<div class="select-btn">
<i
:class="['el-icon-arrow-right', {'ban-btn':banNext}]"
@click="handlePage('next')"
/>
</div>
</div>
<div class="scale-control">
<i class="el-icon-narrow-l" @click="emitControl('scaleControl', -1)" />
<span>{{ page.scale + "%" }}</span>
<i class="el-icon-amplification-m" @click="emitControl('scaleControl', 1)" />
<i class="el-icon-adapt" @click="emitControl('scaleControl', 0)" />
</div>
</div>
</template>
<script>
export default {
name: 'FooterAi',
filters: {
modefilter(val) {
switch (val) {
case 0:
return '两栏'
case 1:
return '正文页'
case 2:
return '答案页'
default:
return '两栏'
}
}
},
props: {
mode: {
type: Number,
default: 0
},
page: {
type: Object,
default: () => null
}
},
data() {
return {
currentPage: this.page.curPage
}
},
computed: {
banPrev() {
return this.currentPage === 1
},
banNext() {
return this.currentPage === this.page.pages
}
},
methods: {
/**
* 事件分发
* @param {*} type
* @param {*} val
*/
emitControl(type, val) {
this.$emit(type, val)
},
/**
* 页码控制
* @param {*} type
* @param {*} page
*/
handlePage(type, page) {
if (type === 'input') { // 输入框改变页码
if (page > this.page.pages) {
this.$message.warning('超过最大页数!');
this.currentPage = 1;
return
} else if (page <= 0) {
this.currentPage = 1;
return
}
this.currentPage = page
} else if (type === 'prev') { // 上一页
if (this.banPrev) return
if (this.currentPage > 1) this.currentPage--
} else if (type === 'next') { // 下一页
if (this.banNext) return
this.currentPage++
}
this.emitControl('pageChange', {
type,
page: Number(this.currentPage)
})
}
}
}
</script>
<style lang="scss" scoped>
.footer-ai{
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 36px;
padding: 2px 20px;
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-between;
background-color: #F5F5F5;
.page-select{
display: flex;
flex-flow: row nowrap;
align-items: center;
height: 28px;
line-height: 28px;
cursor: pointer;
.select-btn{
margin: 0 10px;
&:hover{
color: #FFAE0D;
}
}
.select-btn-disable{
background-color: #edf1f2;
}
::v-deep .el-input{
width: 40px;
margin-right: 10px;
.el-input__inner{
padding: 0 10px;
color: #FFAE0D;
text-align: center;
}
}
::v-deep input::-webkit-outer-spin-button,
::v-deep input::-webkit-inner-spin-button {
-webkit-appearance: none !important;
}
}
.scale-control{
display: flex;
align-items: center;
flex-flow: row nowrap;
i {
font-size: 24px;
cursor: pointer;
color: #FFAE0D;
margin: 0 5px;
width: 24px;
height: 24px;
background-size: 20px;
&:last-child{
background-position: center;
}
}
}
}
</style>
header.vue
<template>
<div class="header-ai">
<div class="left">
<div class="control">
<div
v-for="item in controls"
:key="item.value"
:class="['item', {'active': item.value === mode, 'ban': clipStatus}]"
@click="modeChange(item)"
>
{{ item.label }}
</div>
</div>
<div class="cutter-zoom">
<el-popover width="500" trigger="click">
<div class="clip-desc">
<p>截图说明</p>
<p>截图:alt + q</p>
<p>粘贴:alt + w或ctrl + v</p>
<p>撤销:ctrl + z</p>
<p>退出:鼠标右键单击关闭</p>
</div>
<i
slot="reference"
class="el-icon-_annotation"
/>
</el-popover>
<i
class="el-icon-_shear_nor"
@click="btnChange('clip')"
/>
</div>
</div>
<i
:class="[expend === 2 ? 'el-icon-s-fold' : 'el-icon-s-unfold']"
@click="expendChange"
/>
</div>
</template>
<script>
export default {
name: 'HeaderAi',
props: {
mode: { // 当前分栏模式
type: Number,
default: 0
},
clipStatus: { // 截图状态
type: Number,
default: 0
},
expend: { // 当前是否展开
type: Number,
default: 2
}
},
data() {
return {
controls: [
{
value: 0,
label: '两栏'
},
{
value: 1,
label: '正文页'
},
{
value: 2,
label: '答案页'
}
]
}
},
methods: {
/**
* 当前分栏模式改变
* @param {*} item
*/
modeChange(item) {
if (this.clipStatus === 1) return
this.$emit('mode', item.value)
},
/**
* 其他按钮触发
* @param {*} val
*/
btnChange(val) {
if (!this.clipStatus) { // 如果处于截图状态,就不再次触发了
this.$emit('btn', val)
}
},
expendChange() {
this.$emit('expend', this.expend === 2 ? 1 : 2)
}
}
}
</script>
<style lang="scss" scoped>
.header-ai{
width: 100%;
height: 40px;
padding: 8px 20px;
display: flex;
flex-flow: row nowrap;
align-items: center;
justify-content: space-between;
& > i {
color: #fff;
font-size: 24px;
cursor: pointer;
&:hover{
color: #FFAE0D;
}
}
.left{
display: flex;
flex-flow: row nowrap;
align-items: center;
.control{
border-radius: 4px;
display: flex;
flex-flow: row nowrap;
align-items: center;
.item{
color: #fff;
background-color: rgba(255, 255, 255, .1);
width: 66px;
height: 24px;
line-height: 24px;
text-align: center;
position: relative;
cursor: pointer;
&:first-child{
border-radius: 4px 0 0 4px;
}
&:last-child{
border-radius: 0 4px 4px 0;
}
&:not(:last-child)::after{
content: '';
position: absolute;
right: 0;
top: 0;
width: 1px;
height: 100%;
background-color: #606266;
}
}
.active{
background-color: #FFAE0D;
}
.ban{
cursor: not-allowed;
}
}
.cutter-zoom {
margin-left: 12px;
display: flex;
align-items: center;
flex-flow: row nowrap;
i {
font-size: 24px;
cursor: pointer;
color: #ffae0d;
margin: 0 12px;
}
}
}
.right{
}
}
</style>
4、疑难问题解答
1、矩形框周围灰色半透明蒙层是如何出现的?
实际上是用的box-shadow阴影
box-shadow: 0 0 0 1999px rgba(0, 0, 0, .4)
这样你绘制矩形框时,周围就会一直有蒙层效果,至于为啥设置1999px而不是9999px,这个你可以自己试试,有惊喜~[/doge],反正1999px是当蒙层是完全够用的对于一般屏幕
2、pdf首页为啥会倒置?
其实这一点官方也没有给出解释,但是再次调用renderPage则恢复正常,那我们想要让用户正常体验怎么办?我是这么解决的:
新增firstPaint变量,用于首次renderPage方法调用时使用,并且只有首页时使用,代码去改变scale,然后再改回1,这样来使pdf首页不倒置
// renderPage方法内
if (num === this.defaultPage && !this.firstPaint) {
const viewport = page.getViewport({ scale: this.pageControl.beforeScale });// 这是让pdf文件的大小等于视口的大小
canvas.width = viewport.width * ratio
canvas.height = viewport.height * ratio// 这里会进行压缩,解决模糊问题
canvas.style.width = viewport.width + 'px'
canvas.style.height = viewport.height + 'px'
const renderContext = {
canvasContext: ctx,
viewport: viewport,
transform: [ratio, 0, 0, ratio, 0, 0]// 这里会进行放大,解决模糊问题
}
page.render(renderContext).promise.then(() => {
const viewport1 = page.getViewport({ scale: 1 });// 这是让pdf文件的大小等于视口的大小
canvas.width = viewport1.width * ratio
canvas.height = viewport1.height * ratio// 这里会进行压缩,解决模糊问题
canvas.style.width = viewport1.width + 'px'
canvas.style.height = viewport1.height + 'px'
const renderContext1 = {
canvasContext: ctx,
viewport: viewport1,
transform: [ratio, 0, 0, ratio, 0, 0]// 这里会进行放大,解决模糊问题
}
page.render(renderContext1)
this.firstPaint = true
})
} else {
const viewport = page.getViewport({ scale: this.pageControl.beforeScale });// 这是让pdf文件的大小等于视口的大小
canvas.width = viewport.width * ratio
canvas.height = viewport.height * ratio// 这里会进行压缩,解决模糊问题
canvas.style.width = viewport.width + 'px'
canvas.style.height = viewport.height + 'px'
const renderContext = {
canvasContext: ctx,
viewport: viewport,
transform: [ratio, 0, 0, ratio, 0, 0]// 这里会进行放大,解决模糊问题
}
// const that = this
page.render(renderContext)
}
3、如何读取pdf文件?
其实我们发现一进来其实默认读取第一页的数据, renderPage(1),想要切换其他页,就传入renderPage(index),注意哦,索引是从1开始的, 读取文件将文件地址传入loadFile。
但是我们看了两次renderPage,其实为啥会渲染两次呢,因为需要一个原始比例的图,作为截图的基准,因为pdf的page.render后的canvas是随着你viewport参数的scale来生成的,且后续canvas任何修改都会模糊
loadFile(url) {
PDFJS.getDocument({
url,
cMapPacked: true
}).promise.then((pdf) => {
this.pdfDoc = pdf
this.pdfDoc1 = pdf
this.pageControl.pages = this.pdfDoc.numPages
this.pageControl.curPage = 1
this.renderPage(this.pageControl.curPage)
this.realRenderPage(this.pageControl.curPage)
}, (err) => {
if (err.name === 'MissingPDFException') {
this.$message.warn('无效的PDF链接')
}
})
}
4、pdf文件如何实现放大缩小?
放大缩小主要有两种:
鼠标滑轮控制
@mousewheel="(e) => scaleDom(e, 'wheel')"
自定义按钮控制
this.scaleDom(val, 'click')
// 放大
this.scaleDom(1, 'click')
// 缩小
this.scaleDom(-1, 'click')
通用方法:我设置最大放大2倍,最小缩放0.2倍,当然可以改,根据你们自己想要的来,当修改缩放比例后,再调用renderPage方法重新绘制
/**
* 鼠标滑轮事件
* @param {*} e
*/
scaleDom(e, type = 'wheel') {
let scaleReal = this.pageControl.beforeScale
const size = type === 'wheel' ? e.wheelDelta / 1200 : parseFloat(e / 10)
scaleReal += size;
if (scaleReal >= 0.18 && scaleReal <= 2) { // 不能直接取 0.2, 因为浏览器不同,可能每次size都不能刚好为 0.1
this.pageControl.beforeScale = Number(scaleReal.toFixed(2))
this.pageControl.scale = Number((Number(scaleReal.toFixed(2)) * 100).toFixed(0))
this.renderPage(this.pageControl.curPage)
}
}
5、pdf的截图如何实现 ?
使用html2canvas插件,第一参数为操作的canvas,然后做法就是将操作的canvas当成底图再进行新的canvas绘制,注意噢,drawImage的绘制参数不一定和我这个一样哦,这个需要你对canvas还是要了解一些哟~
图例:
html2canvas(canvas, {
scale: window.devicePixelRatio < 3 ? window.devicePixelRatio : 2, // 处理模糊问题
useCORS: true // 开启跨域,这个是必须的
}).then(canvas => {
const clipCanvas = document.createElement('canvas')
let oX = this.offset.x / this.pageControl.beforeScale // 此处还需要进行修改
let oY = this.offset.y / this.pageControl.beforeScale
clipCanvas.width = this.offset.w / this.pageControl.beforeScale
clipCanvas.height = this.offset.h / this.pageControl.beforeScale
// 截取
clipCanvas.getContext('2d').drawImage(canvas, oX, oY, clipCanvas.width, clipCanvas.height, 0, 0, clipCanvas.width, clipCanvas.height)
clipCanvas.toBlob((blob) => console.log(URL.createObjectURL(blob)))
})
6、为啥preview组件有两个canvas ,一个只读?一个可编辑?
原因是因为pdfjs-dist的page.render输出canvas是根据参数里的scale来生成的,虽然说可以实时更改canvas预览的大小,但是截图肯定还是要在原始比例下截图,所以只读canvas是作为截图的底图存在,并不作展示,可编辑canvas才是我们在页面上看到的。
7、截图框绘制和编辑时的卡顿问题
在mousemove里首先加上这几行就能解决卡顿,其他的user-select都可以不用加噢~
document.onmousemove = (e) => {
e.preventDefault();
if (e.stopPropagation) {
e.stopPropagation();
} else {
e.cancelBubble = true;
}
}
---不了解的可以评论哟,林大大哟原创---