1
0

canvas-photo-editor.js 61 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733
  1. import { throttle, debounce, getFileNameFromUrl } from "@/util";
  2. import { ElMessage, ElMessageBox, ElLoading } from "element-plus";
  3. import { jsPDF } from 'jspdf'
  4. import html2canvas from "html2canvas";
  5. import JSZip from 'jszip'
  6. import { saveAs } from 'file-saver'
  7. /**
  8. * Canvas 照片排版编辑器类(拖拽不重绘 + 纯坐标移动版)
  9. * 核心:1. 拖拽仅改坐标,松开再重绘 2. 纯Canvas坐标移动 3. 无transform
  10. */
  11. export class CanvasPhotoEditor {
  12. constructor(canvas, options = {}) {
  13. // 基础配置
  14. this.canvas = canvas
  15. this.ctx = canvas.getContext('2d')
  16. this.ctxOld = null
  17. this.rqWidth = 370
  18. this.rqHeight = 250
  19. this.pageWidth = options.pageWidth || 600
  20. this.pageHeight = options.pageHeight || 840
  21. this.pageMargin = options.pageMargin || 4
  22. this.canvasBgColor = options.canvasBgColor || '#efefef'
  23. this.pageBgColor = options.pageBgColor || '#ffffff'
  24. this.selectedPageColor = options.selectedPageColor || '#3b82f6'
  25. this.photos = options.photos || []
  26. this.show = options.show || false // true 纯显示,不编辑
  27. // 响应式数据
  28. this._layoutMode = 'double'//全局的页面布局
  29. this._pages = [{
  30. list: [null],
  31. layoutMode: this._layoutMode,//排版模式
  32. coordinate: [],//坐标信息
  33. }]
  34. this._selectedPageItem = {
  35. index: -1,
  36. pageIndex: -1,
  37. } //选中页面的图片
  38. this._scale = options.scale || 0.8
  39. this._scaleMin = 0.1
  40. this._scaleMax = 5.0
  41. this._selectedPageIndex = -1
  42. // 核心:绘制坐标偏移(替代transform)
  43. this.drawOffsetX = 0
  44. this.drawOffsetY = 0
  45. // 拖拽状态
  46. this.isDragging = false
  47. this.dragStartX = 0
  48. this.dragStartY = 0
  49. this.lastDrawOffsetX = 0
  50. this.lastDrawOffsetY = 0
  51. // 页面拖拽排序
  52. this.isPageDragging = false
  53. this.dragPageStartY = 0
  54. this.dragPageMoved = false
  55. // 动态标引状态
  56. this.tempArrow = {
  57. start: null, // { x, y }
  58. end: null, // { x, y }
  59. drawing: false // 是否正在绘制
  60. };
  61. //修改pages页码
  62. this.dragPageData = {
  63. drawing: false,//是否正在绘制页面移动
  64. index: -1,
  65. start: null, // { x, y }
  66. end: null, // { x, y }
  67. movedIndex: -1,//移动到的索引位置
  68. }
  69. //标引状态
  70. this.indexing = false//是否开启
  71. this.indexingNum = 0 //0 未点击 1 已生成开始点位 2 结束点位已生成
  72. this.indexingStartX = 0
  73. this.indexingStartY = 0
  74. this.indexingEndX = 0
  75. this.indexingEndY = 0
  76. this.indexingList = []//是否开启
  77. this.indexingLineList = []//绘制线条
  78. this.isLineDel = false//删除标引
  79. // 图片缓存
  80. this.imgCache = new Map()
  81. // 操作缓存
  82. this.history = []
  83. this.isHistory = true
  84. this.currentIndex = -1; // 当前在第几步
  85. // 绑定事件
  86. this.handleMouseDown = this.handleMouseDown.bind(this)
  87. this.handleMouseMove = this.handleMouseMove.bind(this)
  88. this.handleMouseUp = this.handleMouseUp.bind(this)
  89. this.handleMouseLeave = this.handleMouseLeave.bind(this)
  90. this.handleWheel = this.handleWheel.bind(this)
  91. // 初始化
  92. this.updata = options.updata
  93. this.init()
  94. }
  95. // --- 数据 getter/setter ---
  96. get pages() { return this._pages }
  97. set pages(newPages) {
  98. console.log('isArrayEqual1', this.indexingLineList, newPages)
  99. // const isSave = this._pages.length !== newPages.length?true:!this._pages.some((ele, index) => this.isArrayEqual(ele.list, newPages[index].list))
  100. this._pages = newPages
  101. this.resizeCanvas()
  102. this.drawAllPages() // 仅页面数据变化时重绘
  103. if (this.isHistory) {
  104. this.saveHistory()
  105. }
  106. }
  107. // --- 数据 getter/setter ---
  108. get selectedPageItem() { return this._selectedPageItem }
  109. set selectedPageItem(newItem) {
  110. this._selectedPageItem = { ...newItem }
  111. this.updata({
  112. selectedPageItem: this._selectedPageItem,
  113. selectedPageIndex: this.selectedPageIndex,
  114. historylength: this.history.length,
  115. currentIndex: this.currentIndex
  116. })
  117. }
  118. get scale() { return this._scale }
  119. set scale(newScale) {
  120. this._scale = Math.max(this._scaleMin, Math.min(this._scaleMax, newScale))
  121. this.drawAllPages() // 缩放时必须重绘(无法通过坐标偏移实现)
  122. }
  123. get selectedPageIndex() { return this._selectedPageIndex }
  124. set selectedPageIndex(newIndex) {
  125. this._selectedPageIndex = newIndex
  126. this.updata({
  127. selectedPageItem: this._selectedPageItem,
  128. selectedPageIndex: newIndex,
  129. historylength: this.history.length,
  130. currentIndex: this.currentIndex
  131. })
  132. }
  133. get layoutMode() { return this._layoutMode }
  134. set layoutMode(mode) {
  135. this._layoutMode = mode
  136. this.drawDragPreview()
  137. }
  138. // --- 初始化 ---
  139. init() {
  140. // 绑定事件
  141. this.canvas.addEventListener('wheel', this.handleWheel)//滚轮事件
  142. this.canvas.addEventListener('mousedown', this.handleMouseDown)//按下鼠标按键时触发
  143. document.addEventListener('mousemove', this.handleMouseMove)//移动鼠标时触发
  144. document.addEventListener('mouseup', this.handleMouseUp)//释放鼠标按键时触发
  145. document.addEventListener('mouseleave', this.handleMouseLeave)//离开触发
  146. // 初始化画布
  147. this.resizeCanvas()
  148. this.resetPosition()
  149. this.drawDragPreview() // 仅初始化时重绘一次
  150. this.saveHistory()
  151. }
  152. bindScrollWrapper(wrapper) {
  153. this.scrollWrapper = wrapper
  154. // this.drawDragPreview() // 仅初始化时重绘一次
  155. this.resizeCanvas()//更换画布和页面大小
  156. // 监听容器尺寸变化
  157. const resizeObserver = new ResizeObserver(() => {
  158. this.resetPosition()
  159. })
  160. resizeObserver.observe(wrapper)
  161. }
  162. destroy() {
  163. this.canvas.removeEventListener('wheel', this.handleWheel)//滚轮事件
  164. this.canvas.removeEventListener('mousedown', this.handleMouseDown)//按下鼠标按键时触发
  165. document.removeEventListener('mousemove', this.handleMouseMove)//移动鼠标时触发
  166. document.removeEventListener('mouseup', this.handleMouseUp)//释放鼠标按键时触发
  167. document.removeEventListener('mouseleave', this.handleMouseLeave)//离开触发
  168. this.imgCache.clear()
  169. }
  170. // --- 调整画布尺寸 ---
  171. resizeCanvas() {
  172. if (!this.canvas || !this.scrollWrapper) return
  173. // Canvas铺满容器
  174. this.canvas.width = this.scrollWrapper.clientWidth || this.pageWidth
  175. this.canvas.height = this.scrollWrapper.clientHeight || this.pageHeight
  176. // 移除所有transform样式
  177. this.canvas.style.transform = 'none'
  178. this.canvas.style.transformOrigin = '0 0'
  179. }
  180. // --- 核心:拖拽仅改坐标,不重绘 ---
  181. async handleMouseDown(e) {
  182. if (e.target !== this.canvas) return
  183. // ==========================================
  184. // 🔥 页面拖拽排序:按下时记录
  185. // ==========================================
  186. const rect = this.canvas.getBoundingClientRect()
  187. const mouseX = (e.clientX - rect.left - this.drawOffsetX) / this.scale
  188. const mouseY = (e.clientY - rect.top - this.drawOffsetY) / this.scale
  189. if (this.dragPageData.drawing) {
  190. this.dragPageData.end = { x: mouseX, y: mouseY }
  191. this.drawAllPages()
  192. return
  193. }
  194. // 记录拖拽起始状态
  195. if (this.indexing) {
  196. if (this.indexingNum == 0) return this.starIindexing(e)
  197. if (this.indexingNum == 1) return this.endIindexing(e)
  198. return
  199. }
  200. const pageIndex = this.getPageIndexByClick(e)
  201. const oldPageIndex = this.selectedPageIndex; // 保存旧选中页
  202. if (!this.show && pageIndex != undefined && pageIndex != -1 && (pageIndex !== this.selectedPageIndex || this._selectedPageItem.pageIndex != -1)) {
  203. // 1. 清除旧页面的边框
  204. // this.clearOldBorder(oldPageIndex);
  205. this.selectedPageIndex = pageIndex
  206. } else {
  207. this.selectedPageIndex = -1
  208. this.selectedPageItem = {
  209. ...this.selectedPageItem,
  210. index: -1,
  211. pageIndex: -1,
  212. }
  213. }
  214. console.log('pageIndex', this.pages, this.selectedPageItem, pageIndex)
  215. if(oldPageIndex == pageIndex == this.selectedPageIndex == this.selectedPageItem.pageIndex && pageIndex != -1 && this.selectedPageItem.index == -1){
  216. if(this.indexingLineList.length){
  217. await this.checkIndexing('修改页面排序将会清除所有标引是否继续?')
  218. this.drawAllPages()
  219. return
  220. }
  221. this.dragPageData.start = { x: mouseX, y: mouseY }
  222. this.dragPageData.drawing = true;
  223. this.dragPageData.index = pageIndex;
  224. this.dragPageData.movedIndex = pageIndex;
  225. this.canvas.style.cursor = 'copy'
  226. this.drawAllPages()
  227. return
  228. }
  229. this.isDragging = true
  230. this.dragStartX = e.clientX
  231. this.dragStartY = e.clientY
  232. this.lastDrawOffsetX = this.drawOffsetX
  233. this.lastDrawOffsetY = this.drawOffsetY
  234. this.canvas.style.cursor = 'grabbing'
  235. console.log('pageIndex', this.pages, this._selectedPageItem, this.selectedPageItem, pageIndex)
  236. this.drawAllPages()
  237. }
  238. handleMouseMove(e) {
  239. const drawing = this.tempArrow?.drawing
  240. const rect = this.canvas.getBoundingClientRect()
  241. const mouseX = (e.clientX - rect.left - this.drawOffsetX) / this.scale
  242. const mouseY = (e.clientY - rect.top - this.drawOffsetY) / this.scale
  243. if (this.indexing && drawing) {
  244. this.tempArrow.end = {x: mouseX, y: mouseY }
  245. this.drawAllPages()
  246. return
  247. }
  248. if(this.dragPageData.drawing){
  249. let endIndex = Math.round(mouseX / this.pageWidth)
  250. this.dragPageData.end = { x: mouseX, y: mouseY }
  251. let newEndIndex = endIndex<0?0:endIndex>this.pages.length-1?this.pages.length-1:endIndex
  252. if(newEndIndex != this.dragPageData.movedIndex){
  253. this.dragPageData.movedIndex = newEndIndex
  254. this.drawAllPages()
  255. }
  256. return
  257. }
  258. if (!this.isDragging) return
  259. // 仅更新坐标偏移,不触发重绘(核心优化)
  260. const deltaX = e.clientX - this.dragStartX
  261. const deltaY = e.clientY - this.dragStartY
  262. this.drawOffsetX = this.lastDrawOffsetX + deltaX
  263. this.drawOffsetY = this.lastDrawOffsetY + deltaY
  264. // const mouseX = (e.clientX - rect.left - this.drawOffsetX) / this.scale
  265. // const mouseY = (e.clientY - rect.top - this.drawOffsetY) / this.scale
  266. // ==========================================
  267. // // 🔥 页面拖拽排序:移动时交换顺序
  268. // // ==========================================
  269. // if (this.isPageDragging && this.selectedPageIndex !== -1) {
  270. // const deltaY = e.clientY - this.dragPageStartY
  271. // // 向下拖 → 向后交换
  272. // if (deltaY > 80 && this.selectedPageIndex < this.pages.length - 1) {
  273. // const newPages = [...this.pages]
  274. // const temp = newPages[this.selectedPageIndex]
  275. // newPages[this.selectedPageIndex] = newPages[this.selectedPageIndex + 1]
  276. // newPages[this.selectedPageIndex + 1] = temp
  277. // this.pages = newPages
  278. // this.selectedPageIndex += 1
  279. // this.dragPageStartY = e.clientY
  280. // this.dragPageMoved = true
  281. // return
  282. // }
  283. // // 向上拖 → 向前交换
  284. // if (deltaY < -80 && this.selectedPageIndex > 0) {
  285. // const newPages = [...this.pages]
  286. // const temp = newPages[this.selectedPageIndex]
  287. // newPages[this.selectedPageIndex] = newPages[this.selectedPageIndex - 1]
  288. // newPages[this.selectedPageIndex - 1] = temp
  289. // this.pages = newPages
  290. // this.selectedPageIndex -= 1
  291. // this.dragPageStartY = e.clientY
  292. // this.dragPageMoved = true
  293. // return
  294. // }
  295. // }
  296. if(this.indexing && this.indexingNum == 1){
  297. // 绘制中途轨迹
  298. }
  299. // 【关键】通过Canvas临时绘制偏移效果(替代重绘),避免拖拽时画面静止
  300. this.drawAllPages()
  301. }
  302. handleMouseUp(e) {
  303. console.log('handleMouseUp', this.isDragging)
  304. this.isDragging = false
  305. this.canvas.style.cursor = 'grab'
  306. if(this.dragPageData.drawing){//鼠标释放左键时,移动页面到指定位置)
  307. // 末尾必须加分号!!!
  308. let newPagelist = this.moveItem(this.pages, this.dragPageData.index, this.dragPageData.movedIndex)
  309. this.pages = newPagelist;
  310. this.drawAllPages()
  311. }
  312. this.dragPageData.drawing = false
  313. this.dragPageData.movedIndex = 0
  314. // 松开鼠标后仅重绘一次,确认最终位置
  315. // this.drawDragPreview()
  316. // 选中页面(仅点击时重绘一次)
  317. console.log('pageIndex', this.indexing, this.indexingNum)
  318. }
  319. moveItem(arr, fromIndex, toIndex) {
  320. const newArr = [...arr]
  321. const item = newArr.splice(fromIndex, 1)[0]
  322. newArr.splice(toIndex, 0, item)
  323. return newArr
  324. }
  325. handleMouseLeave() {
  326. console.log('handleMouseLeave', this.isDragging)
  327. this.isDragging = false
  328. this.dragPageData.drawing = false
  329. this.dragPageData.movedIndex = 0
  330. this.canvas.style.cursor = 'grab'
  331. // 离开画布后重绘一次
  332. this.drawDragPreview()
  333. }
  334. // --- 缩放逻辑(缩放必须重绘,无法通过坐标偏移实现)---
  335. handleWheel(e) {
  336. console.log('handleWheel', e.target)
  337. e.preventDefault()
  338. const rect = this.canvas.getBoundingClientRect()
  339. const mouseX = e.clientX - rect.left
  340. const mouseY = e.clientY - rect.top
  341. const delta = e.deltaY > 0 ? -0.1 : 0.1
  342. const oldScale = this.scale
  343. const newScale = Math.max(this._scaleMin, Math.min(this._scaleMax, oldScale + delta))
  344. const scaleDiff = newScale / oldScale
  345. // 调整偏移量,保证缩放中心在鼠标位置
  346. this.drawOffsetX = (mouseX - (mouseX - this.drawOffsetX) * scaleDiff)
  347. this.drawOffsetY = (mouseY - (mouseY - this.drawOffsetY) * scaleDiff)
  348. this.scale = newScale
  349. }
  350. //绘制标引起点
  351. starIindexing(e) {
  352. const rect = this.canvas.getBoundingClientRect()
  353. const mouseX = (e.clientX - rect.left - this.drawOffsetX) / this.scale
  354. const mouseY = (e.clientY - rect.top - this.drawOffsetY) / this.scale
  355. const PhotoIndex = this.getPhotoPositionInPage(e)
  356. console.log('indexingLineList', this.indexingLineList)
  357. let isLine = this.indexingLineList.findIndex(item => {
  358. let points = item.points || [];
  359. // 点数量不足2个,直接不匹配
  360. if (points.length < 2) return false;
  361. // 遍历所有相邻两点,判断鼠标是否在任意一段上
  362. for (let i = 0; i < points.length - 1; i++) {
  363. const p1 = points[i];
  364. const p2 = points[i + 1];
  365. // 只要有一段满足,就返回 true
  366. if (this.isPointOnLine(mouseX, mouseY, p1.x, p1.y, p2.x, p2.y)) {
  367. return true;
  368. }
  369. }
  370. // 所有线段都不满足
  371. return false;
  372. });
  373. this.isLineDel = false
  374. if (isLine != -1) {//选中已生成标引
  375. // 阻止事件冒泡和默认行为,避免鼠标事件持续触发
  376. e.stopPropagation();
  377. e.preventDefault();
  378. this.isLineDel = true
  379. return ElMessageBox.confirm("确定删除当前选中标引?", "提示", {
  380. confirmButtonText: "确定",
  381. cancelButtonText: "取消",
  382. type: "warning",
  383. }).then(async () => {
  384. this.indexingLineList.splice(isLine, 1);
  385. this.indexingLineList = [...this.indexingLineList];
  386. this.drawAllPages()
  387. ElMessage({
  388. type: "success",
  389. message: "删除成功",
  390. });
  391. this.isLineDel = false
  392. });
  393. } else {
  394. this.isLineDel = false
  395. }
  396. console.log('indexing', this.indexing, PhotoIndex, isLine)
  397. if (PhotoIndex.itemIndex == -1) {
  398. ElMessage.error("请选中对应的图片再进行标引操作!");
  399. return
  400. }
  401. this.indexingNum = 1
  402. this.indexingList.push(PhotoIndex)
  403. this.indexingStartX = mouseX
  404. this.indexingStartY = mouseY
  405. this.tempArrow.start = { x: mouseX, y: mouseY }
  406. this.tempArrow.drawing = true;
  407. console.log('indexing', this.indexing, PhotoIndex, mouseX, mouseY)
  408. }
  409. /**
  410. * 结束标引操作,计算鼠标在画布上的相对坐标并保存起始位置
  411. * @param {MouseEvent} e - 鼠标事件对象
  412. */
  413. endIindexing(e) {
  414. const PhotoIndex = this.getPhotoPositionInPage(e)
  415. if (PhotoIndex.itemIndex == -1) {
  416. ElMessage.error("请选中标引结束的图片操作!");
  417. return
  418. }
  419. const oldPhotoIndex = this.indexingList[0]
  420. console.log('oldPhotoIndex', oldPhotoIndex, this.indexingList)
  421. if (oldPhotoIndex.pageIndex == PhotoIndex.pageIndex && oldPhotoIndex.itemIndex == PhotoIndex.itemIndex) {
  422. ElMessage.error("标引开始和结束图片不能相同!");
  423. return
  424. }
  425. console.log('indexing1111', this.indexing, oldPhotoIndex.pageIndex - PhotoIndex.pageIndex)
  426. if (Math.abs(oldPhotoIndex.pageIndex - PhotoIndex.pageIndex) > 1) {
  427. ElMessage.error("标引不支持跨页面!");
  428. return
  429. }
  430. this.indexingNum = 2
  431. const rect = this.canvas.getBoundingClientRect()
  432. const mouseX = (e.clientX - rect.left - this.drawOffsetX) / this.scale
  433. const mouseY = (e.clientY - rect.top - this.drawOffsetY) / this.scale
  434. let coordinate = this.pages[PhotoIndex.pageIndex].coordinate[PhotoIndex.itemIndex]
  435. console.log('coordinate', PhotoIndex, oldPhotoIndex)
  436. this.indexingEndX = mouseX
  437. this.indexingEndY = mouseY
  438. let startInfo = this.indexingList[0]
  439. this.indexingList.push(PhotoIndex)
  440. let endIindexing1 = {//默认同页面
  441. x: this.indexingEndX,
  442. y: startInfo.itemIndex > PhotoIndex.itemIndex ? (coordinate.y + coordinate.height) : coordinate.y,
  443. }
  444. if (startInfo.pageIndex != PhotoIndex.pageIndex) {
  445. endIindexing1 = {//默认同页面
  446. x: startInfo.pageIndex > PhotoIndex.pageIndex ? (coordinate.x + coordinate.width) : coordinate.x,
  447. y: this.indexingEndY,
  448. }
  449. }
  450. let points = [{
  451. x: this.indexingStartX,
  452. y: this.indexingStartY,
  453. }, endIindexing1]
  454. if(PhotoIndex.pageIndex != oldPhotoIndex.pageIndex && Math.abs(this.indexingStartY - endIindexing1.y)>this.rqHeight){//需要折现换行
  455. let addPage = PhotoIndex.pageIndex>oldPhotoIndex.pageIndex?PhotoIndex.pageIndex:oldPhotoIndex.pageIndex
  456. let newX = addPage*(this.pageWidth + this.pageMargin)
  457. points.splice(1, 0, { x: newX, y: this.indexingStartY }, { x: newX, y: endIindexing1.y })
  458. }
  459. this.tempArrow.start = null
  460. this.tempArrow.end = null
  461. this.tempArrow.drawing = false;
  462. this.drawGuideLine(points, coordinate, this.indexingList)
  463. this.drawAllPages()
  464. console.log('tempArrow', this.indexing, this.tempArrow)
  465. }
  466. // --- 拖拽预览:轻量级绘制,仅更新偏移(替代全量重绘)---
  467. drawDragPreview() {
  468. // 快速绘制当前状态(跳过图片,提升流畅度)
  469. throttle(this.drawAllPages, 50)
  470. }
  471. // --- 拖拽预览:轻量级绘制,仅更新偏移(替代全量重绘)---
  472. /**
  473. * 绘制所有标引线
  474. *
  475. * 遍历标引线列表,逐条绘制每条标引线到画布上
  476. */
  477. drawGuideLineAll(val) {
  478. if (this.indexingLineList.length) {
  479. for (let i = 0; i < this.indexingLineList.length; i++) {
  480. const { points, coordinate, indexingList } = this.indexingLineList[i]
  481. this.drawGuideLine(points, coordinate, indexingList, val)
  482. }
  483. }
  484. }
  485. // 绘制临时标引
  486. drawInterimLine(points) {//val 不是重绘才保存历史记录,否则是重绘就不保存了
  487. const ctx = this.ctx;
  488. let color = '#ff0000';
  489. let lineWidth = 2;
  490. // 1. 保存当前上下文状态(避免污染)
  491. ctx.save();
  492. // 2. 应用和全量绘制一致的偏移/缩放(保证坐标对齐)
  493. ctx.translate(this.drawOffsetX, this.drawOffsetY);
  494. ctx.scale(this.scale, this.scale);
  495. if (points.length < 2) return;
  496. ctx.strokeStyle = color;
  497. ctx.fillStyle = color;
  498. ctx.lineWidth = lineWidth;
  499. ctx.lineCap = 'round';
  500. ctx.lineJoin = 'round';
  501. // 绘制折线
  502. ctx.beginPath();
  503. ctx.moveTo(points[0].x, points[0].y);
  504. for (let i = 1; i < points.length; i++) {
  505. ctx.lineTo(points[i].x, points[i].y);
  506. }
  507. ctx.stroke();
  508. ctx.restore();
  509. }
  510. // 绘制标引
  511. drawGuideLine(points, coordinate, indexingList, val = false) {//val 不是重绘才保存历史记录,否则是重绘就不保存了
  512. const ctx = this.ctx;
  513. let color = '#ff0000';
  514. let lineWidth = 2;
  515. // 1. 保存当前上下文状态(避免污染)
  516. ctx.save();
  517. // 2. 应用和全量绘制一致的偏移/缩放(保证坐标对齐)
  518. ctx.translate(this.drawOffsetX, this.drawOffsetY);
  519. ctx.scale(this.scale, this.scale);
  520. if (points.length < 2) return;
  521. ctx.strokeStyle = color;
  522. ctx.fillStyle = color;
  523. ctx.lineWidth = lineWidth;
  524. ctx.lineCap = 'round';
  525. ctx.lineJoin = 'round';
  526. // 绘制折线
  527. ctx.beginPath();
  528. ctx.moveTo(points[0].x, points[0].y);
  529. for (let i = 1; i < points.length; i++) {
  530. ctx.lineTo(points[i].x, points[i].y);
  531. }
  532. ctx.stroke();
  533. // 绘制起点圆点
  534. const first = points[0];
  535. const last = points[points.length - 1];
  536. ctx.beginPath();
  537. ctx.arc(first.x, first.y, 4, 0, Math.PI * 2);
  538. ctx.fill();
  539. // ctx.restore();
  540. // 绘制T边
  541. let startInfo = indexingList[0]
  542. let endInfo = indexingList[1]
  543. ctx.beginPath();
  544. if (startInfo.pageIndex == endInfo.pageIndex) {//判断上下就行
  545. ctx.moveTo(coordinate.x, last.y);
  546. ctx.lineTo((coordinate.x + coordinate.width), last.y);
  547. } else {//左右
  548. ctx.moveTo(last.x, coordinate.y);
  549. ctx.lineTo(last.x, (coordinate.y + coordinate.height));
  550. }
  551. ctx.stroke();
  552. ctx.restore();
  553. if (this.indexing && !val) {
  554. this.indexingLineList.push({ points, coordinate, indexingList })
  555. this.indexingNum = 0
  556. this.indexingList = []
  557. // this.saveHistory()
  558. }
  559. // drawArrow(ctx, last2.x, last2.y, last.x, last.y, color);
  560. // this.indexingNum = 0
  561. }
  562. // --- 计算点击的页面标引 ---
  563. getPageIndexByClick(e) {
  564. const rect = this.canvas.getBoundingClientRect()
  565. const mouseX = (e.clientX - rect.left - this.drawOffsetX) / this.scale
  566. const mouseY = (e.clientY - rect.top - this.drawOffsetY) / this.scale
  567. for (let i = 0; i < this.pages.length; i++) {
  568. const pageStartX = i * (this.pageWidth + this.pageMargin)
  569. const pageEndX = pageStartX + this.pageWidth
  570. if (
  571. mouseX >= pageStartX && mouseX <= pageEndX &&
  572. mouseY >= 0 && mouseY <= this.pageHeight
  573. ) {
  574. let newindex = this.pages[i].coordinate.findIndex(item => this.isPointInRect(mouseX, mouseY, item))
  575. if (!this.indexing) {//绘图模式不更新选中项,绘图模式下不触发选中事件
  576. this.selectedPageItem = {
  577. index: newindex != -1 && !!(this.pages[i].list[newindex]) ? newindex : -1,
  578. pageIndex: i,
  579. ...this.pages[i].coordinate[newindex]
  580. }
  581. }
  582. return i
  583. }
  584. }
  585. return -1
  586. }
  587. isPointInRect(px, py, item = {}) {
  588. let rectX = item.x, rectY = item.y, rectWidth, rectHeight
  589. // 核心判断逻辑:点的坐标在矩形的上下左右边界之间
  590. const inXRange = px >= item.x && px <= item.x + item.width;
  591. const inYRange = py >= item.y && py <= item.y + item.height;
  592. return inXRange && inYRange;
  593. }
  594. // --- 计算拖拽到页面内的位置 ---
  595. getPhotoPositionInPage(e) {
  596. const pageIndex = this.getPageIndexByClick(e)
  597. if (pageIndex === -1) return { pageIndex: -1, itemIndex: -1 }
  598. const selectPage = this.pages[pageIndex]
  599. const layoutMode = selectPage.layoutMode || this.layoutMode
  600. const rect = this.canvas.getBoundingClientRect()
  601. const mouseY = (e.clientY - rect.top - this.drawOffsetY) / this.scale
  602. const mouseX = (e.clientX - rect.left - this.drawOffsetX) / this.scale
  603. const layout = this.getItemSize(layoutMode)
  604. let itemIndex = -1
  605. if (layoutMode === 'double') {
  606. itemIndex = this.isPointInRect(mouseX, mouseY, selectPage.coordinate[0]) ? 0 : this.isPointInRect(mouseX, mouseY, selectPage.coordinate[1]) ? 1 : -1
  607. } else {
  608. itemIndex = this.isPointInRect(mouseX, mouseY, selectPage.coordinate[0]) ? 0 : -1
  609. }
  610. return { pageIndex, itemIndex, layoutMode, count: layout.count }
  611. }
  612. /**
  613. * 判断点是否在线段上
  614. * @param {number} px 鼠标点 X
  615. * @param {number} py 鼠标点 Y
  616. * @param {number} x1 线段起点 X
  617. * @param {number} y1 线段起点 Y
  618. * @param {number} x2 线段终点 X
  619. * @param {number} y2 线段终点 Y
  620. * @param {number} r 允许的误差范围(线宽容错)
  621. * @return {boolean}
  622. */
  623. isPointOnLine(px, py, x1, y1, x2, y2, r = 10) {
  624. // 1. 计算点到线段的垂直距离
  625. const A = px - x1;
  626. const B = py - y1;
  627. const C = x2 - x1;
  628. const D = y2 - y1;
  629. const dot = A * C + B * D;
  630. const lenSq = C * C + D * D;
  631. // 线段长度为 0 时
  632. if (lenSq === 0) {
  633. return Math.hypot(px - x1, py - y1) < r;
  634. }
  635. // 计算投影比例
  636. let param = dot / lenSq;
  637. // 最近点
  638. let xx, yy;
  639. if (param < 0) {
  640. xx = x1; yy = y1;
  641. } else if (param > 1) {
  642. xx = x2; yy = y2;
  643. } else {
  644. xx = x1 + param * C;
  645. yy = y1 + param * D;
  646. }
  647. // 2. 判断距离是否小于容错值
  648. return Math.hypot(px - xx, py - yy) < r;
  649. }
  650. // --- 页面尺寸计算 ---
  651. getTotalCanvasWidth() {
  652. return this.pages.length * (this.pageWidth + this.pageMargin) - this.pageMargin
  653. }
  654. getItemSize(value) {
  655. const layoutMode = value || this.layoutMode
  656. const padding = 20
  657. if (layoutMode === 'single') {
  658. return {
  659. width: this.rqWidth,//this.pageWidth - 2 * padding,
  660. height: this.rqHeight,
  661. x: (this.pageWidth - this.rqWidth) / 2,
  662. y: (this.pageHeight - this.rqHeight) / 2,
  663. count: 1
  664. }
  665. } else if (layoutMode === 'landscape') {
  666. return {
  667. width: this.rqHeight,//this.pageWidth - 2 * padding,
  668. height: this.rqWidth,
  669. x: (this.pageWidth - this.rqHeight) / 2,
  670. y: (this.pageHeight - this.rqWidth) / 2,
  671. count: 1
  672. }
  673. } else {
  674. const itemHeight = (this.pageHeight - 3 * padding - 40) / 2
  675. return {
  676. width: this.rqWidth,//this.pageWidth - 2 * padding,
  677. height: this.rqHeight,
  678. x: (this.pageWidth - this.rqWidth) / 2,
  679. y1: 87,
  680. y2: 418,
  681. count: 2
  682. }
  683. }
  684. }
  685. getCoordinate(pageX, layout) {//生成每个页的图片坐标信息
  686. let list = [{ x: pageX + layout.x, y: layout.count == 1 ? layout.y : layout.y1, width: layout.width, height: layout.height }]
  687. if (layout.y2) {
  688. list.push({ x: pageX + layout.x, y: layout.y2, width: layout.width, height: layout.height })
  689. }
  690. return list
  691. }
  692. //绘制选中页码或者图片的边框
  693. // drawAllPagesBorder(){
  694. // const ctx = this.ctxOld
  695. // // 页面边框
  696. // // 应用坐标偏移和缩放
  697. // ctx.strokeStyle = this.selectedPageColor
  698. // ctx.lineWidth = 4 / this.scale
  699. // const pageX = this.selectedPageIndex * (this.pageWidth + this.pageMargin)
  700. // ctx.strokeRect(pageX, 0, this.pageWidth, this.pageHeight)
  701. // console.log('drawAllPagesBorder', pageX, 0, this.pageWidth, this.pageHeight, this.selectedPageIndex)
  702. // }
  703. // 改造后:独立绘制边框,可单独调用
  704. drawAllPagesBorder(pageIndex) {
  705. if (!this.ctx || this.selectedPageIndex === -1) return;
  706. const ctx = this.ctx;
  707. // 1. 保存当前上下文状态(避免污染)
  708. ctx.save();
  709. // 2. 应用和全量绘制一致的偏移/缩放(保证坐标对齐)
  710. ctx.translate(this.drawOffsetX, this.drawOffsetY);
  711. ctx.scale(this.scale, this.scale);
  712. // 3. 先清除原边框区域(避免边框重叠)
  713. const pageX = this.selectedPageIndex * (this.pageWidth + this.pageMargin);
  714. // 清除页面边框区域(多扩一点,避免残留)
  715. // ctx.clearRect(
  716. // pageX - 5 / this.scale,
  717. // -5 / this.scale,
  718. // this.pageWidth + 10 / this.scale,
  719. // this.pageHeight + 10 / this.scale
  720. // );
  721. ctx.strokeStyle = pageIndex === this.selectedPageIndex ? this.selectedPageColor : this.canvasBgColor;
  722. // 5. 绘制选中图片边框(如果有)
  723. if (this._selectedPageItem) {
  724. ctx.lineWidth = 4 / this.scale;
  725. ctx.strokeRect(
  726. this._selectedPageItem.x,
  727. this._selectedPageItem.y,
  728. this._selectedPageItem.width,
  729. this._selectedPageItem.height
  730. );
  731. } else {
  732. // 4. 绘制选中页面边框
  733. ctx.lineWidth = 4 / this.scale;
  734. ctx.strokeRect(pageX, 0, this.pageWidth, this.pageHeight);
  735. }
  736. // 6. 恢复上下文状态
  737. ctx.restore();
  738. }
  739. // 清除指定页面的旧边框(切换选中页时调用)
  740. clearOldBorder(oldPageIndex) {
  741. if (!this.ctx || oldPageIndex === -1) return;
  742. const ctx = this.ctx;
  743. ctx.save();
  744. ctx.translate(this.drawOffsetX, this.drawOffsetY);
  745. ctx.scale(this.scale, this.scale);
  746. const pageX = oldPageIndex * (this.pageWidth + this.pageMargin);
  747. // 清除旧边框区域
  748. // ctx.clearRect(
  749. // pageX - 5 / this.scale,
  750. // -5 / this.scale,
  751. // this.pageWidth + 10 / this.scale,
  752. // this.pageHeight + 10 / this.scale
  753. // );
  754. ctx.restore();
  755. }
  756. // --- 全量重绘(仅在必要时调用)---
  757. drawAllPages(photos1 = []) {
  758. if (photos1.length) {
  759. this.photos = photos1.map(ele => {
  760. return {
  761. ...ele,
  762. layoutMode: this.layoutMode,//排版模式
  763. coordinate: [],//坐标信息
  764. name: ele.name || getFileNameFromUrl(ele.url)
  765. }
  766. })
  767. }
  768. if (!this.ctx) return
  769. const ctx = this.ctx
  770. const canvasWidth = this.canvas.width
  771. const canvasHeight = this.canvas.height
  772. // 清空画布
  773. ctx.clearRect(0, 0, canvasWidth, canvasHeight)
  774. ctx.save()
  775. // 应用坐标偏移和缩放
  776. ctx.translate(this.drawOffsetX, this.drawOffsetY)
  777. ctx.scale(this.scale, this.scale)
  778. // 绘制背景
  779. ctx.fillStyle = this.canvasBgColor
  780. ctx.fillRect(
  781. -this.drawOffsetX / this.scale,
  782. -this.drawOffsetY / this.scale,
  783. canvasWidth / this.scale,
  784. canvasHeight / this.scale
  785. )
  786. // 绘制所有页面(包含图片)
  787. this.pages.forEach((pagePhotos, pageIndex) => {
  788. const pageX = pageIndex * (this.pageWidth + this.pageMargin)
  789. // 页面背景
  790. ctx.fillStyle = this.pageBgColor
  791. ctx.fillRect(pageX, 0, this.pageWidth, this.pageHeight)
  792. // 页面边框
  793. if (this._selectedPageItem.index == -1) {
  794. ctx.strokeStyle = pageIndex === this.selectedPageIndex
  795. ? this.selectedPageColor
  796. : '#dddddd'
  797. ctx.lineWidth = pageIndex === this.selectedPageIndex ? 4 * this.scale : 2 * this.scale
  798. ctx.strokeRect(pageX, 0, this.pageWidth, this.pageHeight)
  799. }
  800. // 页码
  801. ctx.fillStyle = '#666666'
  802. ctx.font = `${16 * this.scale}px sans-serif`
  803. ctx.textAlign = 'right'
  804. ctx.fillText(
  805. `第 ${pageIndex + 1} 页`,
  806. pageX + this.pageWidth - 20,
  807. this.pageHeight - 20
  808. )
  809. // 绘制图片(重构核心:调用独立方法)
  810. const layoutModePages = pagePhotos.layoutMode || this.layoutMode
  811. const layout = this.getItemSize(layoutModePages)
  812. pagePhotos.coordinate = this.getCoordinate(pageX, layout)
  813. let newList = this.padArrayLength(pagePhotos.list, layout.count)
  814. newList.forEach((photoId, itemIndex) => {
  815. let itemY = layoutModePages === 'single' || layoutModePages === 'landscape'
  816. ? layout.y
  817. : (itemIndex === 0 ? layout.y1 : layout.y2)
  818. // 图片占位框
  819. ctx.fillStyle = '#ffffff'
  820. ctx.fillRect(pageX + layout.x, itemY, layout.width, layout.height)
  821. // coordinate.push({x: pageX + layout.x, y: itemY, width: layout.width, height:layout.height})
  822. ctx.strokeStyle = '#e5e7eb'
  823. ctx.lineWidth = 1 / this.scale
  824. ctx.strokeRect(pageX + layout.x, itemY, layout.width, layout.height)
  825. // 设置填充颜色
  826. ctx.fillStyle = '#D9D9D9'; // 黄色
  827. // 绘制填充矩形
  828. ctx.fillRect(pageX + layout.x, itemY, layout.width, layout.height);
  829. // 说明文字占位框
  830. if (this._selectedPageItem && this._selectedPageItem.pageIndex === pageIndex && this._selectedPageItem.index === itemIndex) {
  831. ctx.strokeStyle = this.selectedPageColor
  832. ctx.lineWidth = 2 / this.scale
  833. ctx.strokeRect(pageX + layout.x - 1, itemY - 1, layout.width + 2, layout.height + 2)
  834. ctx.strokeStyle = '#e5e7eb'
  835. }
  836. this.renderSinglePhoto(ctx, pageX, itemY, layout, photoId)
  837. })
  838. })
  839. ctx.restore()
  840. console.log('this.tempArrow', this.selectedPageItem)
  841. if(this.tempArrow.drawing && this.indexing){//绘制实时鼠标标引
  842. this.drawInterimLine([this.tempArrow.start,this.tempArrow.end])
  843. }
  844. if(this.dragPageData.drawing && this.dragPageData.movedIndex){//绘制实时鼠标标引
  845. this.drawPlusIcon(this.dragPageData.movedIndex)
  846. }
  847. this.drawGuideLineAll(true)//绘制标引
  848. }
  849. // 新增:独立渲染单张图片(确保裁剪上下文独立)
  850. /**
  851. * 在Canvas上渲染单个图片
  852. *
  853. * @param {CanvasRenderingContext2D} ctx - Canvas渲染上下文
  854. * @param {number} pageX - 页面起始X坐标
  855. * @param {number} itemY - 项目起始Y坐标
  856. * @param {Object} layout - 布局配置,包含宽高和位置信息
  857. * @param {string} photoId - 图片ID
  858. */
  859. renderSinglePhoto(ctx, pageX, itemY, layout, photoId) {
  860. const photo = this.photos.find(p => p.id === photoId)
  861. // if (!photo) return
  862. let img = this.imgCache.get(photoId)
  863. console.log(img, photo,'imgCache', this.photos)
  864. if (!img && photo && photo.url) {
  865. img = new Image()
  866. img.crossOrigin = 'anonymous'
  867. // 仅首次绑定onload,避免重复绑定
  868. img.onload = () => {
  869. // 加载完成后触发全量重绘(保证使用最新状态)
  870. this.drawAllPages()
  871. }
  872. img.onerror = (err) => {
  873. }
  874. img.src = photo.url
  875. this.imgCache.set(photoId, img)
  876. return
  877. }
  878. // 1. 保存当前上下文(独立于外层)
  879. ctx.save();
  880. // 3. 设置裁剪区域(仅当前图片容器)
  881. ctx.beginPath();
  882. ctx.rect(pageX + layout.x, itemY, layout.width, layout.height);
  883. ctx.clip();
  884. //添加说明文字
  885. // 3. 绘制文字(基于图片实际区域居中,且在裁剪区内)
  886. const text = photo && photo.name || '说明文字';
  887. // 文字居中坐标:图片实际显示区域的正中心
  888. // 文字样式(适配缩放)
  889. const fontSize = 14;
  890. ctx.font = `${fontSize}px sans-serif`;
  891. ctx.fillStyle = 'red'; // 白色文字更醒目
  892. ctx.textAlign = 'center'; // 水平居中
  893. ctx.textBaseline = 'middle'; // 垂直居中
  894. // 可选:绘制半透明背景遮罩(避免文字和图片内容重叠看不清)
  895. const textWidth = (ctx.measureText(text).width / 2);
  896. const textHeight = fontSize;
  897. // 4. 绘制图片(超出裁剪区域的部分会被隐藏)
  898. // if(this.layoutMode === 'landscape'){
  899. // ctx.rotate(Math.PI / 2);
  900. // }
  901. // 5. 恢复上下文(仅影响当前图片,不污染全局)
  902. // 4. 恢复上下文(裁剪失效)
  903. // 图片未加载完成则跳过
  904. const jxx = pageX + layout.x;
  905. if (!img || !img.complete || img.width === 0 || img.height === 0) {//无图
  906. } else {
  907. // 重新计算绘制参数(确保使用最新状态)
  908. const scaleX = layout.width / img.width;
  909. const scaleY = layout.height / img.height;
  910. const coverScale = Math.max(scaleX, scaleY);
  911. const scaledWidth = img.width * coverScale;
  912. const scaledHeight = img.height * coverScale;
  913. const offsetXInLayout = (layout.width - scaledWidth) / 2;
  914. const offsetYInLayout = (layout.height - scaledHeight) / 2;
  915. const drawX = pageX + layout.x + offsetXInLayout;
  916. const drawY = itemY + offsetYInLayout;
  917. const textCenterX = scaledWidth; // 图片水平中点
  918. const textCenterY = drawY + scaledHeight + 10; // 图片垂直中点
  919. ctx.drawImage(img, drawX, drawY, scaledWidth, scaledHeight);
  920. }
  921. // 【核心】严格隔离裁剪上下文
  922. ctx.restore();
  923. ctx.setLineDash([]);
  924. // 绘制文字(最后绘制文字,避免被背景覆盖)
  925. ctx.fillStyle = '#8C8C8C';
  926. let textX = layout.width //textWidth > (this.rqWidth / 2) ? drawX : drawX + (textWidth / 2)
  927. // ctx.fillText(text, jxx + textWidth + (layout.width / 2), itemY + layout.height + 30, layout.width);
  928. this.drawCenteredTextWithEllipsis(ctx, text, jxx + (layout.width / 2), itemY + layout.height + 40, layout.width - 20, 24, 2, '16px Microsoft Yahei')
  929. ctx.fillStyle = 'rgba(0, 0, 0, 1)'; // 半透明黑色背景
  930. ctx.setLineDash([1, 1]);
  931. ctx.strokeRect(
  932. jxx,
  933. itemY + layout.height + 10,
  934. layout.width,
  935. 50
  936. );
  937. ctx.setLineDash([]);
  938. }
  939. // --- 页面操作 ---
  940. autoLayout(selectedPhotos=[]) {
  941. let newList = this.pages.flatMap(item => item.list.filter(i => i))
  942. const layout = this.getItemSize()
  943. let newPages = []
  944. const pageX = this.pages.length * (this.pageWidth + this.pageMargin)
  945. let currentPage = {
  946. list: [],
  947. layoutMode: this.layoutMode, //页码布局类型
  948. coordinate: this.getCoordinate(pageX, layout), //坐标信息
  949. }
  950. let list = []
  951. const newArr = [...newList, ...selectedPhotos]
  952. console.log(newArr, selectedPhotos, 'newArr')
  953. newArr.forEach((photoId, photoIndex) => {
  954. list.push(photoId)
  955. if(list.length == layout.count){
  956. newPages.push({...currentPage, list: list, })
  957. list = []
  958. }else if(photoIndex == newArr.length -1){//最后一条直接写入
  959. newPages.push({...currentPage, list: list, })
  960. }
  961. })
  962. this.pages = newPages.length > 0 ? newPages : [{ list: [], }]
  963. this.resetPosition()
  964. return this.pages
  965. }
  966. insertBlankPage(direction) {
  967. //direction true 右边 false 左边
  968. const layout = this.getItemSize()
  969. // if (this.selectedPageIndex === -1) return this.pages
  970. const newPages = [...this.pages]
  971. const PageIndex = direction ? this.selectedPageIndex + 1 : this.selectedPageIndex;
  972. newPages.splice(PageIndex, 0, {
  973. list: [],
  974. layoutMode: this.layoutMode, //页码布局类型
  975. coordinate: [], //坐标信息
  976. })
  977. this.pages = newPages
  978. if (!direction) this.selectedPageIndex++
  979. this.resetPosition()
  980. return this.pages
  981. }
  982. setPageType(direction) {
  983. //direction true 右边 false 左边
  984. // if (this.selectedPageIndex === -1) return this.pages
  985. let newPages = [...this.pages]
  986. let newPageItem = {}
  987. const PageIndex = this.selectedPageIndex;
  988. let list = newPages[PageIndex] && newPages[PageIndex].list?.filter(i => i) || []
  989. newPages[PageIndex].layoutMode = direction
  990. if (list.length == 2) {
  991. newPageItem = {
  992. coordinate: [newPages[PageIndex].coordinate[1]],
  993. layoutMode: direction,
  994. list: [list[1]],
  995. }
  996. newPages[PageIndex].list.length = 1
  997. newPages.splice(PageIndex, 0, newPageItem)
  998. }
  999. if (!direction) this.selectedPageIndex++
  1000. this.pages = newPages
  1001. // this.resetPosition()
  1002. return this.pages
  1003. }
  1004. deleteSelectedPage() {
  1005. console.log(this.selectedPageItem, 'selectedPageItem', this.pages)
  1006. if (this.selectedPageItem.index == -1 &&( this.selectedPageIndex === -1 || this.pages.length <= 1)) return this.pages
  1007. const newPages = [...this.pages]
  1008. if(this.selectedPageItem.index == -1){//删除整页
  1009. newPages.splice(this.selectedPageIndex, 1)
  1010. }else{//删除单个图片
  1011. newPages.forEach((ele, index) => {
  1012. if(index == this.selectedPageIndex){
  1013. ele.list.splice(this.selectedPageItem.index, 1)
  1014. }
  1015. })
  1016. }
  1017. this.pages = newPages
  1018. this.selectedPageIndex = Math.min(this.selectedPageIndex, this.pages.length - 1)
  1019. this.resetPosition()
  1020. return this.pages
  1021. }
  1022. // --- 重置位置(居中)---
  1023. resetPosition() {
  1024. if (!this.scrollWrapper) return
  1025. const totalWidth = this.getTotalCanvasWidth() * this.scale
  1026. const totalHeight = this.pageHeight * this.scale
  1027. const wrapperWidth = this.scrollWrapper.clientWidth
  1028. const wrapperHeight = this.scrollWrapper.clientHeight
  1029. // 计算居中坐标偏移
  1030. this.drawOffsetX = (wrapperWidth - totalWidth) / 2
  1031. this.drawOffsetY = (wrapperHeight - totalHeight) / 2
  1032. this.drawAllPages() // 重置位置时重绘一次
  1033. }
  1034. // --- 重置缩放 ---
  1035. resetZoom() {
  1036. this.scale = 1.0
  1037. this.resetPosition()
  1038. }
  1039. isActiveImg() {
  1040. return this._selectedPageItem.index !== -1 || false
  1041. }
  1042. /**
  1043. * 补全数组到指定长度
  1044. * @param {Array} arr - 原始数组
  1045. * @param {number} targetLength - 目标长度
  1046. * @param {any} fillValue - 补位值(支持函数,动态生成)
  1047. * @returns {Array} 补全后的新数组
  1048. */
  1049. padArrayLength(arr = [], targetLength, fillValue = null) {
  1050. // 若原数组长度≥目标长度,直接返回原数组的拷贝(避免修改原数组)
  1051. if (arr.length >= targetLength) {
  1052. return [...arr];
  1053. }
  1054. const needAdd = targetLength - arr.length;
  1055. let fillArr;
  1056. // 如果补位值是函数,动态生成;否则填充固定值
  1057. if (typeof fillValue === 'function') {
  1058. fillArr = Array.from({ length: needAdd }, (_, i) => fillValue(i, arr.length));
  1059. } else {
  1060. fillArr = new Array(needAdd).fill(fillValue);
  1061. }
  1062. return [...arr, ...fillArr];
  1063. }
  1064. /**
  1065. * Canvas 绘制多行居中文字,超出指定行数显示省略号
  1066. * @param {CanvasRenderingContext2D} ctx - Canvas 上下文
  1067. * @param {string} text - 要绘制的文字
  1068. * @param {number} x - 文本块的中心X坐标(整体居中的基准)
  1069. * @param {number} y - 文本块的中心Y坐标(整体居中的基准)
  1070. * @param {number} maxWidth - 文本块的最大宽度(超出换行)
  1071. * @param {number} lineHeight - 行高
  1072. * @param {number} maxLines - 最大显示行数(超出显示省略号)
  1073. * @param {string} font - 字体样式(如 '16px Microsoft Yahei')
  1074. */
  1075. drawCenteredTextWithEllipsis(ctx, text, x, y, maxWidth, lineHeight, maxLines, font) {
  1076. // 1. 设置字体样式(必须先设置,否则measureText计算不准确)
  1077. ctx.font = font;
  1078. ctx.textBaseline = 'top'; // 基准线设为顶部,方便计算行位置
  1079. ctx.textAlign = 'center'; // 水平居中
  1080. // ctx.textBaseline = 'middle'; // 垂直居中
  1081. // 2. 拆分文字为多行(处理换行和截断)
  1082. let words = text.split(''); // 按字符拆分(兼容中文)
  1083. let line = ''; // 当前行文字
  1084. let lines = []; // 最终的行数组
  1085. let isTruncated = false; // 是否需要截断
  1086. for (let i = 0; i < words.length; i++) {
  1087. let testLine = line + words[i];
  1088. let metrics = ctx.measureText(testLine);
  1089. let testWidth = metrics.width;
  1090. // 如果当前测试行宽度超过最大宽度,就换行
  1091. if (testWidth > maxWidth && i > 0) {
  1092. lines.push(line);
  1093. line = words[i];
  1094. // 超出最大行数,停止拆分并标记截断
  1095. if (lines.length >= maxLines) {
  1096. isTruncated = true;
  1097. break;
  1098. }
  1099. } else {
  1100. line = testLine;
  1101. }
  1102. }
  1103. // 处理最后一行
  1104. if (!isTruncated) {
  1105. lines.push(line);
  1106. // 如果最后一行超出行数,仍需截断
  1107. if (lines.length > maxLines) {
  1108. isTruncated = true;
  1109. lines = lines.slice(0, maxLines);
  1110. }
  1111. }
  1112. // 3. 处理省略号(仅当截断时)
  1113. if (isTruncated) {
  1114. // 从最后一行末尾删除字符,直到能放下省略号
  1115. let lastLine = lines[maxLines - 1];
  1116. while (ctx.measureText(lastLine + '...').width > maxWidth && lastLine.length > 0) {
  1117. lastLine = lastLine.slice(0, -1);
  1118. }
  1119. lines[maxLines - 1] = lastLine + '...';
  1120. }
  1121. // 4. 计算整体居中的起始位置
  1122. // 文本块的总高度 = 实际显示行数 * 行高
  1123. const totalTextHeight = lines.length * lineHeight;
  1124. // 起始Y = 中心Y - 总高度/2(垂直居中)
  1125. const startY = y - totalTextHeight / 2;
  1126. // 每行的起始X = 中心X - maxWidth/2(水平居中,基于文本块宽度)
  1127. const startX = x - maxWidth / 2;
  1128. // 5. 逐行绘制文字(最终居中效果)
  1129. ctx.textAlign = 'center'; // 保持left,基于startX绘制
  1130. for (let i = 0; i < lines.length; i++) {
  1131. const currentY = startY + i * lineHeight;
  1132. ctx.fillText(lines[i], x, currentY);
  1133. }
  1134. }
  1135. saveHistory() {
  1136. // 清理当前位置之后的“重做记录”
  1137. this.history = this.history.slice(0, this.currentIndex + 1);
  1138. // 重点:用 getImageData 保存整个画布像素
  1139. const imageData = this.ctx.getImageData(0, 0, this.canvas.width, this.canvas.height);
  1140. this.history.push({
  1141. imageData: imageData,
  1142. pages: [...this.pages], // 保存当前页码信息,以便恢复时使用
  1143. indexingLineList: [...this.indexingLineList], // 保存标引线信息,以便恢复时使用
  1144. });
  1145. this.currentIndex++;
  1146. if (this.history.length > 5) {
  1147. this.history.shift(); // 删除最早的一条
  1148. // this.currentIndex--;
  1149. }
  1150. this.updata({
  1151. selectedPageItem: this._selectedPageItem,
  1152. selectedPageIndex: this.selectedPageIndex,
  1153. historylength: this.history.length,
  1154. currentIndex: this.currentIndex
  1155. })
  1156. }
  1157. undo(currentIndex, type) {
  1158. let newCurrentIndex = type ? (currentIndex - 1) : (currentIndex + 1)
  1159. const { pages, indexingLineList } = this.history[newCurrentIndex]
  1160. this.indexingLineList = indexingLineList
  1161. this.isHistory = false //回退不保存
  1162. this.pages = pages
  1163. this.currentIndex = newCurrentIndex
  1164. this.updata({
  1165. selectedPageItem: this._selectedPageItem,
  1166. selectedPageIndex: this.selectedPageIndex,
  1167. historylength: this.history.length,
  1168. currentIndex: this.currentIndex
  1169. })
  1170. this.isHistory = true
  1171. }
  1172. isArrayEqual(arr1, arr2) {
  1173. if (arr1.length !== arr2.length) return false;
  1174. for (let i = 0; i < arr1.length; i++) {
  1175. if (arr1[i] !== arr2[i]) return false;
  1176. }
  1177. return true;
  1178. }
  1179. async exportPagesToPDF(paperType = "a4", name) {
  1180. const loading = ElLoading.service({
  1181. lock: true,
  1182. text: "正在导出超清PDF,请稍候...",
  1183. background: "rgba(0, 0, 0, 0.7)",
  1184. });
  1185. const originalState = {
  1186. scale: this.scale,
  1187. drawOffsetX: this.drawOffsetX,
  1188. drawOffsetY: this.drawOffsetY,
  1189. selectedPageIndex: this.selectedPageIndex,
  1190. isHistory: this.isHistory,
  1191. };
  1192. try {
  1193. this.isHistory = false;
  1194. const DPR = 3;
  1195. const rules = {
  1196. a4: { perSheet: 1, orient: "portrait", format: "a4" },
  1197. a3: { perSheet: 2, orient: "landscape", format: "a3" },
  1198. four: { perSheet: 4, orient: "landscape", format: [840, 297]},
  1199. };
  1200. const { perSheet, orient, format } = rules[paperType];
  1201. const pdf = new jsPDF({ orientation: orient, unit: "mm", format });
  1202. const pdfW = pdf.internal.pageSize.getWidth();
  1203. const pdfH = pdf.internal.pageSize.getHeight();
  1204. const groups = [];
  1205. for (let i = 0; i < this.pages.length; i += perSheet) {
  1206. groups.push(this.pages.slice(i, i + perSheet));
  1207. }
  1208. groups.forEach((group, sheetIndex) => {
  1209. if (sheetIndex > 0) pdf.addPage();
  1210. group.forEach((_, idxInSheet) => {
  1211. const pageIndex = sheetIndex * perSheet + idxInSheet;
  1212. const page = this.pages[pageIndex];
  1213. // 离屏画布
  1214. const pageCanvas = document.createElement("canvas");
  1215. pageCanvas.width = this.pageWidth * DPR;
  1216. pageCanvas.height = this.pageHeight * DPR;
  1217. const ctx = pageCanvas.getContext("2d");
  1218. ctx.scale(DPR, DPR);
  1219. // 背景
  1220. ctx.fillStyle = "#fff";
  1221. ctx.fillRect(0, 0, this.pageWidth, this.pageHeight);
  1222. const layout = this.getItemSize(page.layoutMode);
  1223. const coords = this.getCoordinate(0, layout);
  1224. coords.forEach((coord, i) => {
  1225. const photoId = page.list[i];
  1226. const photo = this.photos.find(p => p.id === photoId);
  1227. // 相框底板
  1228. ctx.fillStyle = "#D9D9D9";
  1229. ctx.fillRect(coord.x, coord.y, coord.width, coord.height);
  1230. ctx.strokeStyle = "#eee";
  1231. ctx.strokeRect(coord.x, coord.y, coord.width, coord.height);
  1232. if (photo) {
  1233. const img = this.imgCache.get(photo.id);
  1234. if (img && img.complete) {
  1235. // 图片裁切(不超出相框)
  1236. ctx.save();
  1237. ctx.beginPath();
  1238. ctx.rect(coord.x, coord.y, coord.width, coord.height);
  1239. ctx.clip();
  1240. const s = Math.max(coord.width / img.width, coord.height / img.height);
  1241. const w = img.width * s;
  1242. const h = img.height * s;
  1243. const x = coord.x + (coord.width - w) / 2;
  1244. const y = coord.y + (coord.height - h) / 2;
  1245. ctx.drawImage(img, x, y, w, h);
  1246. ctx.restore();
  1247. }
  1248. // 文字
  1249. const text = photo.name || "说明文字";
  1250. this.drawCenteredTextWithEllipsis(
  1251. ctx, text,
  1252. coord.x + coord.width / 2,
  1253. coord.y + coord.height + 40,
  1254. coord.width - 20, 24, 2, "16px Microsoft Yahei"
  1255. );
  1256. // 虚线框
  1257. ctx.setLineDash([1, 1]);
  1258. ctx.strokeRect(coord.x, coord.y + coord.height + 10, coord.width, 50);
  1259. ctx.setLineDash([]);
  1260. }
  1261. });
  1262. // ==========================================
  1263. // 🔥 1:1 还原你原 drawGuideLine 标引逻辑
  1264. // ==========================================
  1265. this.indexingLineList.forEach(line => {
  1266. const { points, coordinate, indexingList } = line;
  1267. const startInfo = indexingList[0];
  1268. const endInfo = indexingList[1];
  1269. // 当前正在导出的是哪一页
  1270. const currentIsStart = startInfo.pageIndex === pageIndex;
  1271. const currentIsEnd = endInfo.pageIndex === pageIndex;
  1272. if (!currentIsStart && !currentIsEnd) return;
  1273. ctx.save();
  1274. ctx.strokeStyle = '#ff0000';
  1275. ctx.fillStyle = '#ff0000';
  1276. ctx.lineWidth = 2;
  1277. ctx.lineCap = 'round';
  1278. ctx.lineJoin = 'round';
  1279. // 页面全局偏移(核心修正)
  1280. const pageOffsetX = pageIndex * (this.pageWidth + this.pageMargin);
  1281. // 绘制连线
  1282. ctx.beginPath();
  1283. points.forEach((p, i) => {
  1284. const x = p.x - pageOffsetX;
  1285. const y = p.y;
  1286. i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
  1287. });
  1288. ctx.stroke();
  1289. // 起点圆点(只在起点页画)
  1290. if (currentIsStart) {
  1291. const first = points[0];
  1292. const fx = first.x - pageOffsetX;
  1293. ctx.beginPath();
  1294. ctx.arc(fx, first.y, 4, 0, Math.PI * 2);
  1295. ctx.fill();
  1296. }
  1297. // T型端线(只在终点页画)
  1298. if (currentIsEnd) {
  1299. const last = points[points.length - 1];
  1300. ctx.beginPath();
  1301. if (startInfo.pageIndex === endInfo.pageIndex) {
  1302. ctx.moveTo(coordinate.x - pageOffsetX, last.y);
  1303. ctx.lineTo(coordinate.x - pageOffsetX + coordinate.width, last.y);
  1304. } else {
  1305. ctx.moveTo(last.x - pageOffsetX, coordinate.y);
  1306. ctx.lineTo(last.x - pageOffsetX, coordinate.y + coordinate.height);
  1307. }
  1308. ctx.stroke();
  1309. }
  1310. ctx.restore();
  1311. });
  1312. const imgData = pageCanvas.toDataURL("image/png", 1.0);
  1313. // PDF 位置
  1314. let x, y, w, h;
  1315. if (paperType === "a4") {
  1316. [x, y, w, h] = [0, 0, pdfW, pdfH];
  1317. } else if (paperType === "a3") {
  1318. w = pdfW / 2; h = pdfH; x = idxInSheet * w; y = 0;
  1319. } else {
  1320. w = pdfW / 4; h = pdfH;
  1321. x = idxInSheet * w;
  1322. y = 0;
  1323. }
  1324. pdf.addImage(imgData, "PNG", x, y, w, h);
  1325. });
  1326. });
  1327. let fileName = name || "完整导出_" + Date.now();
  1328. pdf.save(fileName + `.pdf`);
  1329. ElMessage.success("PDF导出成功!");
  1330. return true
  1331. } catch (err) {
  1332. console.error(err);
  1333. ElMessage.error("导出失败");
  1334. return false
  1335. } finally {
  1336. Object.assign(this, originalState);
  1337. // this.drawAllPages();
  1338. loading.close();
  1339. }
  1340. }
  1341. async exportPagesAsImages(paperType = "a4", name, fileType = 'pdf') {
  1342. const loading = ElLoading.service({
  1343. lock: true,
  1344. text: "正在生成图片...",
  1345. background: "rgba(0, 0, 0, 0.7)",
  1346. });
  1347. const DPR = 3;
  1348. const zip = new JSZip();
  1349. // 完全和你 exportPagesToPDF 保持一致的规则
  1350. const rules = {
  1351. a4: { perSheet: 1 },
  1352. a3: { perSheet: 2 },
  1353. four: { perSheet: 4 },
  1354. };
  1355. const perSheet = rules[paperType]?.perSheet || 1;
  1356. try {
  1357. // 分组:几页拼一张图(a4=1,a3=2,four=4)
  1358. const groups = [];
  1359. for (let i = 0; i < this.pages.length; i += perSheet) {
  1360. groups.push(this.pages.slice(i, i + perSheet));
  1361. }
  1362. // 遍历每组 → 生成一张图片
  1363. for (let groupIndex = 0; groupIndex < groups.length; groupIndex++) {
  1364. const group = groups[groupIndex];
  1365. // 创建画布(和PDF导出尺寸逻辑一致)
  1366. const pageCanvas = document.createElement("canvas");
  1367. pageCanvas.width = this.pageWidth * DPR * (paperType === "a3" ? 2 : paperType === "a4" ? 1 : 4);
  1368. pageCanvas.height = this.pageHeight * DPR * 1;
  1369. const ctx = pageCanvas.getContext("2d");
  1370. ctx.scale(DPR, DPR);
  1371. // 白色背景
  1372. ctx.fillStyle = "#fff";
  1373. ctx.fillRect(0, 0, pageCanvas.width, pageCanvas.height);
  1374. // 绘制本组页面
  1375. group.forEach((page, idxInSheet) => {
  1376. const pageIndex = groupIndex * perSheet + idxInSheet;
  1377. let offsetX = 0;
  1378. let offsetY = 0;
  1379. if (paperType === "a3") offsetX = idxInSheet * this.pageWidth;
  1380. if (paperType === "four") {
  1381. offsetX = idxInSheet * this.pageWidth;
  1382. // offsetY = idxInSheet * this.pageHeight;
  1383. }
  1384. ctx.save();
  1385. ctx.translate(offsetX, offsetY);
  1386. // ==========================================
  1387. // 🔥 直接复用你 PDF 里一模一样的绘制逻辑
  1388. // ==========================================
  1389. const layout = this.getItemSize(page.layoutMode);
  1390. const coords = this.getCoordinate(0, layout);
  1391. coords.forEach((coord, i) => {
  1392. const photoId = page.list[i];
  1393. const photo = this.photos.find((p) => p.id === photoId);
  1394. ctx.fillStyle = "#D9D9D9";
  1395. ctx.fillRect(coord.x, coord.y, coord.width, coord.height);
  1396. ctx.strokeStyle = "#eee";
  1397. ctx.strokeRect(coord.x, coord.y, coord.width, coord.height);
  1398. if (photo) {
  1399. const img = this.imgCache.get(photo.id);
  1400. if (img && img.complete) {
  1401. ctx.save();
  1402. ctx.beginPath();
  1403. ctx.rect(coord.x, coord.y, coord.width, coord.height);
  1404. ctx.clip();
  1405. const s = Math.max(coord.width / img.width, coord.height / img.height);
  1406. const w = img.width * s;
  1407. const h = img.height * s;
  1408. const x = coord.x + (coord.width - w) / 2;
  1409. const y = coord.y + (coord.height - h) / 2;
  1410. ctx.drawImage(img, x, y, w, h);
  1411. ctx.restore();
  1412. }
  1413. // 文字
  1414. const text = photo.name || "说明文字";
  1415. this.drawCenteredTextWithEllipsis(
  1416. ctx, text,
  1417. coord.x + coord.width / 2,
  1418. coord.y + coord.height + 40,
  1419. coord.width - 20, 24, 2, "16px Microsoft Yahei"
  1420. );
  1421. ctx.setLineDash([1, 1]);
  1422. ctx.strokeRect(coord.x, coord.y + coord.height + 10, coord.width, 50);
  1423. ctx.setLineDash([]);
  1424. }
  1425. });
  1426. // ==========================================
  1427. // 🔥 1:1 还原你原 drawGuideLine 标引逻辑
  1428. // ==========================================
  1429. this.indexingLineList.forEach(line => {
  1430. const { points, coordinate, indexingList } = line;
  1431. const startInfo = indexingList[0];
  1432. const endInfo = indexingList[1];
  1433. // 当前正在导出的是哪一页
  1434. const currentIsStart = startInfo.pageIndex === pageIndex;
  1435. const currentIsEnd = endInfo.pageIndex === pageIndex;
  1436. if (!currentIsStart && !currentIsEnd) return;
  1437. ctx.save();
  1438. ctx.strokeStyle = '#ff0000';
  1439. ctx.fillStyle = '#ff0000';
  1440. ctx.lineWidth = 2;
  1441. ctx.lineCap = 'round';
  1442. ctx.lineJoin = 'round';
  1443. // 页面全局偏移(核心修正)
  1444. const pageOffsetX = pageIndex * (this.pageWidth + this.pageMargin);
  1445. // 绘制连线
  1446. ctx.beginPath();
  1447. points.forEach((p, i) => {
  1448. const x = p.x - pageOffsetX;
  1449. const y = p.y;
  1450. i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
  1451. });
  1452. ctx.stroke();
  1453. // 起点圆点(只在起点页画)
  1454. if (currentIsStart) {
  1455. const first = points[0];
  1456. const fx = first.x - pageOffsetX;
  1457. ctx.beginPath();
  1458. ctx.arc(fx, first.y, 4, 0, Math.PI * 2);
  1459. ctx.fill();
  1460. }
  1461. // T型端线(只在终点页画)
  1462. if (currentIsEnd) {
  1463. const last = points[points.length - 1];
  1464. ctx.beginPath();
  1465. if (startInfo.pageIndex === endInfo.pageIndex) {
  1466. ctx.moveTo(coordinate.x - pageOffsetX, last.y);
  1467. ctx.lineTo(coordinate.x - pageOffsetX + coordinate.width, last.y);
  1468. } else {
  1469. ctx.moveTo(last.x - pageOffsetX, coordinate.y);
  1470. ctx.lineTo(last.x - pageOffsetX, coordinate.y + coordinate.height);
  1471. }
  1472. ctx.stroke();
  1473. }
  1474. ctx.restore();
  1475. });
  1476. ctx.restore();
  1477. });
  1478. // 转图片并加入 ZIP
  1479. const base64 = pageCanvas.toDataURL("image/png");
  1480. zip.file(`第${groupIndex + 1}张.png`, base64.split(",")[1], { base64: true });
  1481. }
  1482. // 下载
  1483. const filename = name || "排版图片";
  1484. const blob = await zip.generateAsync({ type: "blob" });
  1485. saveAs(blob, `${filename}.zip`);
  1486. ElMessage.success("导出成功!");
  1487. return true
  1488. } catch (err) {
  1489. console.error(err);
  1490. ElMessage.error("导出失败");
  1491. return false
  1492. } finally {
  1493. loading.close();
  1494. }
  1495. }
  1496. /**
  1497. * 截取 Canvas 指定区域并返回图片 base64
  1498. * @param {HTMLCanvasElement} canvas - 原画布
  1499. * @param {number} x - 截取区域左上角 x
  1500. * @param {number} y - 截取区域左上角 y
  1501. * @param {number} width - 截取宽度
  1502. * @param {number} height - 截取高度
  1503. * @returns {string} 图片 dataURL
  1504. */
  1505. captureCanvasArea(canvas, x, y, width, height) {
  1506. // 1. 创建一个临时小画布,大小就是你要截取的尺寸
  1507. const tempCanvas = document.createElement('canvas');
  1508. const tempCtx = tempCanvas.getContext('2d');
  1509. tempCanvas.width = width;
  1510. tempCanvas.height = height;
  1511. // 2. 把原画布的指定区域 画到 临时画布
  1512. tempCtx.drawImage(
  1513. canvas,
  1514. x, y, width, height, // 原画布要截取的区域
  1515. 0, 0, width, height // 临时画布的绘制位置
  1516. );
  1517. // 3. 导出图片
  1518. return tempCanvas.toDataURL('image/png');
  1519. }
  1520. exportToPDF(paperType, name, fileType = 'pdf'){
  1521. if(fileType == 'pdf'){
  1522. this.exportPagesToPDF(paperType, name);
  1523. }else{
  1524. this.exportPagesAsImages(paperType, name);
  1525. }
  1526. }
  1527. async checkIndexing(mes = '此操作将会清除所有标引是否继续?'){
  1528. let length = this.indexingLineList.length;
  1529. // try {
  1530. if ( length && await ElMessageBox.confirm(mes, '提示') ) {
  1531. this.saveHistory();
  1532. this.indexingLineList = [];
  1533. return true;
  1534. }
  1535. // } catch (error) {
  1536. // return false
  1537. // }
  1538. return false
  1539. }
  1540. //绘制移动page的目的地提示
  1541. drawPlusIcon = (pageIndex) => {
  1542. const ctx = this.ctx;
  1543. const color = '#1677ff';
  1544. const lineWidth = 2;
  1545. const size = 60 * this.scale;
  1546. const r = 24 * this.scale
  1547. // 1. 保存当前上下文状态(避免污染)
  1548. ctx.save();
  1549. // 2. 应用和全量绘制一致的偏移/缩放(保证坐标对齐)
  1550. ctx.translate(this.drawOffsetX, this.drawOffsetY);
  1551. ctx.scale(this.scale, this.scale);
  1552. const pageX = (pageIndex * (this.pageWidth + this.pageMargin)) - this.pageMargin / 2;
  1553. const arcY = 0-(size/2)
  1554. // 蓝色圆形
  1555. ctx.beginPath();
  1556. ctx.arc(pageX, arcY, r, 0, Math.PI * 2);
  1557. ctx.fillStyle = color;
  1558. ctx.fill();
  1559. // 白色加号
  1560. ctx.beginPath();
  1561. ctx.moveTo(pageX - size / 3, arcY);
  1562. ctx.lineTo(pageX + size / 3, arcY);
  1563. ctx.moveTo(pageX, arcY - size / 3);
  1564. ctx.lineTo(pageX, arcY + size / 3);
  1565. ctx.strokeStyle = '#fff';
  1566. ctx.lineWidth = lineWidth;
  1567. ctx.lineCap = 'round';
  1568. ctx.fill();
  1569. ctx.stroke();
  1570. ctx.beginPath();
  1571. ctx.moveTo(pageX, 0);
  1572. ctx.lineTo(pageX, this.pageHeight);
  1573. ctx.strokeStyle = color;
  1574. ctx.lineWidth = this.pageMargin;
  1575. ctx.fill();
  1576. ctx.stroke();
  1577. ctx.restore();
  1578. };
  1579. insertItemToArray(arr, item, toIndex) {
  1580. const newArr = [...arr]
  1581. newArr.splice(toIndex, 0, item)
  1582. return newArr
  1583. }
  1584. }