svg实现图形编辑器系列七:右键菜单、快捷键、撤销回退 您所在的位置:网站首页 linux编辑回退 svg实现图形编辑器系列七:右键菜单、快捷键、撤销回退

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

#svg实现图形编辑器系列七:右键菜单、快捷键、撤销回退| 来源: 网络整理| 查看: 265

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

一、右键菜单 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 ( containerDomRef.current} menuList={menuList} onTrigger={handleMenuTrigger} /> ); }; 复制代码 2. 图形编辑器右键菜单定制

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

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

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

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

菜单配置数据提供注册机制:以便于在不同的模块里维护属于自己模块的菜单项功能; 每个菜单项都可以独立定义点击触发时的操作:不在一个同一个onTrigger触发器里分发处理每个菜单项的点击逻辑; 为菜单项触发时处理函数里添加图形编辑器相关的上下文,以方便使用; 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 { triggerList: any[] = []; stage: GraphicEditorCore | null = null; menuItemMap: Record = {}; 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 ( 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 { 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 ( { // 这里处理触发后的逻辑 }, }, { 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 { 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 = {}; 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. 撤销回退底层方案

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

如何实现历史记录功能 - 掘金

历史记录演示demo

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

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

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

export class GraphicEditorCore extends React.Component { private readonly registerSpriteMetaMap: Record = {}; // 历史记录 - 添加 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 }); }; 复制代码 四、总结

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

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



【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有