svg实现图形编辑器系列七:右键菜单、快捷键、撤销回退

2,108 阅读4分钟

在之前的系列文章中,我们介绍了图形编辑器基础的 移动缩放旋转 等基础编辑能力,以及吸附、网格、辅助线、锚点、连接线等辅助编辑的能力。这些能力提高了编辑功能的上限,本文将介绍的是提效相关的功能:右键菜单、快捷键、撤销回退。

一、右键菜单

1. 右键菜单底层方案

关于右键菜单的通用底层解决方案,我之前已经写了一篇文章专门介绍,有兴趣的同学欢迎参考:

功能:

  • 每个菜单项都可以独立设置是否禁用、是否隐藏
  • 支持子菜单
  • 支持显示icon图标、提示语、快捷键等
  • 与业务完全解耦,通过简单配置即可定制出功能各异的菜单

menu

  • 使用通用右键菜单组件演示:
import { ContextMenu, IContextMenuItem } from 'context-menu-common-react';

// 菜单配置数据
const menuList: IContextMenuItem[] = [
  { text: '复制', key: 'copy' },
  { text: '粘贴', key: 'paste', shortcutKeyDesc: `${cmd}+V` },
  {
    text: '对齐',
    key: 'align',
    children: [
      { text: '水平垂直居中', key: 'horizontalVerticalAlign' },
      { text: '水平居中', key: 'horizontalAlign' },
    ],
 },
];

export () => {
  const containerDomRef = React.useRef();
  // 菜单点击触发
  const handleMenuTrigger = (menu: IContextMenuItem) => {
      console.log(menu); // { text: '复制', key: 'copy' }
      // 这里处理触发菜单后的逻辑....

  };
  return (
    <div
      ref={containerDomRef}
      style={{ position: 'relative' }}>
      <ContextMenu
        getContainerDom={() => containerDomRef.current}
        menuList={menuList}
        onTrigger={handleMenuTrigger}
      />
    </div>
  );
};

2. 图形编辑器右键菜单定制

上面的文章介绍了一种通过数据配置生成右键菜单的通用解决方案,它和业务没有任何的耦合,是一个独立功能。

但是仅有上面的功能在面临复杂业务的时候使用体验就不是很好了,例如:

  • 某个特殊的精灵想右键菜单在自己身上触发的时候,显示一个独属于自己的菜单项。
    • 比如富文本精灵提供清除内容富文本格式的功能,把加粗、字体大小等等样式全部清除变为普通无样式文本

这里我们为了提升右键菜单的扩展性易用性,会基于上面的方案做一些抽象和定制,例如:

  1. 菜单配置数据提供注册机制:以便于在不同的模块里维护属于自己模块的菜单项功能;
  2. 每个菜单项都可以独立定义点击触发时的操作:不在一个同一个onTrigger触发器里分发处理每个菜单项的点击逻辑;
  3. 为菜单项触发时处理函数里添加图形编辑器相关的上下文,以方便使用;
import { IContextMenuItem } from "context-menu-common-react";
import ContextMenu from "context-menu-common-react";
import React from "react";
import { ISprite, IStageApis } from "../../demo3-drag/type";
import { GraphicEditorCore } from "../../demo3-drag/graphic-editor";

export * from "context-menu-common-react";

export interface ITriggerParmas {
  stage: GraphicEditorCore;
  activeSpriteList: ISprite[];
  menuItem: IEditorContextMenuItem;
}

export type IEditorContextMenuItem = IContextMenuItem & {
  onTrigger: (params: ITriggerParmas) => void;
};

interface IProps {
  getStage: () => GraphicEditorCore;
}

interface IState {
  menuItemList: IContextMenuItem[];
}

export class EditorContextMenu extends React.Component<IProps, IState> {
  triggerList: any[] = [];

  stage: GraphicEditorCore | null = null;

  menuItemMap: Record<string, IEditorContextMenuItem> = {};

  state: IState = {
    menuItemList: []
  };

  componentDidMount() {
    this.stage = this.props.getStage?.();
  }

  public registerItemList = (_menuItemList: IEditorContextMenuItem[]) => {
    const { menuItemList } = this.state;
    _menuItemList.forEach((e) => {
      this.menuItemMap[e.key] = e;
    });
    this.setState({ menuItemList: [...menuItemList, ..._menuItemList] });
  };

  public registerItem = (menuItem: IEditorContextMenuItem) => {
    const { menuItemList } = this.state;
    this.menuItemMap[menuItem.key] = menuItem;
    this.setState({ menuItemList: [...menuItemList, menuItem] });
    return () => this.remove(menuItem);
  };

  public remove = (menuItem: IEditorContextMenuItem | string) => {
    const { menuItemList } = this.state;
    const list = [...menuItemList];
    const key = typeof menuItem === "string" ? menuItem : menuItem.key;
    const index = list.findIndex((e) => e.key === key);
    delete this.menuItemMap[key];
    if (index !== -1) {
      list.splice(index);
      this.setState({ menuItemList: list });
    }
  };

  public has = (menuItem: IEditorContextMenuItem | string) => {
    const key = typeof menuItem === "string" ? menuItem : menuItem.key;
    return Boolean(this.menuItemMap[key]);
  };

  handleTrigger = (menuItem: IContextMenuItem) => {
    const { stage } = this;
    const { activeSpriteList } = stage?.state || ({} as any);
    const item = this.menuItemMap[menuItem?.key];
    if (typeof item?.onTrigger === "function") {
      item?.onTrigger({
        menuItem,
        stage: this.stage as any,
        activeSpriteList
      });
    }
  };

  render() {
    const { menuItemList } = this.state;
    return (
      <ContextMenu
        getContainerDom={() => document.body}
        menuList={menuItemList}
        onTrigger={this.handleTrigger}
      />
    );
  }
}


3. 一些通用的右键操作方法

3.1 复制

const handleCopy = ({ stage, activeSprite }) => {
  const jsonData = JSON.stringify({ type: 'activeSprite', content: activeSprite });
  return navigator.clipboard.writeText(jsonData);
};
const menuItem: IContextMenuItem = {
  text: '复制',
  key: 'copy',
  // 此菜单项是否禁用
  disabled: ({ activeSprite }) => Boolean(activeSprite),
  onTrigger: handleCopy,
};

stage.apis.contextMenu.registerItem(menuItem);

3.2 粘贴

const handlePaste = async ({ stage }) => {
  const jsonData = await navigator.clipboard.readText();
  const jsonObj = JSON.parse(jsonData);
  if (jsonObj?.type === 'activeSprite') {
    stage.apis.addSpriteToStage(jsonObj.content);
  }
};
const menuItem: IContextMenuItem = {
  text: '粘贴',
  key: 'paste',
  onTrigger: handlePaste,
};

stage.apis.contextMenu.registerItem(menuItem);

3.3 删除

const handleRemove = async ({ stage, activeSprite }) => {
  stage.apis.removeSprite(activeSprite);
};
const menuItem: IContextMenuItem = {
  text: '删除',
  key: 'remove',
  onTrigger: handleRemove,
};

stage.apis.contextMenu.registerItem(menuItem);

3.4 剪切

const handleCut = async ({ stage, activeSprite }) => {
  const jsonData = JSON.stringify({ type: 'activeSprite', content: activeSprite });
  // 先复制, 再删除
  const res = await navigator.clipboard.writeText(jsonData);
  stage.apis.removeSprite(activeSprite);
  return res;
};
const menuItem: IContextMenuItem = {
  text: '剪切',
  key: 'cut',
  onTrigger: handleCut,
};

stage.apis.contextMenu.registerItem(menuItem);

3.5 撤销、重做

const menuItem: IContextMenuItem = {
  text: '撤销',
  key: 'redo',
  onTrigger: ({ stage }) => stage.apis.redo(),
};

stage.apis.contextMenu.registerItem(menuItem);
const menuItem: IContextMenuItem = {
  text: '重做',
  key: 'undo',
  onTrigger: ({ stage }) => stage.apis.undo(),
};
stage.apis.contextMenu.registerItem(menuItem);

4. 精灵注册属于自己的右键菜单快捷操作

// 文本精灵组件
export class RichTextSprite extends BaseSprite<IProps> {

  componentDidMount() {
    const { stage } = this.props;
    const { contextMenu } = stage.apis;
    if (!contextMenu.has('clearRichTextFormat')) {
      const menuItem: IContextMenuItem = {
        text: '清除富文本格式',
        key: 'clearRichTextFormat',
        // 显示此菜单项的条件
        condition: ({ sprite }) => sprite.type === 'RichTextSprite',
        onTrigger: this.handleClearTextFormat,
      };
      stage.apis.contextMenu.registerItem(menuItem);
    }
  }

  componentWillUnmount() {
    if (contextMenu.has('clearRichTextFormat')) {
      stage.apis.contextMenu.remove('clearRichTextFormat');
    }
  }

  handleClearTextFormat = () => {
    const { stage, sprite } = this.props;
    const { content } = sprite.props;

    const text = clearTextFormat(content);
    const newProps = { ...sprite.props, content: text };
    stage.apis.updateSpriteProps(sprite.id, newProps);
  }

  render() {
    const { sprite } = this.props;
    const { props, attrs } = sprite;
    const { content } = props;
    return (
      <foreignObject
        <span {...props}>{content}</span>
      </foreignObject>
    );
  }
}

二、快捷键

1. 图形编辑器快捷键定制

/**
 * 快捷键配置
 */
export const shortcutOpts: IShortcutOpt[] = [
  {
    name: ShortcutNameEnum.copy,
    title: '复制',
    keys: ['c'],
    containerSelectors: ['.div-1'],
    option: { metaPress: true },
    // 触发当前快捷键时执行
    onTrigger: ({ opt, event }) => {
      // 这里处理触发后的逻辑
    },
  },
  {
    name: ShortcutNameEnum.undo,
    title: '重做',
    keys: ['z'],
    option: { metaPress: true, shiftPress: true },
    // 触发当前快捷键时执行
    onTrigger: ({ opt, event }) => {
      // 这里处理触发后的逻辑
    },
  },
];

export default () => {

  useEffect(() => {
    // 实例化
    const keyboardOpt = new KeyBoardOperate({
      preventDefault: true,
      onTrigger: (opt: IShortcutOpt, e) => {
        console.info('bingo', opt, e);
        // 所有快捷键触发后都会执行
      },
    });
    shortcutOpts.forEach(e => keyboardOpt.registerShortcutKey(e));
    return () => {
      keyboardOpt.removeAllEventListener();
    };
  }, []);

  return null
};

2. 精灵注册属于自己的快捷键操作

// 文本精灵组件
export class RichTextSprite extends BaseSprite<IProps> {
  componentDidMount() {
    const { stage } = this.props;
    const { shortcutKey } = stage.apis;
    if (!shortcutKey.has('clearRichTextFormat')) {
      const opt: IShortcutOpt = {
        title: '清除富文本格式',
        name: 'clearRichTextFormat',
        keys: ['c', 'l'],
        option: { metaPress: true },
        onTrigger: this.handleClearTextFormat,
      };
      stage.apis.shortcutKey.registerItem(menuItem);
    }
  }
  componentWillUnmount() {
    if (stage.apis.shortcutKey.has('clearRichTextFormat')) {
      stage.apis.shortcutKey.remove('clearRichTextFormat');
    }
  }
  render() {
    ...
  }
}

3. 快捷键底层方案

这里的实现思路和右键菜单的注册思路类似,为了快捷键的稳定性和兼容性我们借助hotkeys-js这个包来实现快捷键的监听。

export interface IShortcutOpt {
  // 快捷键的名字,不能重复,否则会报错
  name: string;
  // 按键数组
  keys: string[];
  // 容器选择器,选择快捷键生效的触发区域,支持class选择器和id选择器,例如:['.title', '#root']
  containerSelectors?: string[];
  // 名称
  title?: string;
  // 配置
  option?: IShortcutOption;
  // 触发回调
  onTrigger?: (params: { opt: IShortcutOpt; event: KeyboardEvent }) => void;
}

上面就是一个快捷键的配置,我们的设计如下:

  • 使用option表示是否需要meta、shift等键按下
  • 使用keys表示监听的键,例如复制['c']
  • onTrigger表示快捷键被触发了时执行的回调
  • 同样支持 registerShortcutKey方法来注册上面的单个快捷键

以下是快捷键的源码:

import hotkeys from 'hotkeys-js';
import { getHotkeysStr, selectParents } from './helper';
import { IShortcutOpt, ITriggerCallback } from './types';

export class KeyBoardOperate {
  // 快捷键映射
  shortcutKeyMap: Record<string, IShortcutOpt[]> = {};

  onTrigger: ITriggerCallback;

  preventDefault: boolean = true;

  clickEle: any;

  constructor({
    shortcutOpts = [],
    preventDefault = true,
    onTrigger = () => '',
  }: {
    shortcutOpts: IShortcutOpt[];
    preventDefault?: boolean;
    onTrigger?: ITriggerCallback;
  }) {
    this.preventDefault = preventDefault;
    this.onTrigger = (opt: IShortcutOpt, e: KeyboardEvent) => {
      opt.onTrigger?.({ opt, event: e });
      onTrigger?.(opt, e);
    };
    shortcutOpts.forEach(opt => this.registerShortcutKey(opt));
    document.addEventListener('click', (e: MouseEvent) => {
      this.clickEle = e.target;
    });
    console.log('yf123', this);
  }

  /**
   * 注册快捷键
   *
   * @param shortcutOpt - 快捷键操作
   * @param shortcutOpt.name - 快捷键操作名字,同时作为映射的key,要保证唯一性
   * @param shortcutOpt.keys - 按键数组
   * @param shortcutOpt.option - 配置
   */
  public registerShortcutKey(shortcutOpt: IShortcutOpt) {
    const { name, keys } = shortcutOpt;
    if (!Array.isArray(keys)) {
      throw new Error(`注册快捷键时, keys 参数是必要的!`);
    }
    // 避免重复
    if (this.shortcutKeyMap[name]) {
      throw new Error(`快捷键操作「${name}」已存在,请更换`);
    }
    this.addEventListener(shortcutOpt);
  }

  public removeAllEventListener() {
    hotkeys.unbind();
  }

  private addEventListener(shortcutOpt: IShortcutOpt) {
    const keyStr = getHotkeysStr(shortcutOpt);
    hotkeys(keyStr, (e: KeyboardEvent) => this.handleKeyTrigger(e, shortcutOpt));
  }

  private removeEventListener(shortcutOpt: IShortcutOpt) {
    const keyStr = getHotkeysStr(shortcutOpt);
    hotkeys.unbind(keyStr);
  }

  private handleKeyTrigger = (event: KeyboardEvent, shortcutOpt: IShortcutOpt) => {
    if (this.preventDefault) {
      event.preventDefault();
    }
    // 如果配置了生效区域,但是触发快捷键的节点不在容器里,就认为是无效操作
    const { containerSelectors = [] } = shortcutOpt;
    if (containerSelectors.length > 0) {
      const parents = selectParents(this.clickEle, containerSelectors);
      if (parents.length === 0) {
        return;
      }
    }
    // 成功命中快捷键
    this.onTrigger(shortcutOpt, event);
  };
}

  • 工具函数
import { IShortcutOpt } from './types';

// 利用原生Js获取操作系统版本
export function getOS() {
  const isWin =
    navigator.platform === 'Win32' || navigator.platform === 'Windows';
  const isMac =
    navigator.platform === 'Mac68K' ||
    navigator.platform === 'MacPPC' ||
    navigator.platform === 'Macintosh' ||
    navigator.platform === 'MacIntel';
  if (isMac) {
    return 'Mac';
  }
  const isLinux = String(navigator.platform).includes('Linux');
  if (isLinux) {
    return 'Linux';
  }
  if (isWin) {
    return 'Win';
  }
  return 'other';
}

export const isMac = getOS() === 'Mac';

export const getMetaStr = () => (isMac ? 'command' : 'ctrl');

export const getHotkeysStr = (opt: IShortcutOpt) => {
  const { metaPress, shiftPress, altPress } = opt.option || {};
  let key = '';
  if (metaPress) {
    key += `${getMetaStr()}+`;
  }
  if (shiftPress) {
    key += 'shift+';
  }
  if (altPress) {
    key += 'alt+';
  }
  key += `${opt.keys.join('+')}`;
  return key;
};

export const findDomParents = (dom: any) => {
  const arr: any = [];
  const findParent = (e: any) => {
    if (e?.parentNode) {
      arr.push(e);
      findParent(e.parentNode);
    }
  };
  findParent(dom);
  return arr;
};

export const selectParents = (dom: any, selectors: string[]) => {
  const results: any[] = [];
  const parents = findDomParents(dom);
  selectors.forEach((selector: string) => {
    for (const node of parents) {
      const selectorName = selector.slice(1);
      if (selector.startsWith('#')) {
        if (
          node.getAttribute('id') === selectorName &&
          !results.find(e => e === node)
        ) {
          results.push(node);
        }
      } else if (selector.startsWith('.')) {
        if (
          node.classList.contains(selectorName) &&
          !results.find(e => e === node)
        ) {
          results.push(node);
        }
      }
    }
  });
  return results;
};
  • types
export interface IShortcutOption {
  metaPress?: boolean;
  shiftPress?: boolean;
  altPress?: boolean;
}

export type ITriggerCallback = (opt: IShortcutOpt, e: KeyboardEvent) => void;

export interface IShortcutOpt {
  // 快捷键的名字,不能重复,否则会报错
  name: string;
  // 按键数组
  keys: string[];
  // 容器选择器,选择快捷键生效的触发区域,支持class选择器和id选择器,例如:['.title', '#root']
  containerSelectors?: string[];
  // 名称
  title?: string;
  // 配置
  option?: IShortcutOption;
  // 触发回调
  onTrigger?: (params: { opt: IShortcutOpt; event: KeyboardEvent }) => void;
}

三、撤销回退

history.gif

1. 撤销回退底层方案

关于历史记录的通用底层解决方案,我之前已经写了一篇文章专门介绍,有兴趣的同学欢迎参考:

这个方案比较简单,是存储全量数据的,如果需要使用仅存储增量数据,欢迎在评论区分享方案讨论~

2. 图形编辑器中使用撤销回退

我们需要在图形编辑器里操作精灵列表spriteList数据的核心api里加上历史记录相关的操作。


export class GraphicEditorCore extends React.Component<IProps, IState> {
  private readonly registerSpriteMetaMap: Record<string, ISpriteMeta> = {};

  // 历史记录 - 添加
  public pushHistory = (spriteList: ISprite[]) => {
    history: string[] = [];

    const { history } = this;
    history.push(
      JSON.stringify({ ...this.getMetaData(), children: spriteList }),
    );
  };

  // 历史记录 - 撤销
  public undo = () => {
    const { history } = this;
    if (history.getLength() > 1) {
      history.undo();
      history.currentValue &&
        this.setSpriteList(JSON.parse(history.currentValue).children, false);
    }
  };

  // 历史记录 - 重做
  public redo = () => {
    const { history } = this;
    history.redo();
    history.currentValue &&
      this.setSpriteList(JSON.parse(history.currentValue).children, false);
  };

  public addSpriteToStage = (sprite: ISprite | ISprite[]) => {
    const { spriteList } = this.state;
    const newSpriteList = [...spriteList];
    if (Array.isArray(sprite)) {
      newSpriteList.push(...sprite);
    } else {
      newSpriteList.push(sprite);
    }
    this.setState({ spriteList: newSpriteList });
    // 在操作精灵列表数据的方法里都加上历史记录的操作即可
    this.pushHistory(newSpriteList);
  };

  setSpriteList = (newSpriteList: ISprite[]) => {
    this.setState({ spriteList: newSpriteList });
  };

四、总结

本文介绍了编辑器常用的三种提效功能:右键菜单、快捷键、历史记录,可以使我们编辑操作的效率得到大大的提升,优化体验,并且每个功能都做了分层抽象,可以形成解决方案,在别的业务中复用。

加下来我们会继续介绍提升编辑效率的功能:多选组合,以方便批量操作精灵,提升效率。

系列文章汇总

  1. svg实现图形编辑器系列一:精灵系统
  2. svg实现图形编辑器系列二:精灵的开发和注册
  3. svg实现图形编辑器系列三:移动、缩放、旋转
  4. svg实现图形编辑器系列四:吸附&辅助线
  5. svg实现图形编辑器系列五:辅助编辑锚点
  6. svg实现图形编辑器系列六:链接线、连接桩
  7. svg实现图形编辑器系列七:右键菜单、快捷键、撤销回退
  8. svg实现图形编辑器系列八:多选、组合、解组
  9. svg实现图形编辑器系列九:精灵的编辑态&开发常用精灵
  10. svg实现图形编辑器系列十:工具栏&配置面板(最终篇)