基于react+react-konva实现对konva节点的灵活布局

本文讲述了如何在React项目中利用ReactKonva框架实现Konva图形节点的动态布局,包括IconText组件的创建、拖拽事件处理和图例的自动排列。作者分享了具体实现步骤和示例代码,供读者参考和优化项目中的此类需求。
摘要由CSDN通过智能技术生成

前言

        在最近的前端项目实战中,我遇到了一个任务,即利用React与React Konva框架,实现对Konva图形节点的灵活、动态布局。接下来,我将深入剖析我是如何系统地攻克这一需求的具体步骤和策略,同时,热烈欢迎各位同行针对本文中的任何疏漏或不足之处提出宝贵意见,并分享可能存在的更为优化的解决方案。需要指出的是,这里所述的方法是基于项目实际场景进行的个性化实践,旨在提供一种解决思路参考。

最终效果

一、version

 "react": "^18.2.0"
 "react-konva": "^18.2.10"
 "konva": "^9.2.0"

二、图例

图例是由图标和文字两部分构成,其中图标部分可以是图片元素,也可以是利用Konva.Rect创建的自定义形状。

IconText组件

export enum DataType {
  'stage' = 'stage',
  'workPointSetting' = 'workPointSetting', // 作业点
  'riskSetting' = 'riskSetting', // 风险分析
  'liveAreaSetting' = 'liveAreaSetting', // 带电区域
  'bucketArmCarSetting' = 'bucketArmCarSetting', // 斗臂车
  'craneSetting' = 'craneSetting', // 吊车
  'serviceAreaSetting' = 'serviceAreaSetting', // 检修区域
  'drivingRoutesSetting' = 'drivingRoutesSetting', // 行驶路线
  'powerRetentionSetting' = 'powerRetentionSetting', // 保电设备
  'safetySetting' = 'safetySetting', // 安全围栏和作业范围
  'controlDiagramSetting' = 'controlDiagramSetting' // 管控图
}
import Konva from 'konva';
import React, { useCallback, useEffect, useMemo, useRef } from 'react';
import { Group, Rect, Transformer } from 'react-konva';
import { DataType } from '../../common';
import { Html } from 'react-konva-utils';

interface IProps {
  x: number;
  y: number;
  name?: DataType;
  iconUrl?: string;
  iconFill?: string;
  iconSzie?: [number, number];
  text?: string;
  textSize?: [number, number];
  textFill?: string;
  textFontSize?: number;
  spacing?: number;
  isDash?: boolean;
  isSelected?: boolean;
  onChange?: (data: any, name: DataType) => void;
}

export const IconText: React.FC<IProps> = props => {
  const { x, y, iconSzie = [10, 10], iconUrl, spacing = 10, iconFill = 'rgba(0, 0, 255, 0.5)', isDash = false, text = '图例', textSize = [10, 10], textFontSize = 14, textFill = 'rgba(0, 0, 0, 0.65)', onChange, isSelected, name = DataType.stage } = props;
  const [iconWidth, iconHeight] = iconSzie;
  const [textWidth, textHeight] = textSize;

  const trRef = useRef<Konva.Transformer>(null);
  const rectRef = useRef<Konva.Rect>(null);

  useEffect(() => {
    if (isSelected && trRef.current !== null && rectRef.current !== null) {
      trRef.current.nodes([rectRef.current]);
    }
  }, [isSelected]);

  useEffect(() => {
    const bgWorkPointElement = document.getElementById('bg-icon-text-' + name);
    if (bgWorkPointElement === null) {
      return;
    }
    const parentElement = bgWorkPointElement.parentElement;
    if (parentElement === null) {
      return;
    }
    const firstChild = parentElement.firstChild;
    if (firstChild === null) {
      parentElement.appendChild(bgWorkPointElement);
    } else {
      parentElement.insertBefore(bgWorkPointElement, firstChild);
    }
  }, [name]);

  const handleOnDragMove = useCallback(
    (e: Konva.KonvaEventObject<DragEvent>) => {
      const target = e.target;
      let newX = target.x();
      let newY = target.y();

      const stage = target.getStage();
      if (stage === null) {
        onChange &&
          onChange(
            {
              x: Math.round(newX),
              y: Math.round(newY)
            },
            name
          );
        return;
      }

      // 获取舞台的宽度和高度
      const stageWidth = stage.width();
      const stageHeight = stage.height();

      // 获取矩形的宽度和高度
      const rectWidth = target.width();
      const rectHeight = iconHeight > textHeight ? iconHeight : textHeight;
      // 计算边界限制
      const minX = 0;
      const minY = 0;
      const maxX = stageWidth - rectWidth;
      const maxY = stageHeight - rectHeight - 10;
      if (newX < minX) {
        newX = minX;
      } else if (newX > maxX) {
        newX = maxX;
      }

      if (newY < minY) {
        newY = minY;
      } else if (newY > maxY) {
        newY = maxY;
      }
      target.x(newX);
      target.y(newY);
      onChange &&
        onChange(
          {
            x: Math.round(newX),
            y: Math.round(newY)
          },
          name
        );
    },
    [iconHeight, name, onChange, textHeight]
  );

  const divMemo = useMemo(() => {
    const groupHeight = iconHeight > textHeight ? iconHeight : textHeight;
    const styles: React.CSSProperties = {
      width: iconWidth + 'px',
      height: iconHeight + 'px'
    };
    return (
      <div style={{ position: 'relative', top: 0, left: 0, width: '100%', height: '100%', overflow: 'hidden', display: 'flex', justifyContent: 'center', alignItems: 'center', fontFamily: 'Inter', backgroundColor: isSelected ? '#F1F4FF' : '' }}>
        {!iconUrl && !isDash && <div style={{ ...styles, backgroundColor: iconFill }}></div>}
        {!iconUrl && isDash && <div style={{ ...styles, border: '1px dashed ' + iconFill }}></div>}
        {iconUrl && <img src={iconUrl} style={{ ...styles }} />}
        <span style={{ fontSize: textFontSize + 'px', marginLeft: '10px', lineHeight: groupHeight + 'px' }}>{text}</span>
      </div>
    );
  }, [iconFill, iconHeight, iconUrl, iconWidth, isDash, isSelected, text, textFontSize, textHeight]);

  return useMemo(() => {
    const groupWidth = iconWidth + textWidth + spacing;
    const groupHeight = iconHeight > textHeight ? iconHeight : textHeight;
    return (
      <Group>
        <Html groupProps={{ x, y }} divProps={{ style: { zIndex: 0, width: groupWidth + 10 + 'px', height: groupHeight + 10 + 'px' }, id: 'bg-icon-text-' + name }}>
          {divMemo}
        </Html>
        <Rect name={name} ref={rectRef} x={x} y={y} width={groupWidth + 10} height={groupHeight + 10} draggable onDragMove={handleOnDragMove} />
        {isSelected && (
          <Transformer
            ref={trRef}
            rotateEnabled={false}
            boundBoxFunc={(oldBox, newBox) => {
              // 限制变换框的范围
              if (newBox.width < 100 || newBox.height < 100) {
                return oldBox;
              }
              return newBox;
            }}
            enabledAnchors={[]}
          />
        )}
      </Group>
    );
  }, [divMemo, handleOnDragMove, iconHeight, iconWidth, isSelected, name, spacing, textHeight, textWidth, x, y]);
};

export default IconText;

class Legend

interface LegendConfig {
  name: DataType;
  iconSzie: [number, number]; //宽高
  textSize: [number, number]; //宽高
  iconFill?: string;
  iconUrl?: string;
  text: string;
  spacing: number;
}

type WithUndefined<T = any> = T | undefined;
export class Legend {
  private _x: number = 0;

  private _y: number = 0;

  private _name: WithUndefined<DataType>;

  private _iconSzie: WithUndefined<[number, number]>;

  private _textSize: WithUndefined<[number, number]>;

  private _iconFill: WithUndefined<string>;

  private _iconUrl: WithUndefined<string>;

  private _text: WithUndefined<string>;

  private _spacing: WithUndefined<number>;

  constructor(options: LegendConfig) {
    const { name, iconSzie, textSize, iconFill, iconUrl, text, spacing } = options;
    this._name = name;
    this._iconSzie = iconSzie;
    this._textSize = textSize;
    this._iconFill = iconFill;
    this._iconUrl = iconUrl;
    this._text = text;
    this._spacing = spacing;
  }

  get x() {
    return this._x;
  }

  set x(value: number) {
    this._x = value;
  }

  get y() {
    return this._y;
  }

  set y(value: number) {
    this._y = value;
  }

  get name() {
    return this._name;
  }

  get iconSzie() {
    return this._iconSzie;
  }

  get textSize() {
    return this._textSize;
  }

  get iconFill() {
    return this._iconFill;
  }

  get iconUrl() {
    return this._iconUrl;
  }

  get text() {
    return this._text;
  }

  get spacing() {
    return this._spacing;
  }

  get width() {
    if (this._iconSzie && this._textSize && this._spacing) {
      const iconSizeWidth = Array.isArray(this._iconSzie) ? this._iconSzie[0] : undefined;
      const textSizeWidth = Array.isArray(this._textSize) ? this._textSize[0] : undefined;
      if (iconSizeWidth !== undefined && textSizeWidth !== undefined) {
        return iconSizeWidth + textSizeWidth + this._spacing;
      } else {
        return undefined;
      }
    } else {
      return undefined;
    }
  }

  get height() {
    if (this._iconSzie && this._textSize) {
      const iconSizeHeight = Array.isArray(this._iconSzie) ? this._iconSzie[1] : undefined;
      const textSizeHeight = Array.isArray(this._textSize) ? this._textSize[1] : undefined;
      if (iconSizeHeight !== undefined && textSizeHeight !== undefined) {
        return iconSizeHeight > textSizeHeight ? iconSizeHeight : textSizeHeight;
      } else {
        return undefined;
      }
    } else {
      return undefined;
    }
  }
}

初始化数据

export const liveAreaIconConfig: Legend = new Legend({
  name: DataType.liveAreaSetting,
  iconSzie: [44, 16],
  textSize: [56, 16],
  iconFill: '#FFAAAA',
  text: '带电区域',
  spacing: 10
});

export const serviceAreaIconConfig: Legend = new Legend({
  name: DataType.serviceAreaSetting,
  iconSzie: [44, 16],
  textSize: [56, 16],
  iconFill: '#92D050',
  text: '检修区域',
  spacing: 10
});

export const powerRetentionIconConfig = new Legend({
  name: DataType.powerRetentionSetting,
  iconSzie: [44, 16],
  textSize: [56, 16],
  iconFill: '#00B0F0',
  text: '保电设备',
  spacing: 10
});

export const safetyIconConfig: Legend = new Legend({
  name: DataType.safetySetting,
  iconSzie: [44, 16],
  textSize: [126, 16],
  iconFill: '#C94242',
  text: '安全围栏和作业范围',
  spacing: 10
});

export const bucketArmCarIconConfig: Legend = new Legend({
  name: DataType.bucketArmCarSetting,
  iconSzie: [32, 32],
  textSize: [42, 16],
  text: '斗臂车',
  spacing: 10
});

export const craneIconConfig: Legend = new Legend({
  name: DataType.craneSetting,
  iconSzie: [32, 32],
  textSize: [28, 16],
  text: '吊车',
  spacing: 10
});

export const drivingRoutesIconConfig: Legend = new Legend({
  name: DataType.drivingRoutesSetting,
  iconSzie: [44, 16],
  textSize: [56, 16],
  text: '行驶路线',
  spacing: 10
});

排列图例方法

export const sortLegend = (legends: Array<Legend>, lineSpacing: number = 10, spacing: number = 10, containerWidth: number = 350, offsetX: number = 0, offsetY: number = 0) => {
  const sort: Array<{
    legends: Array<Legend>;
    rowHeight: number;
  }> = [];

  // 换行 x坐标
  let row = 0;
  let currentWidth = 0;
  for (let index = 0; index < legends.length; index++) {
    const legend = legends[index];
    const width = (legend.width || 0) + spacing;
    currentWidth += width;
    if (currentWidth <= containerWidth) {
      sort[row] === undefined &&
        (sort[row] = {
          legends: [],
          rowHeight: 0
        });
      legend.x = currentWidth - width + offsetX;
      sort[row].legends.push(legend);
    } else {
      ++row;
      currentWidth = width;
      sort[row] === undefined &&
        (sort[row] = {
          legends: [],
          rowHeight: 0
        });
      legend.x = currentWidth - width + offsetX;
      sort[row].legends.push(legend);
    }
  }

  // 行高 y坐标
  let height: number = 0;
  for (let index = 0; index < sort.length; index++) {
    const element = sort[index];
    const rowHeight = Math.max(...element.legends.map(item => item.height || 0)) + lineSpacing; // 每一行的最大宽度
    element.rowHeight = rowHeight;
    height += rowHeight;
    for (let j = 0; j < element.legends.length; j++) {
      const legend = element.legends[j];
      const currentHeight = (legend.height || 0) + lineSpacing;
      if (currentHeight < rowHeight) {
        legend.y = height - rowHeight + (rowHeight / 2 - currentHeight / 2) + offsetY;
      } else {
        legend.y = height - rowHeight + offsetY;
      }
    }
  }

  return sort.flatMap(item => item.legends);
};

使用

 const legends: Legend[] = [];
      legends.push(liveAreaIconConfig);
      isCrane && legends.push(craneIconConfig);
      isCat && legends.push(bucketArmCarIconConfig);
      legends.push(serviceAreaIconConfig);
      isArrow && legends.push(drivingRoutesIconConfig);
      _protectText && legends.push(powerRetentionIconConfig);
      legends.push(safetyIconConfig);
      const sortLegends = sortLegend(legends, 10, 5, 300);
      for (let index = 0; index < sortLegends.length; index++) {
        const element = sortLegends[index];
        const xy = {
          x: element.x,
          y: element.y
        };
        element.name && updateSettingXy(element.name, xy);
      }

  • 10
    点赞
  • 8
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
好的,下面是基于 React 和 Electron 并使用 electron-builder 进行打包的教程: 1. 创建 React 应用 首先,我们需要使用 create-react-app 工具创建一个 React 应用。在命令行中运行以下命令: ``` npx create-react-app my-electron-app ``` 这会在当前目录下创建一个名为 `my-electron-app` 的新应用。 2. 添加 Electron 接下来,我们需要在项目中添加 Electron。我们可以使用 `electron` 和 `electron-builder` 库来实现这一点。 在命令行中运行以下命令: ``` cd my-electron-app npm install --save-dev electron electron-builder ``` 3. 添加 Electron 入口文件 我们需要创建一个新文件 `public/electron.js`,这是 Electron 的入口文件。在这个文件中,我们需要引入 `electron` 模块并创建一个新的 Electron 窗口。 ```js const { app, BrowserWindow } = require('electron') function createWindow () { const win = new BrowserWindow({ width: 800, height: 600, webPreferences: { nodeIntegration: true } }) win.loadURL('http://localhost:3000') } app.whenReady().then(() => { createWindow() app.on('activate', function () { if (BrowserWindow.getAllWindows().length === 0) createWindow() }) }) app.on('window-all-closed', function () { if (process.platform !== 'darwin') app.quit() }) ``` 这个文件创建了一个新的 Electron 窗口,并在窗口中加载了 React 应用的 `http://localhost:3000` 页面。 4. 修改 package.json 文件 我们需要修改 `package.json` 文件中的一些字段,以便 electron-builder 能够正确地打包我们的应用。 ```json { "name": "my-electron-app", "version": "0.1.0", "homepage": "./", "main": "public/electron.js", "build": { "appId": "com.example.my-electron-app", "productName": "My Electron App", "directories": { "output": "build" }, "files": [ "build/**/*", "node_modules/**/*", "public/**/*" ], "mac": { "category": "public.app-category.developer-tools", "icon": "public/icon.icns" }, "win": { "icon": "public/icon.ico" } } } ``` 这个文件中的 `main` 字段告诉 electron-builder 我们的 Electron 入口文件在 `public/electron.js`。`build` 字段中的其他字段指定了打包的一些设置,例如应用的 ID、名称、输出目录、文件列表和图标。 5. 添加打包脚本 最后,我们需要在 `package.json` 文件中添加一个打包脚本。 ```json { "scripts": { "start": "react-scripts start", "build": "react-scripts build", "pack": "electron-builder --dir", "dist": "electron-builder" } } ``` 这些脚本中的 `pack` 脚本会在开发模式下打包应用程序,而 `dist` 脚本会在生产模式下打包应用程序。 6. 运行应用 现在,我们可以运行应用程序。在命令行中运行以下命令: ``` npm start ``` 这将启动 React 应用程序。 然后,在另一个命令行窗口中运行以下命令: ``` npm run pack ``` 这将使用 `electron-builder` 打包应用程序,并在输出目录中生成一个可执行文件。 如果您想要构建一个安装程序,您可以运行以下命令: ``` npm run dist ``` 这将打包应用程序,并在输出目录中生成一个安装程序。 这就是使用 React 和 Electron 并使用 electron-builder 进行打包的教程。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值