@logicflow/extension 2.0.16 → 2.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -70,14 +70,18 @@ export class Snapshot {
70
70
  ) => await this.getSnapshot(fileName, toImageOptions)
71
71
 
72
72
  /* 获取Blob对象 */
73
- lf.getSnapshotBlob = async (backgroundColor?: string, fileType?: string) =>
74
- await this.getSnapshotBlob(backgroundColor, fileType)
73
+ lf.getSnapshotBlob = async (
74
+ backgroundColor?: string, // 兼容老的使用方式
75
+ fileType?: string,
76
+ toImageOptions?: ToImageOptions,
77
+ ) => await this.getSnapshotBlob(backgroundColor, fileType, toImageOptions)
75
78
 
76
79
  /* 获取Base64对象 */
77
80
  lf.getSnapshotBase64 = async (
78
- backgroundColor?: string,
81
+ backgroundColor?: string, // 兼容老的使用方式
79
82
  fileType?: string,
80
- ) => await this.getSnapshotBase64(backgroundColor, fileType)
83
+ toImageOptions?: ToImageOptions,
84
+ ) => await this.getSnapshotBase64(backgroundColor, fileType, toImageOptions)
81
85
  }
82
86
 
83
87
  /**
@@ -144,123 +148,134 @@ export class Snapshot {
144
148
  }
145
149
 
146
150
  /**
147
- * 导出画布:导出前的处理画布工作,局部渲染模式处理、静默模式处理
148
- * @param fileName
149
- * @param toImageOptions
151
+ * 将图片转换为base64格式
152
+ * @param url - 图片URL
153
+ * @returns Promise<string> - base64字符串
150
154
  */
151
- async getSnapshot(fileName?: string, toImageOptions?: ToImageOptions) {
152
- const curPartial = this.lf.graphModel.getPartial()
153
- const { partial = curPartial } = toImageOptions ?? {}
154
- // 获取流程图配置
155
- const editConfig = this.lf.getEditConfig()
156
- // 开启静默模式:如果元素多的话 避免用户交互 感知卡顿
157
- this.lf.updateEditConfig({
158
- isSilentMode: true,
159
- stopScrollGraph: true,
160
- stopMoveGraph: true,
155
+ private async convertImageToBase64(url: string): Promise<string> {
156
+ return new Promise((resolve, reject) => {
157
+ const img = new Image()
158
+ img.crossOrigin = 'anonymous' // 处理跨域问题
159
+ img.onload = () => {
160
+ const canvas = document.createElement('canvas')
161
+ canvas.width = img.width
162
+ canvas.height = img.height
163
+ const ctx = canvas.getContext('2d')
164
+ ctx?.drawImage(img, 0, 0)
165
+ const base64 = canvas.toDataURL('image/png')
166
+ resolve(base64)
167
+ }
168
+ img.onerror = () => {
169
+ reject(new Error(`Failed to load image: ${url}`))
170
+ }
171
+ img.src = url
161
172
  })
162
- // 画布当前渲染模式和用户导出渲染模式不一致时,需要更新画布
163
- if (curPartial !== partial) {
164
- this.lf.graphModel.setPartial(partial)
165
- this.lf.graphModel.eventCenter.once('graph:updated', async () => {
166
- await this.snapshot(fileName, toImageOptions)
167
- // 恢复原来渲染模式
168
- this.lf.graphModel.setPartial(curPartial)
169
- })
170
- } else {
171
- await this.snapshot(fileName, toImageOptions)
172
- }
173
- // 恢复原来配置
174
- this.lf.updateEditConfig(editConfig)
175
173
  }
176
174
 
177
175
  /**
178
- * 下载图片
179
- * @param fileName
180
- * @param toImageOptions
176
+ * 检查URL是否为相对路径
177
+ * @param url - 要检查的URL
178
+ * @returns boolean - 是否为相对路径
181
179
  */
182
- private async snapshot(fileName?: string, toImageOptions?: ToImageOptions) {
183
- const { fileType = 'png', quality } = toImageOptions ?? {}
184
- this.fileName = `${fileName ?? `logic-flow.${Date.now()}`}.${fileType}`
185
- const svg = this.getSvgRootElement(this.lf)
186
- await updateImageSource(svg as SVGElement)
187
- if (fileType === 'svg') {
188
- const copy = this.cloneSvg(svg)
189
- const svgString = new XMLSerializer().serializeToString(copy)
190
- const blob = new Blob([svgString], {
191
- type: 'image/svg+xml;charset=utf-8',
192
- })
193
- const url = URL.createObjectURL(blob)
194
- this.triggerDownload(url)
195
- } else {
196
- this.getCanvasData(svg, toImageOptions ?? {}).then(
197
- (canvas: HTMLCanvasElement) => {
198
- // canvas元素 => base64 url image/octet-stream: 确保所有浏览器都能正常下载
199
- const imgUrl = canvas
200
- .toDataURL(`image/${fileType}`, quality)
201
- .replace(`image/${fileType}`, 'image/octet-stream')
202
- this.triggerDownload(imgUrl)
203
- },
204
- )
205
- }
180
+ private isRelativePath(url: string): boolean {
181
+ return (
182
+ !url.startsWith('data:') &&
183
+ !url.startsWith('http://') &&
184
+ !url.startsWith('https://') &&
185
+ !url.startsWith('//')
186
+ )
206
187
  }
207
188
 
208
189
  /**
209
- * 获取base64对象
210
- * @param backgroundColor
211
- * @param fileType
212
- * @returns
190
+ * 处理SVG中的图片元素
191
+ * @param element - SVG元素
213
192
  */
214
- async getSnapshotBase64(
215
- backgroundColor?: string,
216
- fileType?: string,
217
- ): Promise<SnapshotResponse> {
218
- const svg = this.getSvgRootElement(this.lf)
219
- await updateImageSource(svg as SVGElement)
220
- return new Promise((resolve) => {
221
- this.getCanvasData(svg, { backgroundColor }).then(
222
- (canvas: HTMLCanvasElement) => {
223
- const base64 = canvas.toDataURL(`image/${fileType ?? 'png'}`)
224
- // 输出图片数据以及图片宽高
225
- resolve({
226
- data: base64,
227
- width: canvas.width,
228
- height: canvas.height,
229
- })
230
- },
231
- )
232
- })
193
+ private async processImages(element: Element): Promise<void> {
194
+ // 处理image元素
195
+ const images = element.getElementsByTagName('image')
196
+ for (let i = 0; i < images.length; i++) {
197
+ const image = images[i]
198
+ const href =
199
+ image.getAttributeNS('http://www.w3.org/1999/xlink', 'href') ||
200
+ image.getAttribute('href')
201
+ if (href && this.isRelativePath(href)) {
202
+ try {
203
+ const base64 = await this.convertImageToBase64(href)
204
+ image.setAttributeNS('http://www.w3.org/1999/xlink', 'href', base64)
205
+ image.setAttribute('href', base64)
206
+ } catch (error) {
207
+ console.warn(`Failed to convert image to base64: ${href}`, error)
208
+ }
209
+ }
210
+ }
211
+
212
+ // 处理foreignObject中的img元素
213
+ const foreignObjects = element.getElementsByTagName('foreignObject')
214
+ for (let i = 0; i < foreignObjects.length; i++) {
215
+ const foreignObject = foreignObjects[i]
216
+ const images = foreignObject.getElementsByTagName('img')
217
+ for (let j = 0; j < images.length; j++) {
218
+ const image = images[j]
219
+ const src = image.getAttribute('src')
220
+ if (src && this.isRelativePath(src)) {
221
+ try {
222
+ const base64 = await this.convertImageToBase64(src)
223
+ image.setAttribute('src', base64)
224
+ } catch (error) {
225
+ console.warn(`Failed to convert image to base64: ${src}`, error)
226
+ }
227
+ }
228
+ }
229
+ }
233
230
  }
234
231
 
235
232
  /**
236
- * 获取Blob对象
237
- * @param backgroundColor
238
- * @param fileType
233
+ * 克隆并处理画布节点
234
+ * @param svg
239
235
  * @returns
240
236
  */
241
- async getSnapshotBlob(
242
- backgroundColor?: string,
243
- fileType?: string,
244
- ): Promise<SnapshotResponse> {
245
- const svg = this.getSvgRootElement(this.lf)
246
- await updateImageSource(svg as SVGElement)
247
- return new Promise((resolve) => {
248
- this.getCanvasData(svg, { backgroundColor }).then(
249
- (canvas: HTMLCanvasElement) => {
250
- canvas.toBlob(
251
- (blob) => {
252
- // 输出图片数据以及图片宽高
253
- resolve({
254
- data: blob!,
255
- width: canvas.width,
256
- height: canvas.height,
257
- })
258
- },
259
- `image/${fileType ?? 'png'}`,
260
- )
261
- },
262
- )
263
- })
237
+ private async cloneSvg(
238
+ svg: Element,
239
+ addStyle: boolean = true,
240
+ ): Promise<Node> {
241
+ const copy = svg.cloneNode(true) as Element
242
+ const graph = copy.lastChild as Element
243
+ let childLength = graph?.childNodes?.length
244
+ if (childLength) {
245
+ for (let i = 0; i < childLength; i++) {
246
+ const lfLayer = graph?.childNodes[i] as SVGGraphicsElement
247
+ // 只保留包含节点和边的基础图层进行下载,其他图层删除
248
+ const layerClassList =
249
+ lfLayer.classList && Array.from(lfLayer.classList)
250
+ if (layerClassList && layerClassList.indexOf('lf-base') < 0) {
251
+ graph?.removeChild(graph.childNodes[i])
252
+ childLength--
253
+ i--
254
+ } else {
255
+ // 删除锚点
256
+ const lfBase = graph?.childNodes[i]
257
+ lfBase &&
258
+ lfBase.childNodes.forEach((item) => {
259
+ const element = item as SVGGraphicsElement
260
+ this.removeAnchor(element.firstChild!)
261
+ this.removeRotateControl(element.firstChild!)
262
+ })
263
+ }
264
+ }
265
+ }
266
+
267
+ // 处理图片路径
268
+ await this.processImages(copy)
269
+
270
+ // 设置css样式
271
+ if (addStyle) {
272
+ const style = document.createElement('style')
273
+ style.innerHTML = this.getClassRules()
274
+ const foreignObject = document.createElement('foreignObject')
275
+ foreignObject.appendChild(style)
276
+ copy.appendChild(foreignObject)
277
+ }
278
+ return copy
264
279
  }
265
280
 
266
281
  /**
@@ -302,7 +317,7 @@ export class Snapshot {
302
317
  toImageOptions: ToImageOptions,
303
318
  ): Promise<HTMLCanvasElement> {
304
319
  const { width, height, backgroundColor, padding = 40 } = toImageOptions
305
- const copy = this.cloneSvg(svg, false)
320
+ const copy = await this.cloneSvg(svg, false)
306
321
 
307
322
  let dpr = window.devicePixelRatio || 1
308
323
  if (dpr < 1) {
@@ -335,21 +350,31 @@ export class Snapshot {
335
350
  const { transformModel } = graphModel
336
351
  const { SCALE_X, SCALE_Y, TRANSLATE_X, TRANSLATE_Y } = transformModel
337
352
 
353
+ // 计算实际宽高,考虑缩放因素
354
+ // 在宽画布情况下,getBoundingClientRect可能无法获取到所有元素的边界
355
+ // 因此我们添加一个安全系数来确保能够容纳所有元素
356
+ const safetyFactor = 1.1 // 安全系数,增加20%的空间
357
+ const actualWidth = (bbox.width / SCALE_X) * safetyFactor
358
+ const actualHeight = (bbox.height / SCALE_Y) * safetyFactor
359
+
338
360
  // 将导出区域移动到左上角,canvas 绘制的时候是从左上角开始绘制的
361
+ // 在transform矩阵中加入padding值,确保左侧元素不会被截断
339
362
  ;(copy.lastChild as SVGElement).style.transform = `matrix(1, 0, 0, 1, ${
340
- (-offsetX + TRANSLATE_X) * (1 / SCALE_X)
341
- }, ${(-offsetY + TRANSLATE_Y) * (1 / SCALE_Y)})`
363
+ (-offsetX + TRANSLATE_X) * (1 / SCALE_X) + padding / dpr
364
+ }, ${(-offsetY + TRANSLATE_Y) * (1 / SCALE_Y) + padding / dpr})`
342
365
 
343
- // 包含所有元素的最小宽高
344
- const bboxWidth = Math.ceil(bbox.width / SCALE_X)
345
- const bboxHeight = Math.ceil(bbox.height / SCALE_Y)
366
+ // 包含所有元素的最小宽高,确保足够大以容纳所有元素
367
+ const bboxWidth = Math.ceil(actualWidth)
368
+ const bboxHeight = Math.ceil(actualHeight)
346
369
  const canvas = document.createElement('canvas')
347
370
  canvas.style.width = `${bboxWidth}px`
348
371
  canvas.style.height = `${bboxHeight}px`
349
372
 
350
373
  // 宽高值 默认加padding 40,保证图形不会紧贴着下载图片
351
- canvas.width = bboxWidth * dpr + padding * 2
352
- canvas.height = bboxHeight * dpr + padding * 2
374
+ // 为宽画布添加额外的安全边距,确保不会裁剪
375
+ const safetyMargin = 40 // 额外的安全边距
376
+ canvas.width = bboxWidth * dpr + padding * 2 + safetyMargin
377
+ canvas.height = bboxHeight * dpr + padding * 2 + safetyMargin
353
378
  const ctx = canvas.getContext('2d')
354
379
  if (ctx) {
355
380
  // 清空canvas
@@ -387,19 +412,22 @@ export class Snapshot {
387
412
  ? copyCanvas(canvas, width, height).height
388
413
  : canvas.height,
389
414
  }).then((imageBitmap) => {
390
- ctx?.drawImage(imageBitmap, padding / dpr, padding / dpr)
415
+ // 由于在transform矩阵中已经考虑了padding,这里不再需要额外的padding偏移
416
+ ctx?.drawImage(imageBitmap, 0, 0)
391
417
  resolve(
392
418
  width && height ? copyCanvas(canvas, width, height) : canvas,
393
419
  )
394
420
  })
395
421
  } else {
396
- ctx?.drawImage(img, padding / dpr, padding / dpr)
422
+ // 由于在transform矩阵中已经考虑了padding,这里不再需要额外的padding偏移
423
+ ctx?.drawImage(img, 0, 0)
397
424
  resolve(
398
425
  width && height ? copyCanvas(canvas, width, height) : canvas,
399
426
  )
400
427
  }
401
428
  } catch (e) {
402
- ctx?.drawImage(img, padding / dpr, padding / dpr)
429
+ // 由于在transform矩阵中已经考虑了padding,这里不再需要额外的padding偏移
430
+ ctx?.drawImage(img, 0, 0)
403
431
  resolve(width && height ? copyCanvas(canvas, width, height) : canvas)
404
432
  }
405
433
  }
@@ -421,45 +449,190 @@ export class Snapshot {
421
449
  }
422
450
 
423
451
  /**
424
- * 克隆并处理画布节点
425
- * @param svg
426
- * @returns
452
+ * 封装导出前的通用处理逻辑:局部渲染模式处理、静默模式处理
453
+ * @param callback 实际执行的导出操作回调函数
454
+ * @param toImageOptions 导出图片选项
455
+ * @returns 返回回调函数的执行结果
427
456
  */
428
- private cloneSvg(svg: Element, addStyle: boolean = true): Node {
429
- const copy = svg.cloneNode(true)
430
- const graph = copy.lastChild
431
- let childLength = graph?.childNodes?.length
432
- if (childLength) {
433
- for (let i = 0; i < childLength; i++) {
434
- const lfLayer = graph?.childNodes[i] as SVGGraphicsElement
435
- // 只保留包含节点和边的基础图层进行下载,其他图层删除
436
- const layerClassList =
437
- lfLayer.classList && Array.from(lfLayer.classList)
438
- if (layerClassList && layerClassList.indexOf('lf-base') < 0) {
439
- graph?.removeChild(graph.childNodes[i])
440
- childLength--
441
- i--
442
- } else {
443
- // 删除锚点
444
- const lfBase = graph?.childNodes[i]
445
- lfBase &&
446
- lfBase.childNodes.forEach((item) => {
447
- const element = item as SVGGraphicsElement
448
- this.removeAnchor(element.firstChild!)
449
- this.removeRotateControl(element.firstChild!)
450
- })
451
- }
457
+ private async withExportPreparation<T>(
458
+ callback: () => Promise<T>,
459
+ toImageOptions?: ToImageOptions,
460
+ ): Promise<T> {
461
+ // 获取当前局部渲染状态
462
+ const curPartial = this.lf.graphModel.getPartial()
463
+ const { partial = curPartial } = toImageOptions ?? {}
464
+ // 获取流程图配置
465
+ const editConfig = this.lf.getEditConfig()
466
+
467
+ // 开启静默模式:如果元素多的话 避免用户交互 感知卡顿
468
+ this.lf.updateEditConfig({
469
+ isSilentMode: true,
470
+ stopScrollGraph: true,
471
+ stopMoveGraph: true,
472
+ })
473
+
474
+ let result: T
475
+
476
+ try {
477
+ // 如果画布的渲染模式与导出渲染模式不一致,则切换渲染模式
478
+ if (curPartial !== partial) {
479
+ this.lf.graphModel.setPartial(partial)
480
+ // 等待画布更新完成
481
+ result = await new Promise<T>((resolve) => {
482
+ this.lf.graphModel.eventCenter.once('graph:updated', async () => {
483
+ const callbackResult = await callback()
484
+ // 恢复原来渲染模式
485
+ this.lf.graphModel.setPartial(curPartial)
486
+ resolve(callbackResult)
487
+ })
488
+ })
489
+ } else {
490
+ // 直接执行回调
491
+ result = await callback()
452
492
  }
493
+ } finally {
494
+ // 恢复原来配置
495
+ this.lf.updateEditConfig(editConfig)
453
496
  }
454
- // 设置css样式
455
- if (addStyle) {
456
- const style = document.createElement('style')
457
- style.innerHTML = this.getClassRules()
458
- const foreignObject = document.createElement('foreignObject')
459
- foreignObject.appendChild(style)
460
- copy.appendChild(foreignObject)
497
+
498
+ return result
499
+ }
500
+
501
+ /**
502
+ * 导出画布:导出前的处理画布工作,局部渲染模式处理、静默模式处理
503
+ * @param fileName
504
+ * @param toImageOptions
505
+ */
506
+ async getSnapshot(fileName?: string, toImageOptions?: ToImageOptions) {
507
+ await this.withExportPreparation(
508
+ () => this.snapshot(fileName, toImageOptions),
509
+ toImageOptions,
510
+ )
511
+ }
512
+
513
+ /**
514
+ * 下载图片
515
+ * @param fileName
516
+ * @param toImageOptions
517
+ */
518
+ private async snapshot(fileName?: string, toImageOptions?: ToImageOptions) {
519
+ const { fileType = 'png', quality } = toImageOptions ?? {}
520
+ this.fileName = `${fileName ?? `logic-flow.${Date.now()}`}.${fileType}`
521
+ const svg = this.getSvgRootElement(this.lf)
522
+ await updateImageSource(svg as SVGElement)
523
+ if (fileType === 'svg') {
524
+ const copy = await this.cloneSvg(svg)
525
+ const svgString = new XMLSerializer().serializeToString(copy)
526
+ const blob = new Blob([svgString], {
527
+ type: 'image/svg+xml;charset=utf-8',
528
+ })
529
+ const url = URL.createObjectURL(blob)
530
+ this.triggerDownload(url)
531
+ } else {
532
+ this.getCanvasData(svg, toImageOptions ?? {}).then(
533
+ (canvas: HTMLCanvasElement) => {
534
+ // canvas元素 => base64 url image/octet-stream: 确保所有浏览器都能正常下载
535
+ const imgUrl = canvas
536
+ .toDataURL(`image/${fileType}`, quality)
537
+ .replace(`image/${fileType}`, 'image/octet-stream')
538
+ this.triggerDownload(imgUrl)
539
+ },
540
+ )
461
541
  }
462
- return copy
542
+ }
543
+
544
+ /**
545
+ * 获取Blob对象
546
+ * @param fileType
547
+ * @param toImageOptions
548
+ * @returns
549
+ */
550
+ async getSnapshotBlob(
551
+ backgroundColor?: string,
552
+ fileType?: string,
553
+ toImageOptions?: ToImageOptions,
554
+ ): Promise<SnapshotResponse> {
555
+ return await this.withExportPreparation(
556
+ () => this.snapshotBlob(toImageOptions, fileType, backgroundColor),
557
+ toImageOptions,
558
+ )
559
+ }
560
+
561
+ // 内部方法处理blob转换
562
+ private async snapshotBlob(
563
+ toImageOptions?: ToImageOptions,
564
+ baseFileType?: string,
565
+ backgroundColor?: string,
566
+ ): Promise<SnapshotResponse> {
567
+ const { fileType = baseFileType } = toImageOptions ?? {}
568
+ const svg = this.getSvgRootElement(this.lf)
569
+ await updateImageSource(svg as SVGElement)
570
+ return new Promise((resolve) => {
571
+ this.getCanvasData(svg, {
572
+ backgroundColor,
573
+ ...(toImageOptions ?? {}),
574
+ }).then((canvas: HTMLCanvasElement) => {
575
+ canvas.toBlob(
576
+ (blob) => {
577
+ // 输出图片数据以及图片宽高
578
+ resolve({
579
+ data: blob!,
580
+ width: canvas.width,
581
+ height: canvas.height,
582
+ })
583
+ },
584
+ `image/${fileType ?? 'png'}`,
585
+ )
586
+ })
587
+ })
588
+ }
589
+
590
+ /**
591
+ * 获取base64对象
592
+ * @param backgroundColor
593
+ * @param fileType
594
+ * @param toImageOptions
595
+ * @returns
596
+ */
597
+ async getSnapshotBase64(
598
+ backgroundColor?: string,
599
+ fileType?: string,
600
+ toImageOptions?: ToImageOptions,
601
+ ): Promise<SnapshotResponse> {
602
+ console.log(
603
+ 'getSnapshotBase64---------------',
604
+ backgroundColor,
605
+ fileType,
606
+ toImageOptions,
607
+ )
608
+ return await this.withExportPreparation(
609
+ () => this._getSnapshotBase64(backgroundColor, fileType, toImageOptions),
610
+ toImageOptions,
611
+ )
612
+ }
613
+
614
+ // 内部方法处理实际的base64转换
615
+ private async _getSnapshotBase64(
616
+ backgroundColor?: string,
617
+ baseFileType?: string,
618
+ toImageOptions?: ToImageOptions,
619
+ ): Promise<SnapshotResponse> {
620
+ const { fileType = baseFileType } = toImageOptions ?? {}
621
+ const svg = this.getSvgRootElement(this.lf)
622
+ await updateImageSource(svg as SVGElement)
623
+ return new Promise((resolve) => {
624
+ this.getCanvasData(svg, {
625
+ backgroundColor,
626
+ ...(toImageOptions ?? {}),
627
+ }).then((canvas: HTMLCanvasElement) => {
628
+ const base64 = canvas.toDataURL(`image/${fileType ?? 'png'}`)
629
+ resolve({
630
+ data: base64,
631
+ width: canvas.width,
632
+ height: canvas.height,
633
+ })
634
+ })
635
+ })
463
636
  }
464
637
  }
465
638