background-layer.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521
  1. /**
  2. * Copyright (c) 2025 Bytedance Ltd. and/or its affiliates
  3. * SPDX-License-Identifier: MIT
  4. */
  5. import { domUtils } from '@flowgram.ai/utils';
  6. import { Layer, observeEntity, PlaygroundConfigEntity, SCALE_WIDTH } from '@flowgram.ai/core';
  7. interface BackgroundScaleUnit {
  8. realSize: number;
  9. renderSize: number;
  10. zoom: number;
  11. }
  12. const PATTERN_PREFIX = 'gedit-background-pattern-';
  13. const DEFAULT_RENDER_SIZE = 20;
  14. const DEFAULT_DOT_SIZE = 1;
  15. let id = 0;
  16. export const BackgroundConfig = Symbol('BackgroundConfig');
  17. export interface BackgroundLayerOptions {
  18. /** 网格间距,默认 20px */
  19. gridSize?: number;
  20. /** 点的大小,默认 1px */
  21. dotSize?: number;
  22. /** 点的颜色,默认 "#eceeef" */
  23. dotColor?: string;
  24. /** 点的透明度,默认 0.5 */
  25. dotOpacity?: number;
  26. /** 背景颜色,默认透明 */
  27. backgroundColor?: string;
  28. /** 点的填充颜色,默认与stroke颜色相同 */
  29. dotFillColor?: string;
  30. /** Logo 配置 */
  31. logo?: {
  32. /** Logo 文本内容 */
  33. text?: string;
  34. /** Logo 图片 URL */
  35. imageUrl?: string;
  36. /** Logo 位置,默认 'center' */
  37. position?: 'center' | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
  38. /** Logo 大小,默认 'medium' */
  39. size?: 'small' | 'medium' | 'large' | number;
  40. /** Logo 透明度,默认 0.1 */
  41. opacity?: number;
  42. /** Logo 颜色(仅文本),默认 "#cccccc" */
  43. color?: string;
  44. /** Logo 字体大小(仅文本),默认根据 size 计算 */
  45. fontSize?: number;
  46. /** Logo 字体家族(仅文本),默认 'Arial, sans-serif' */
  47. fontFamily?: string;
  48. /** Logo 字体粗细(仅文本),默认 'normal' */
  49. fontWeight?: 'normal' | 'bold' | 'lighter' | number;
  50. /** 自定义偏移 */
  51. offset?: { x: number; y: number };
  52. /** 新拟态(Neumorphism)效果配置 */
  53. neumorphism?: {
  54. /** 是否启用新拟态效果,默认 false */
  55. enabled?: boolean;
  56. /** 主要文字颜色,应该与背景色接近,默认自动计算 */
  57. textColor?: string;
  58. /** 亮色阴影颜色,默认自动计算(背景色的亮色版本) */
  59. lightShadowColor?: string;
  60. /** 暗色阴影颜色,默认自动计算(背景色的暗色版本) */
  61. darkShadowColor?: string;
  62. /** 阴影偏移距离,默认 6 */
  63. shadowOffset?: number;
  64. /** 阴影模糊半径,默认 12 */
  65. shadowBlur?: number;
  66. /** 效果强度(0-1),影响阴影的透明度,默认 0.3 */
  67. intensity?: number;
  68. /** 凸起效果(true)还是凹陷效果(false),默认 true */
  69. raised?: boolean;
  70. };
  71. };
  72. }
  73. /**
  74. * dot 网格背景
  75. */
  76. export class BackgroundLayer extends Layer<BackgroundLayerOptions> {
  77. static type = 'WorkflowBackgroundLayer';
  78. @observeEntity(PlaygroundConfigEntity)
  79. protected playgroundConfigEntity: PlaygroundConfigEntity;
  80. private _patternId = `${PATTERN_PREFIX}${id++}`;
  81. node = domUtils.createDivWithClass('gedit-flow-background-layer');
  82. grid: HTMLElement = document.createElement('div');
  83. /**
  84. * 获取网格大小配置
  85. */
  86. private get gridSize(): number {
  87. return this.options.gridSize ?? DEFAULT_RENDER_SIZE;
  88. }
  89. /**
  90. * 获取点大小配置
  91. */
  92. private get dotSize(): number {
  93. return this.options.dotSize ?? DEFAULT_DOT_SIZE;
  94. }
  95. /**
  96. * 获取点颜色配置
  97. */
  98. private get dotColor(): string {
  99. return this.options.dotColor ?? '#eceeef';
  100. }
  101. /**
  102. * 获取点透明度配置
  103. */
  104. private get dotOpacity(): number {
  105. return this.options.dotOpacity ?? 0.5;
  106. }
  107. /**
  108. * 获取背景颜色配置
  109. */
  110. private get backgroundColor(): string {
  111. return this.options.backgroundColor ?? 'transparent';
  112. }
  113. /**
  114. * 获取点填充颜色配置
  115. */
  116. private get dotFillColor(): string {
  117. return this.options.dotFillColor ?? this.dotColor;
  118. }
  119. /**
  120. * 获取Logo配置
  121. */
  122. private get logoConfig() {
  123. return this.options.logo;
  124. }
  125. /**
  126. * 当前缩放比
  127. */
  128. get zoom(): number {
  129. return this.config.finalScale;
  130. }
  131. onReady() {
  132. const { firstChild } = this.pipelineNode;
  133. // 背景插入到最下边
  134. this.pipelineNode.insertBefore(this.node, firstChild);
  135. // 初始化设置最大 200% 最小 10% 缩放
  136. this.playgroundConfigEntity.updateConfig({
  137. minZoom: 0.1,
  138. maxZoom: 2,
  139. });
  140. // 确保点的位置在线条的下方
  141. this.grid.style.zIndex = '-1';
  142. this.grid.style.position = 'relative';
  143. this.node.appendChild(this.grid);
  144. this.grid.className = 'gedit-grid-svg';
  145. // 设置背景颜色
  146. if (this.backgroundColor !== 'transparent') {
  147. this.node.style.backgroundColor = this.backgroundColor;
  148. }
  149. }
  150. /**
  151. * 最小单元格大小
  152. */
  153. getScaleUnit(): BackgroundScaleUnit {
  154. const { zoom } = this;
  155. return {
  156. realSize: this.gridSize, // 使用配置的网格大小
  157. renderSize: Math.round(this.gridSize * zoom * 100) / 100, // 一个单元格渲染的大小值
  158. zoom, // 缩放比
  159. };
  160. }
  161. /**
  162. * 绘制
  163. */
  164. autorun(): void {
  165. const playgroundConfig = this.playgroundConfigEntity.config;
  166. const scaleUnit = this.getScaleUnit();
  167. const mod = scaleUnit.renderSize * 10;
  168. const viewBoxWidth = playgroundConfig.width + mod * 2;
  169. const viewBoxHeight = playgroundConfig.height + mod * 2;
  170. const { scrollX } = playgroundConfig;
  171. const { scrollY } = playgroundConfig;
  172. const scrollXDelta = this.getScrollDelta(scrollX, mod);
  173. const scrollYDelta = this.getScrollDelta(scrollY, mod);
  174. domUtils.setStyle(this.node, {
  175. left: scrollX - SCALE_WIDTH,
  176. top: scrollY - SCALE_WIDTH,
  177. });
  178. this.drawGrid(scaleUnit, viewBoxWidth, viewBoxHeight);
  179. // 设置网格
  180. this.setSVGStyle(this.grid, {
  181. width: viewBoxWidth,
  182. height: viewBoxHeight,
  183. left: SCALE_WIDTH - scrollXDelta - mod,
  184. top: SCALE_WIDTH - scrollYDelta - mod,
  185. });
  186. }
  187. /**
  188. * 计算Logo位置
  189. */
  190. private calculateLogoPosition(
  191. viewBoxWidth: number,
  192. viewBoxHeight: number
  193. ): { x: number; y: number } {
  194. if (!this.logoConfig) return { x: 0, y: 0 };
  195. const { position = 'center', offset = { x: 0, y: 0 } } = this.logoConfig;
  196. const playgroundConfig = this.playgroundConfigEntity.config;
  197. const scaleUnit = this.getScaleUnit();
  198. const mod = scaleUnit.renderSize * 10;
  199. // 计算SVG内的相对位置,使Logo相对于可视区域固定
  200. const { scrollX, scrollY } = playgroundConfig;
  201. const scrollXDelta = this.getScrollDelta(scrollX, mod);
  202. const scrollYDelta = this.getScrollDelta(scrollY, mod);
  203. // 可视区域的基准点(相对于SVG坐标系)
  204. const visibleLeft = mod + scrollXDelta;
  205. const visibleTop = mod + scrollYDelta;
  206. const visibleCenterX = visibleLeft + playgroundConfig.width / 2;
  207. const visibleCenterY = visibleTop + playgroundConfig.height / 2;
  208. let x = 0,
  209. y = 0;
  210. switch (position) {
  211. case 'center':
  212. x = visibleCenterX;
  213. y = visibleCenterY;
  214. break;
  215. case 'top-left':
  216. x = visibleLeft + 100;
  217. y = visibleTop + 100;
  218. break;
  219. case 'top-right':
  220. x = visibleLeft + playgroundConfig.width - 100;
  221. y = visibleTop + 100;
  222. break;
  223. case 'bottom-left':
  224. x = visibleLeft + 100;
  225. y = visibleTop + playgroundConfig.height - 100;
  226. break;
  227. case 'bottom-right':
  228. x = visibleLeft + playgroundConfig.width - 100;
  229. y = visibleTop + playgroundConfig.height - 100;
  230. break;
  231. }
  232. return { x: x + offset.x, y: y + offset.y };
  233. }
  234. /**
  235. * 获取Logo大小
  236. */
  237. private getLogoSize(): number {
  238. if (!this.logoConfig) return 0;
  239. const { size = 'medium' } = this.logoConfig;
  240. if (typeof size === 'number') {
  241. return size;
  242. }
  243. switch (size) {
  244. case 'small':
  245. return 24;
  246. case 'medium':
  247. return 48;
  248. case 'large':
  249. return 72;
  250. default:
  251. return 48;
  252. }
  253. }
  254. /**
  255. * 颜色工具函数:将十六进制颜色转换为RGB
  256. */
  257. private hexToRgb(hex: string): { r: number; g: number; b: number } | null {
  258. const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
  259. return result
  260. ? {
  261. r: parseInt(result[1], 16),
  262. g: parseInt(result[2], 16),
  263. b: parseInt(result[3], 16),
  264. }
  265. : null;
  266. }
  267. /**
  268. * 颜色工具函数:调整颜色亮度
  269. */
  270. private adjustBrightness(hex: string, percent: number): string {
  271. const rgb = this.hexToRgb(hex);
  272. if (!rgb) return hex;
  273. const adjust = (value: number) => {
  274. const adjusted = Math.round(value + (255 - value) * percent);
  275. return Math.max(0, Math.min(255, adjusted));
  276. };
  277. return `#${adjust(rgb.r).toString(16).padStart(2, '0')}${adjust(rgb.g)
  278. .toString(16)
  279. .padStart(2, '0')}${adjust(rgb.b).toString(16).padStart(2, '0')}`;
  280. }
  281. /**
  282. * 生成新拟态阴影滤镜
  283. */
  284. private generateNeumorphismFilter(
  285. filterId: string,
  286. lightShadow: string,
  287. darkShadow: string,
  288. offset: number,
  289. blur: number,
  290. intensity: number,
  291. raised: boolean
  292. ): string {
  293. const lightOffset = raised ? -offset : offset;
  294. const darkOffset = raised ? offset : -offset;
  295. return `
  296. <defs>
  297. <filter id="${filterId}" x="-50%" y="-50%" width="200%" height="200%">
  298. <feDropShadow dx="${lightOffset}" dy="${lightOffset}" stdDeviation="${blur}" flood-color="${lightShadow}" flood-opacity="${intensity}"/>
  299. <feDropShadow dx="${darkOffset}" dy="${darkOffset}" stdDeviation="${blur}" flood-color="${darkShadow}" flood-opacity="${intensity}"/>
  300. </filter>
  301. </defs>`;
  302. }
  303. /**
  304. * 绘制Logo SVG内容
  305. */
  306. private generateLogoSVG(viewBoxWidth: number, viewBoxHeight: number): string {
  307. if (!this.logoConfig) return '';
  308. const {
  309. text,
  310. imageUrl,
  311. opacity = 0.1,
  312. color = '#cccccc',
  313. fontSize,
  314. fontFamily = 'Arial, sans-serif',
  315. fontWeight = 'normal',
  316. neumorphism,
  317. } = this.logoConfig;
  318. const position = this.calculateLogoPosition(viewBoxWidth, viewBoxHeight);
  319. const logoSize = this.getLogoSize();
  320. let logoSVG = '';
  321. if (imageUrl) {
  322. // 图片Logo(暂不支持3D效果)
  323. logoSVG = `
  324. <image
  325. href="${imageUrl}"
  326. x="${position.x - logoSize / 2}"
  327. y="${position.y - logoSize / 2}"
  328. width="${logoSize}"
  329. height="${logoSize}"
  330. opacity="${opacity}"
  331. />`;
  332. } else if (text) {
  333. // 文本Logo
  334. const actualFontSize = fontSize ?? Math.max(logoSize / 2, 12);
  335. // 检查是否启用新拟态效果
  336. if (neumorphism?.enabled) {
  337. const {
  338. textColor,
  339. lightShadowColor,
  340. darkShadowColor,
  341. shadowOffset = 6,
  342. shadowBlur = 12,
  343. intensity = 0.3,
  344. raised = true,
  345. } = neumorphism;
  346. // 自动计算颜色(如果未提供)
  347. const bgColor = this.backgroundColor !== 'transparent' ? this.backgroundColor : '#f0f0f0';
  348. const finalTextColor = textColor || bgColor;
  349. const finalLightShadow = lightShadowColor || this.adjustBrightness(bgColor, 0.2);
  350. const finalDarkShadow = darkShadowColor || this.adjustBrightness(bgColor, -0.2);
  351. const filterId = `neumorphism-${this._patternId}`;
  352. // 添加新拟态滤镜定义
  353. logoSVG += this.generateNeumorphismFilter(
  354. filterId,
  355. finalLightShadow,
  356. finalDarkShadow,
  357. shadowOffset,
  358. shadowBlur,
  359. intensity,
  360. raised
  361. );
  362. // 创建新拟态文本
  363. logoSVG += `
  364. <text
  365. x="${position.x}"
  366. y="${position.y}"
  367. font-family="${fontFamily}"
  368. font-size="${actualFontSize}"
  369. font-weight="${fontWeight}"
  370. fill="${finalTextColor}"
  371. opacity="${opacity}"
  372. text-anchor="middle"
  373. dominant-baseline="middle"
  374. filter="url(#${filterId})"
  375. >${text}</text>`;
  376. } else {
  377. // 普通文本(无3D效果)
  378. logoSVG = `
  379. <text
  380. x="${position.x}"
  381. y="${position.y}"
  382. font-family="${fontFamily}"
  383. font-size="${actualFontSize}"
  384. font-weight="${fontWeight}"
  385. fill="${color}"
  386. opacity="${opacity}"
  387. text-anchor="middle"
  388. dominant-baseline="middle"
  389. >${text}</text>`;
  390. }
  391. }
  392. return logoSVG;
  393. }
  394. /**
  395. * 绘制网格
  396. */
  397. protected drawGrid(unit: BackgroundScaleUnit, viewBoxWidth: number, viewBoxHeight: number): void {
  398. const minor = unit.renderSize;
  399. if (!this.grid) {
  400. return;
  401. }
  402. const patternSize = this.dotSize * this.zoom;
  403. // 构建SVG内容,根据是否有背景颜色决定是否添加背景矩形
  404. let svgContent = `<svg width="100%" height="100%">`;
  405. // 如果设置了背景颜色,先绘制背景矩形
  406. if (this.backgroundColor !== 'transparent') {
  407. svgContent += `<rect width="100%" height="100%" fill="${this.backgroundColor}"/>`;
  408. }
  409. // 添加点阵图案
  410. // 构建圆圈属性,保持与原始实现的兼容性
  411. const circleAttributes = [
  412. `cx="${patternSize}"`,
  413. `cy="${patternSize}"`,
  414. `r="${patternSize}"`,
  415. `stroke="${this.dotColor}"`,
  416. // 只有当 dotFillColor 被明确设置且与 dotColor 不同时才添加 fill 属性
  417. this.options.dotFillColor && this.dotFillColor !== this.dotColor
  418. ? `fill="${this.dotFillColor}"`
  419. : '',
  420. `fill-opacity="${this.dotOpacity}"`,
  421. ]
  422. .filter(Boolean)
  423. .join(' ');
  424. svgContent += `
  425. <pattern id="${this._patternId}" width="${minor}" height="${minor}" patternUnits="userSpaceOnUse">
  426. <circle ${circleAttributes} />
  427. </pattern>
  428. <rect width="100%" height="100%" fill="url(#${this._patternId})"/>`;
  429. // 添加Logo
  430. const logoSVG = this.generateLogoSVG(viewBoxWidth, viewBoxHeight);
  431. if (logoSVG) {
  432. svgContent += logoSVG;
  433. }
  434. svgContent += `</svg>`;
  435. this.grid.innerHTML = svgContent;
  436. }
  437. protected setSVGStyle(
  438. svgElement: HTMLElement | undefined,
  439. style: { width: number; height: number; left: number; top: number }
  440. ): void {
  441. if (!svgElement) {
  442. return;
  443. }
  444. svgElement.style.width = `${style.width}px`;
  445. svgElement.style.height = `${style.height}px`;
  446. svgElement.style.left = `${style.left}px`;
  447. svgElement.style.top = `${style.top}px`;
  448. }
  449. /**
  450. * 获取相对滚动距离
  451. * @param realScroll
  452. * @param mod
  453. */
  454. protected getScrollDelta(realScroll: number, mod: number): number {
  455. // 正向滚动不用补差
  456. if (realScroll >= 0) {
  457. return realScroll % mod;
  458. }
  459. return mod - (Math.abs(realScroll) % mod);
  460. }
  461. }