canvas_x.js 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538
  1. import QRCode from './qrCode'
  2. import './style.pcss'
  3. /**
  4. * 绘制一个图片。
  5. */
  6. function makeImage(options, callback) {
  7. console.log(1111)
  8. const { parts, width, height } = options
  9. let error = null
  10. // 初始化Canvas
  11. const canvas = document.createElement('canvas')
  12. const mainCtx = canvas.getContext('2d')
  13. canvas.width = width
  14. canvas.height = height
  15. mainCtx.fillStyle = options.background || '#fff'
  16. mainCtx.fillRect(0, 0, width, height)
  17. mainCtx.save()
  18. /**
  19. * 设置宽高,针对负值定位做处理
  20. * @param {number} x: positionX
  21. * @param {number} y: positionY
  22. * @param {object} o: width & height
  23. * @param {number} o.width: 文字的宽度
  24. * @param {number} o.height: 文字的高度
  25. * @param {number} o.lineAlign: 文字的垂直对齐方式
  26. * @param {number} o.lineNum: 文字的行数
  27. * */
  28. function setPosition(x, y, o) {
  29. let positionX, positionY
  30. // 处理padding 与 负定位
  31. if (x < 0) {
  32. positionX = options.width + x - o.width
  33. }
  34. // 处理padding 与 负定位
  35. if (y < 0) {
  36. positionY = options.height + y - o.height
  37. }
  38. positionX = positionX || x || 0
  39. positionY = positionY || y || 0
  40. // 文字的垂直对齐方式处理
  41. if (o.lineAlign === 'middle') {
  42. positionY -= (o.height / 2) * o.lineNum - options.height / 2
  43. }
  44. else if (o.lineAlign === 'bottom') {
  45. positionY -= o.height * o.lineNum - options.height
  46. }
  47. // 文字的水平对齐方式处理
  48. if (o.textAlign === 'center') {
  49. positionX -= o.width / 2 - options.width / 2
  50. }
  51. else if (o.textAlign === 'right') {
  52. positionX -= o.width - options.width
  53. }
  54. return {
  55. x: positionX,
  56. y: positionY
  57. }
  58. }
  59. /**
  60. * 针对圆角做处理
  61. * */
  62. function tailorImg(x, y, w, h, r) {
  63. /**
  64. * beginPath 与 closePath来关闭绘制圆,以免影响后续绘制,
  65. * 因为不关闭绘制,会导致后续图片全部倍遮挡.
  66. * */
  67. mainCtx.save()
  68. mainCtx.beginPath()
  69. mainCtx.moveTo(x + r, y)
  70. mainCtx.arcTo(x + w, y, x + w, y + h, r)
  71. mainCtx.arcTo(x + w, y + h, x, y + h, r)
  72. mainCtx.arcTo(x, y + h, x, y, r)
  73. mainCtx.arcTo(x, y, x + w, y, r)
  74. mainCtx.clip()
  75. mainCtx.closePath()
  76. }
  77. function handleTailorImg(options) {
  78. const {
  79. image: img,
  80. x, y,
  81. width: w,
  82. height: h,
  83. radius: r,
  84. padding: p,
  85. background: bg,
  86. clipOptions
  87. } = options
  88. // tailorImg中save保存当前画布,restore将保存的画布重新绘制
  89. tailorImg(x - p, y - p, w, h, r)
  90. mainCtx.fillStyle = bg || '#fff'
  91. mainCtx.fill()
  92. mainCtx.restore()
  93. tailorImg(x, y, w - p * 2, h - p * 2, r)
  94. // 针对非同比例的图片进行部分剪裁
  95. if (clipOptions) {
  96. clipOptions.x = clipOptions.x || 0
  97. clipOptions.y = clipOptions.y || 0
  98. // 缩放图片,方便截取选区
  99. if (clipOptions.zoom) {
  100. let dw, dh, offset = 0
  101. if (img.height > img.width) {
  102. dw = w - p * 2
  103. dh = img.height * w / img.width - p * 2
  104. }
  105. else {
  106. dw = img.width * h / img.height - p * 2
  107. dh = h - p * 2
  108. }
  109. // 裁剪居中偏移量
  110. if (clipOptions.align === 'center') {
  111. offset = Math.abs((dw - dh) / 2)
  112. }
  113. mainCtx.drawImage(img, x - clipOptions.x - (dw > dh ? offset : 0), y - clipOptions.y - (dh > dw ? offset : 0), dw, dh)
  114. }
  115. else {
  116. if (clipOptions.align === 'center') {
  117. const offsetX = Math.abs((img.width - w - p) / 2)
  118. const offsetY = Math.abs((img.height - h - p) / 2)
  119. mainCtx.drawImage(img, x - offsetX, x - offsetY)
  120. }
  121. else {
  122. mainCtx.drawImage(img, x - clipOptions.x, y - clipOptions.y)
  123. }
  124. }
  125. }
  126. else {
  127. mainCtx.drawImage(img, x, y, w - p * 2, h - p * 2)
  128. }
  129. mainCtx.restore()
  130. }
  131. /**
  132. * 绘制处理各类数据
  133. * @param {object} options: 绘制对象的配置
  134. * @param {function} nextFunc: 下步的回调,是继续,还是执行成功回调
  135. * */
  136. function handleText(options, nextFunc) {
  137. const bodyStyle = getComputedStyle(document.body)
  138. // 没有任何文本内容直接跳出
  139. if (!options.text || typeof options.text !== 'string') return nextFunc()
  140. const arr = options.text.toString().split('\n')
  141. // 设置字体后,再获取图片的宽高
  142. const lineHeight = parseFloat(options.size || bodyStyle.fontSize) * 1.2
  143. for (let i = 0, lineNum = arr.length; i < lineNum; i++) {
  144. // 设置字体
  145. mainCtx.textBaseline = 'top'
  146. mainCtx.font = `${options.bold ? `bold ` : ''}${options.size || bodyStyle.fontSize} ${bodyStyle.fontFamily}`
  147. mainCtx.fillStyle = options.color || bodyStyle.color
  148. // 设置文本对齐方式
  149. mainCtx.textAlign = 'left'
  150. // 设置透明度
  151. mainCtx.globalAlpha = options.opacity || 1
  152. const position = setPosition(options.x || 0, (options.y || 0) + lineHeight * i, {
  153. lineNum, // 处理lineAlign
  154. lineAlign: options.lineAlign,
  155. textAlign: options.textAlign,
  156. height: lineHeight,
  157. width: mainCtx.measureText(arr[i]).width
  158. })
  159. mainCtx.fillText(arr[i], position.x, position.y)
  160. }
  161. // 最后一个元素时,便执行回调,否则继续绘制
  162. nextFunc && nextFunc()
  163. }
  164. function dataURItoBlob(dataURI) {
  165. const byteString = atob(dataURI.split(',')[1])
  166. const mimeString = dataURI.split(',')[0].split(':')[1].split(';')[0]
  167. const ab = new ArrayBuffer(byteString.length)
  168. const ia = new Uint8Array(ab)
  169. for (let i = 0; i < byteString.length; i++) {
  170. ia[i] = byteString.charCodeAt(i)
  171. }
  172. return new Blob([ab], { type: mimeString })
  173. }
  174. function handleImage(options = {}, nextFunc) {
  175. console.log(options)
  176. const { width, height, x, y, url } = options
  177. if (!url) return console.error('缺失绘制的图片 url')
  178. const padding = options.padding || 0
  179. const img = new Image()
  180. const position = setPosition(x, y, { width, height })
  181. img.crossOrigin = 'anonymous'
  182. // 兼容问题:base64需要特殊处理
  183. img.src = !~url.indexOf('data:image/') ? url : URL.createObjectURL(dataURItoBlob(url))
  184. // 加载完成,绘制至画布
  185. img.onerror = err => {
  186. error = err
  187. // 最后一个元素时,便执行回调,否则继续绘制,
  188. nextFunc && nextFunc()
  189. }
  190. img.onload = () => {
  191. // 设置透明度
  192. mainCtx.globalAlpha = options.opacity || 1
  193. if (options.radius || padding > 0) {
  194. handleTailorImg({
  195. image: img,
  196. x: position.x + padding,
  197. y: position.y + padding,
  198. width: width || img.width,
  199. height: height || img.height,
  200. radius: ((height || img.height) - 2 * padding) / 2 * (options.radius || 0),
  201. padding,
  202. background: options.background,
  203. clipOptions: options.clipOptions
  204. })
  205. }
  206. else {
  207. mainCtx.drawImage(
  208. img,
  209. position.x + padding,
  210. position.y + padding,
  211. (width || img.width) - padding * 2,
  212. (height || img.height) - padding * 2
  213. )
  214. }
  215. // 最后一个元素时,便执行回调,否则继续绘制,
  216. nextFunc && nextFunc()
  217. }
  218. }
  219. function handleQrCode(options, nextFunc) {
  220. let { text, width, height, level } = options
  221. width = width || 200
  222. height = height || width || 200
  223. if (!text) return console.error('缺失绘制的二维码的 text')
  224. const qrCode = new QRCode(null, {
  225. text,
  226. width,
  227. height,
  228. correctLevel: level || 3,
  229. colorDark: '#000000',
  230. colorLight: '#ffffff'
  231. })
  232. const img = qrCode._oDrawing._elImage
  233. // 绘制处理同image
  234. img.onload = () => {
  235. handleImage(Object.assign(options, { url: img.src }), !options.logo && nextFunc)
  236. if (options.logo) {
  237. const ratio = 0.35
  238. const isMinus = options.x < 0 ? -1 : 1
  239. handleImage({
  240. type: 'image',
  241. url: options.logo || 'http://via.placeholder.com/100x100',
  242. width: width * ratio,
  243. height: height * ratio,
  244. x: isMinus * width * (0.5 - ratio / 2) + (options.x || 0),
  245. y: isMinus * height * (0.5 - ratio / 2) + (options.y || 0),
  246. padding: 2
  247. }, nextFunc)
  248. }
  249. }
  250. }
  251. // 初始化数据
  252. let len = parts.length
  253. let i = 0
  254. const start = function () {
  255. /**
  256. * 最后一个元素时,便执行回调,否则继续绘制
  257. * @param {number} opacity: 绘制的透明度,默认为 0
  258. * */
  259. const nextFunc = () => {
  260. i++
  261. // 是否最后一个绘制对西那个
  262. !(len - i) ?
  263. callback && callback(error, canvas.toDataURL('image/jpeg', options.compress || .8)) : start()
  264. }
  265. if (len - i) {
  266. switch (parts[i].type) {
  267. case 'text':
  268. handleText(parts[i], nextFunc)
  269. break
  270. case 'image':
  271. handleImage(parts[i], nextFunc)
  272. break
  273. case 'qrcode':
  274. handleQrCode(parts[i], nextFunc)
  275. break
  276. default:
  277. }
  278. }
  279. }
  280. start()
  281. }
  282. /**
  283. * 创建编辑节点DOM
  284. * @param container 视图渲染的容器
  285. * @param options 同 makeImage 配置项
  286. * @param callback 成功后的回调,参数接受合成后的 base64
  287. * */
  288. function renderEditor(container, options, callback) {
  289. function _extends(o) {
  290. const _options = {}
  291. for (let key in o) {
  292. _options[key] = o[key]
  293. }
  294. return _options
  295. }
  296. // 过滤需要编辑的文字
  297. const _options = _extends(options)
  298. _options.parts = _options.parts
  299. .filter(item => item.editable && item.type !== 'text')
  300. // 生成HTML容器
  301. makeImage(_options, (error, data) => {
  302. // 初始化数据,为编辑状态却没有宽高的图片设置默认宽高,并导出该对象
  303. function initEditImage(callback) {
  304. // 过滤,并添加key值,留下可编辑的图片
  305. const editImageArr = options.parts
  306. .filter((item, key) => {
  307. item._key = key
  308. return item.editable && item.type === 'image'
  309. })
  310. // 处理编辑的图片:i用于循环遍历,editImageArrLen用于判断是否所有image都已加载完成
  311. let i, editImageArrLen
  312. editImageArrLen = i = editImageArr.length
  313. while (i--) {
  314. const img = new Image()
  315. img.src = editImageArr[i].url
  316. img.onload = ((i) => () => {
  317. // 初始化图片宽高
  318. editImageArr[i].width = editImageArr[i].width || img.width
  319. editImageArr[i].height = editImageArr[i].height || img.height
  320. editImageArrLen--
  321. // 全部处理完成,将可编辑的图片,渲染为DOM
  322. if (!editImageArrLen) {
  323. callback && callback(editImageArr)
  324. }
  325. })(i)
  326. }
  327. }
  328. // 针对input change事件,通过key值映射,修改图片源
  329. function updateOptions(imageData, key) {
  330. options.parts.map(item => {
  331. if (item._key === ~~key) {
  332. item.url = imageData
  333. return item
  334. }
  335. return item
  336. })
  337. renderEditor(container, options, callback)
  338. }
  339. function getBase64(e, callback) {
  340. const reader = new FileReader()
  341. reader.addEventListener('load', function () {
  342. callback(this.result)
  343. }, false)
  344. reader.readAsDataURL(e.target.files[0])
  345. return e
  346. }
  347. initEditImage(editImageList => {
  348. // 为每项编辑项添加input
  349. let html = ''
  350. // 过滤,并添加key值,留下可编辑的文字
  351. const editTextArr = options.parts
  352. .filter((item, key) => {
  353. item._key = key
  354. return item.editable && item.type === 'text'
  355. })
  356. // 渲染文字修改选框
  357. for (let i = editTextArr.length; i--;) {
  358. html +=
  359. `
  360. <textarea
  361. class="x-textarea-container"
  362. data-key="${editTextArr[i]._key}"
  363. style="
  364. left: ${editTextArr[i].x || 0}px;
  365. top: ${editTextArr[i].y || 0}px;
  366. color: ${editTextArr[i].color};
  367. font-size: ${editTextArr[i].size};
  368. "
  369. placeholder="${editTextArr[i].placeholder}"
  370. maxlength="${editTextArr[i].maxLength}"
  371. >${editTextArr[i].text}</textarea>
  372. `
  373. }
  374. // 渲染图片替换按钮
  375. for (let i = editImageList.length; i--;) {
  376. html +=
  377. `<div
  378. class="x-input-container"
  379. style="
  380. left: ${editImageList[i].x || 0}px;
  381. top: ${editImageList[i].y || 0}px;
  382. width: ${editImageList[i].width}px;
  383. height: ${editImageList[i].height}px;
  384. "
  385. >
  386. <input
  387. class="x-input"
  388. data-key="${editImageList[i]._key}"
  389. data-click="${editImageList[i].selectImage}"
  390. type="${editImageList[i].selectImage ? 'button' : 'file'}"
  391. value="点击替换图片"
  392. />
  393. <a>点击替换图片</a>
  394. </div>`
  395. }
  396. // 创建视图
  397. container.innerHTML =
  398. `<div class="x-imaging-box">
  399. <img src="${data}" />
  400. ${html}
  401. ${options.buttonText !== null ? (
  402. options.buttonText ?
  403. `<a class="x-make-image">${options.buttonText}</a>` :
  404. '<a class="x-make-image">绘制画布</a>') : ''
  405. }
  406. </div>`
  407. // 冒泡筛选input change事件
  408. const handleChange = e => {
  409. if (e.target.className === 'x-input') {
  410. const key = e.target.getAttribute('data-key')
  411. getBase64(e, imageData => updateOptions(imageData, key))
  412. }
  413. }
  414. container.addEventListener('change', handleChange, false)
  415. // 冒泡筛选input click事件
  416. const handleClick = e => {
  417. // 点击替换按钮的事件
  418. if (e.target.className === 'x-input') {
  419. const key = e.target.getAttribute('data-key')
  420. const cb = imageData => updateOptions(imageData, key)
  421. options.parts[key].selectImage && (options.parts[key].selectImage)(cb)
  422. }
  423. // 合并画布
  424. if (e.target.className === 'x-make-image') {
  425. const textDom = document.getElementsByClassName('x-textarea-container')
  426. for (let i = textDom.length; i--;) {
  427. const key = textDom[i].getAttribute('data-key')
  428. options.parts[key].text = textDom[i].value
  429. }
  430. makeImage(options, (err, data) => {
  431. container.innerHTML =
  432. `<div class="x-imaging-box">
  433. <img src="${data}" />
  434. ${options.resetButtonText !== null ? (
  435. options.resetButtonText ?
  436. `<a class="x-again-make-image">${options.resetButtonText}</a>` :
  437. '<a class="x-again-make-image">重新编辑</a>') : ''
  438. }
  439. </div>`
  440. })
  441. }
  442. // 重新编辑
  443. if (e.target.className === 'x-again-make-image') {
  444. // 移除监听避免重复编辑,累加监听
  445. container.removeEventListener('click', handleClick, false)
  446. container.removeEventListener('change', handleChange, false)
  447. renderEditor(container, options, callback)
  448. }
  449. }
  450. container.addEventListener('click', handleClick, false)
  451. callback && callback(data)
  452. })
  453. })
  454. // 返回一个生成画布的方法
  455. return {
  456. getValue: () => options,
  457. makeImage: callback => {
  458. makeImage(options, callback)
  459. }
  460. }
  461. }
  462. export default {
  463. makeImage,
  464. renderEditor
  465. }