注册
web

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


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



一、右键菜单


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 });
};


四、总结


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


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


作者:前端君
来源:juejin.cn/post/7213757571960799291

0 个评论

要回复文章请先登录注册