service.ts 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. /**
  2. * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
  3. * SPDX-License-Identifier: MIT
  4. */
  5. import { inject, injectable } from 'inversify';
  6. import { FlowDocument } from '@flowgram.ai/document';
  7. import { getWorkflowRect } from './utils';
  8. import { type IFlowExportImageService, type ExportImageOptions } from './type';
  9. import {
  10. IN_SAFARI,
  11. IN_FIREFOX,
  12. EXPORT_IMAGE_WATERMARK_SVG,
  13. EXPORT_IMAGE_STYLE_PROPERTIES,
  14. } from './constant';
  15. import { FlowDownloadFormat } from '../constant';
  16. const PADDING_X = 58;
  17. const PADDING_Y = 138;
  18. @injectable()
  19. export class FlowExportImageService implements IFlowExportImageService {
  20. private modernScreenshot: any;
  21. @inject(FlowDocument)
  22. private document: FlowDocument;
  23. public async export(options: ExportImageOptions): Promise<string | undefined> {
  24. try {
  25. const imgUrl = await this.doExport(options);
  26. return imgUrl;
  27. } catch (e) {
  28. console.error('Export image failed:', e);
  29. return;
  30. }
  31. }
  32. private async loadModernScreenshot() {
  33. if (this.modernScreenshot) {
  34. return this.modernScreenshot;
  35. }
  36. const modernScreenshot = await import('modern-screenshot');
  37. this.modernScreenshot = modernScreenshot;
  38. }
  39. private async doExport(exportOptions: ExportImageOptions): Promise<string | undefined> {
  40. if (this.document.layout.name.includes('fixed-layout')) {
  41. return await this.doFixedExport(exportOptions);
  42. }
  43. return await this.doFreeExport(exportOptions);
  44. }
  45. private async doFreeExport(exportOptions: ExportImageOptions): Promise<string | undefined> {
  46. const { format } = exportOptions;
  47. // const el = this.stackingContextManager.node as HTMLElement;
  48. const renderLayer = window.document.querySelector('.gedit-flow-render-layer') as HTMLElement;
  49. if (!renderLayer) {
  50. return;
  51. }
  52. const { width, height, x, y } = getWorkflowRect(this.document);
  53. await this.loadModernScreenshot();
  54. const { domToPng, domToForeignObjectSvg, domToJpeg } = this.modernScreenshot;
  55. let imgUrl: string;
  56. const options = {
  57. scale: 2,
  58. includeStyleProperties: IN_SAFARI || IN_FIREFOX ? EXPORT_IMAGE_STYLE_PROPERTIES : undefined,
  59. width: width + PADDING_X * 2,
  60. height: height + PADDING_Y * 2,
  61. onCloneEachNode: (cloned: HTMLElement) => {
  62. this.handleFreeClone(cloned, { width, height, x, y, options: exportOptions });
  63. },
  64. };
  65. switch (format) {
  66. case FlowDownloadFormat.PNG:
  67. imgUrl = await domToPng(renderLayer, options);
  68. break;
  69. case FlowDownloadFormat.SVG: {
  70. const svg = await domToForeignObjectSvg(renderLayer, options);
  71. imgUrl = await this.svgToDataURL(svg);
  72. break;
  73. }
  74. case FlowDownloadFormat.JPEG:
  75. imgUrl = await domToJpeg(renderLayer, options);
  76. break;
  77. default:
  78. imgUrl = await domToPng(renderLayer, options);
  79. }
  80. return imgUrl;
  81. }
  82. private async doFixedExport(exportOptions: ExportImageOptions): Promise<string | undefined> {
  83. const { format } = exportOptions;
  84. const el = window.document.querySelector('.gedit-flow-nodes-layer') as HTMLElement;
  85. if (!el) {
  86. return;
  87. }
  88. const { width, height, x, y } = getWorkflowRect(this.document);
  89. await this.loadModernScreenshot();
  90. const { domToPng, domToForeignObjectSvg, domToJpeg } = this.modernScreenshot;
  91. let imgUrl: string;
  92. const options = {
  93. scale: 2,
  94. includeStyleProperties: IN_SAFARI || IN_FIREFOX ? EXPORT_IMAGE_STYLE_PROPERTIES : undefined,
  95. width: width + PADDING_X * 2,
  96. height: height + PADDING_Y * 2,
  97. onCloneEachNode: (cloned: HTMLElement) => {
  98. this.handleFixedClone(cloned, { width, height, x, y, options: exportOptions });
  99. },
  100. };
  101. switch (format) {
  102. case FlowDownloadFormat.PNG:
  103. imgUrl = await domToPng(el, options);
  104. break;
  105. case FlowDownloadFormat.SVG: {
  106. const svg = await domToForeignObjectSvg(el, options);
  107. imgUrl = await this.svgToDataURL(svg);
  108. break;
  109. }
  110. case FlowDownloadFormat.JPEG:
  111. imgUrl = await domToJpeg(el, options);
  112. break;
  113. default:
  114. imgUrl = await domToPng(el, options);
  115. }
  116. return imgUrl;
  117. }
  118. private async svgToDataURL(svg: SVGElement): Promise<string> {
  119. return Promise.resolve()
  120. .then(() => new XMLSerializer().serializeToString(svg))
  121. .then(encodeURIComponent)
  122. .then((html) => `data:image/svg+xml;charset=utf-8,${html}`);
  123. }
  124. // 处理克隆节点
  125. private handleFreeClone(
  126. cloned: HTMLElement,
  127. {
  128. width,
  129. height,
  130. x,
  131. y,
  132. options,
  133. }: { width: number; height: number; x: number; y: number; options: ExportImageOptions }
  134. ) {
  135. if (
  136. cloned?.classList?.contains('gedit-flow-activity-node') ||
  137. cloned?.classList?.contains('gedit-flow-activity-line')
  138. ) {
  139. this.handlePosition(cloned, x, y);
  140. }
  141. if (cloned?.classList?.contains('gedit-flow-render-layer')) {
  142. this.handleCanvas(cloned, width, height, options);
  143. }
  144. }
  145. // 处理克隆节点
  146. private handleFixedClone(
  147. cloned: HTMLElement,
  148. {
  149. width,
  150. height,
  151. x,
  152. y,
  153. options,
  154. }: { width: number; height: number; x: number; y: number; options: ExportImageOptions }
  155. ) {
  156. if (
  157. cloned?.classList?.contains('gedit-flow-activity-node') ||
  158. cloned?.classList?.contains('gedit-flow-activity-line')
  159. ) {
  160. this.handlePosition(cloned, x, y);
  161. }
  162. if (cloned?.classList?.contains('gedit-flow-nodes-layer')) {
  163. const linesLayer = window.document
  164. .querySelector('.gedit-flow-lines-layer')
  165. ?.cloneNode(true) as HTMLElement;
  166. this.handleLines(linesLayer, width, height);
  167. cloned.appendChild(linesLayer);
  168. this.handleCanvas(cloned, width, height, options);
  169. }
  170. }
  171. // 处理节点位置
  172. private handlePosition(cloned: HTMLElement, x: number, y: number) {
  173. cloned.style.transform = `translate(${-x + PADDING_X}px, ${-y + PADDING_Y}px)`;
  174. }
  175. // 处理画布
  176. private handleLines(cloned: HTMLElement, width: number, height: number) {
  177. cloned.style.position = 'absolute';
  178. cloned.style.width = `${width}px`;
  179. cloned.style.height = `${height}px`;
  180. cloned.style.left = `${width / 2 - PADDING_X}px`;
  181. cloned.style.top = `${PADDING_Y}px`;
  182. cloned.style.transform = 'none';
  183. cloned.style.backgroundColor = 'transparent';
  184. cloned.querySelector('.flow-lines-container')!.setAttribute('viewBox', `0 0 1000 1000`);
  185. }
  186. // 处理画布
  187. private handleCanvas(
  188. cloned: HTMLElement,
  189. width: number,
  190. height: number,
  191. options: ExportImageOptions
  192. ) {
  193. cloned.style.width = `${width + PADDING_X * 2}px`;
  194. cloned.style.height = `${height + PADDING_Y * 2}px`;
  195. cloned.style.transform = 'none';
  196. cloned.style.backgroundColor = '#ECECEE';
  197. this.handleWaterMark(cloned, options);
  198. }
  199. // 添加水印节点
  200. private handleWaterMark(element: HTMLElement, options: ExportImageOptions) {
  201. const watermarkNode = document.createElement('div');
  202. // 水印svg
  203. watermarkNode.innerHTML = options?.watermarkSVG ?? EXPORT_IMAGE_WATERMARK_SVG;
  204. watermarkNode.style.position = 'absolute';
  205. watermarkNode.style.bottom = '32px';
  206. watermarkNode.style.right = '32px';
  207. watermarkNode.style.zIndex = '999999';
  208. element.appendChild(watermarkNode);
  209. }
  210. }