From 80f671697d19f4b95f4acae52fc88520681975a4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E4=BA=91=E9=A3=9E?= Date: Sat, 18 Dec 2021 20:00:18 +0800 Subject: [PATCH] feat:add context menu --- src/editor/assets/css/index.css | 76 ++++++ src/editor/assets/images/print.svg | 1 + src/editor/assets/images/submenu-dropdown.svg | 1 + src/editor/core/command/Command.ts | 25 ++ src/editor/core/command/CommandAdapt.ts | 26 ++- src/editor/core/contextmenu/ContextMenu.ts | 221 ++++++++++++++++++ .../core/contextmenu/menus/globalMenus.ts | 56 +++++ src/editor/core/cursor/CursorAgent.ts | 6 +- src/editor/core/draw/Draw.ts | 13 +- src/editor/core/event/CanvasEvent.ts | 56 +++-- src/editor/core/register/Register.ts | 17 ++ src/editor/dataset/enum/Editor.ts | 3 +- src/editor/dataset/enum/Event.ts | 5 + src/editor/index.ts | 15 +- .../interface/contextmenu/ContextMenu.ts | 15 ++ 15 files changed, 506 insertions(+), 30 deletions(-) create mode 100644 src/editor/assets/images/print.svg create mode 100644 src/editor/assets/images/submenu-dropdown.svg create mode 100644 src/editor/core/contextmenu/ContextMenu.ts create mode 100644 src/editor/core/contextmenu/menus/globalMenus.ts create mode 100644 src/editor/core/register/Register.ts create mode 100644 src/editor/dataset/enum/Event.ts create mode 100644 src/editor/interface/contextmenu/ContextMenu.ts diff --git a/src/editor/assets/css/index.css b/src/editor/assets/css/index.css index 09b436e..8384f8d 100644 --- a/src/editor/assets/css/index.css +++ b/src/editor/assets/css/index.css @@ -193,4 +193,80 @@ z-index: 9; position: absolute; border: 1px dotted #000000; +} + +.contextmenu-container { + z-index: 9; + position: fixed; + display: none; + padding: 4px; + overflow-x: hidden; + overflow-y: auto; + background: #fff; + box-shadow: 0 2px 12px 0 rgb(56 56 56 / 20%); + border: 1px solid #e2e6ed; + border-radius: 2px; +} + +.contextmenu-content { + display: flex; + flex-direction: column; +} + +.contextmenu-content .contextmenu-sub-item::after { + position: absolute; + content: ""; + width: 16px; + height: 16px; + right: 12px; + background: url(../images/submenu-dropdown.svg); +} + +.contextmenu-content .contextmenu-item { + width: 180px; + padding: 0 32px 0 16px; + height: 30px; + display: flex; + align-items: center; + white-space: nowrap; + box-sizing: border-box; + cursor: pointer; +} + +.contextmenu-content .contextmenu-item.hover { + background: rgba(25, 55, 88, .04); +} + +.contextmenu-content .contextmenu-item span { + font-size: 12px; + color: #3d4757; +} + +.contextmenu-content .contextmenu-item span.shortcut { + color: #767c85; + height: 30px; + flex: 1; + text-align: right; + line-height: 30px; +} + +.contextmenu-content .contextmenu-item i { + width: 16px; + height: 16px; + vertical-align: middle; + display: inline-block; + background-repeat: no-repeat; + background-size: 100% 100%; + flex-shrink: 0; + margin-right: 8px; +} + +.contextmenu-divider { + background-color: #e2e6ed; + margin: 4px 16px; + height: 1px; +} + +.contextmenu-print { + background-image: url(../../assets/images/print.svg); } \ No newline at end of file diff --git a/src/editor/assets/images/print.svg b/src/editor/assets/images/print.svg new file mode 100644 index 0000000..5ee44a0 --- /dev/null +++ b/src/editor/assets/images/print.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/editor/assets/images/submenu-dropdown.svg b/src/editor/assets/images/submenu-dropdown.svg new file mode 100644 index 0000000..cbfb42e --- /dev/null +++ b/src/editor/assets/images/submenu-dropdown.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/editor/core/command/Command.ts b/src/editor/core/command/Command.ts index 0f2355b..faded6f 100644 --- a/src/editor/core/command/Command.ts +++ b/src/editor/core/command/Command.ts @@ -4,6 +4,10 @@ import { CommandAdapt } from "./CommandAdapt" export class Command { + private static cut: Function + private static copy: Function + private static paste: Function + private static selectAll: Function private static undo: Function private static redo: Function private static painter: Function @@ -30,6 +34,10 @@ export class Command { private static pageScaleAdd: Function constructor(adapt: CommandAdapt) { + Command.cut = adapt.cut.bind(adapt) + Command.copy = adapt.copy.bind(adapt) + Command.paste = adapt.paste.bind(adapt) + Command.selectAll = adapt.selectAll.bind(adapt) Command.undo = adapt.undo.bind(adapt) Command.redo = adapt.redo.bind(adapt) Command.painter = adapt.painter.bind(adapt) @@ -56,6 +64,23 @@ export class Command { Command.pageScaleAdd = adapt.pageScaleAdd.bind(adapt) } + // 全局命令 + public executeCut() { + return Command.cut() + } + + public executeCopy() { + return Command.copy() + } + + public executePaste() { + return Command.paste() + } + + public executeSelectAll() { + return Command.selectAll() + } + // 撤销、重做、格式刷、清除格式 public executeUndo() { return Command.undo() diff --git a/src/editor/core/command/CommandAdapt.ts b/src/editor/core/command/CommandAdapt.ts index eeee2a1..1199027 100644 --- a/src/editor/core/command/CommandAdapt.ts +++ b/src/editor/core/command/CommandAdapt.ts @@ -15,6 +15,7 @@ import { getUUID } from "../../utils" import { formatElementList } from "../../utils/element" import { printImageBase64 } from "../../utils/print" import { Draw } from "../draw/Draw" +import { CanvasEvent } from "../event/CanvasEvent" import { HistoryManager } from "../history/HistoryManager" import { Position } from "../position/Position" import { RangeManager } from "../range/RangeManager" @@ -25,6 +26,7 @@ export class CommandAdapt { private range: RangeManager private position: Position private historyManager: HistoryManager + private canvasEvent: CanvasEvent private options: Required constructor(draw: Draw) { @@ -32,15 +34,35 @@ export class CommandAdapt { this.range = draw.getRange() this.position = draw.getPosition() this.historyManager = draw.getHistoryManager() + this.canvasEvent = draw.getCanvasEvent() this.options = draw.getOptions() } + public cut() { + this.canvasEvent.cut() + } + + public copy() { + this.canvasEvent.copy() + } + + public async paste() { + const text = await navigator.clipboard.readText() + if (text) { + this.canvasEvent.input(text) + } + } + + public selectAll() { + this.canvasEvent.selectAll() + } + public undo() { - return this.historyManager.undo() + this.historyManager.undo() } public redo() { - return this.historyManager.redo() + this.historyManager.redo() } public painter() { diff --git a/src/editor/core/contextmenu/ContextMenu.ts b/src/editor/core/contextmenu/ContextMenu.ts new file mode 100644 index 0000000..d1a544a --- /dev/null +++ b/src/editor/core/contextmenu/ContextMenu.ts @@ -0,0 +1,221 @@ +import { EDITOR_COMPONENT } from "../../dataset/constant/Editor" +import { EditorComponent } from "../../dataset/enum/Editor" +import { IContextMenuContext, IRegisterContextMenu } from "../../interface/contextmenu/ContextMenu" +import { findParent } from "../../utils" +import { Command } from "../command/Command" +import { Draw } from "../draw/Draw" +import { Position } from "../position/Position" +import { RangeManager } from "../range/RangeManager" + +interface IRenderPayload { + contextMenuList: IRegisterContextMenu[]; + left: number; + top: number; + parentMenuConatiner?: HTMLDivElement; +} + +export class ContextMenu { + + private command: Command + private range: RangeManager + private position: Position + private container: HTMLDivElement + private contextMenuList: IRegisterContextMenu[] + private contextMenuContainerList: HTMLDivElement[] + private contextMenuRelationShip: Map + + constructor(draw: Draw, command: Command) { + this.command = command + this.range = draw.getRange() + this.position = draw.getPosition() + this.container = draw.getContainer() + this.contextMenuList = [] + this.contextMenuContainerList = [] + this.contextMenuRelationShip = new Map() + // 接管菜单权限 + document.addEventListener('contextmenu', this._proxyContextMenuEvent.bind(this)) + // 副作用处理 + document.addEventListener('mousedown', this._handleEffect.bind(this)) + } + + private _proxyContextMenuEvent(evt: MouseEvent) { + const context = this._getContext() + let renderList: IRegisterContextMenu[] = [] + let isRegisterContextMenu = false + for (let c = 0; c < this.contextMenuList.length; c++) { + const menu = this.contextMenuList[c] + if (menu.isDivider) { + renderList.push(menu) + } else { + const isMatch = menu.when?.(context) + if (isMatch) { + renderList.push(menu) + isRegisterContextMenu = true + } + } + } + if (isRegisterContextMenu) { + this.dispose() + this._render({ + contextMenuList: renderList, + left: evt.x, + top: evt.y, + }) + } + evt.preventDefault() + } + + private _handleEffect(evt: MouseEvent) { + if (this.contextMenuContainerList.length) { + // 点击非右键菜单内 + const contextMenuDom = findParent( + evt.target as Element, + (node: Node & Element) => !!node && node.nodeType === 1 + && node.getAttribute(EDITOR_COMPONENT) === EditorComponent.CONTEXTMENU, + true + ) + if (!contextMenuDom) { + this.dispose() + } + } + } + + private _getContext(): IContextMenuContext { + const { startIndex, endIndex } = this.range.getRange() + // 是否存在焦点 + const editorTextFocus = startIndex !== 0 || endIndex !== 0 + // 是否存在选区 + const editorHasSelection = editorTextFocus && startIndex !== endIndex + // 是否在表格内 + const positionContext = this.position.getPositionContext() + const isInTable = positionContext.isTable + return { editorHasSelection, editorTextFocus, isInTable } + } + + private _createContextMenuContainer(): HTMLDivElement { + const contextMenuContainer = document.createElement('div') + contextMenuContainer.classList.add('contextmenu-container') + contextMenuContainer.setAttribute(EDITOR_COMPONENT, EditorComponent.CONTEXTMENU) + this.container.append(contextMenuContainer) + return contextMenuContainer + } + + private _render(payload: IRenderPayload): HTMLDivElement { + const { contextMenuList, left, top, parentMenuConatiner } = payload + const contextMenuContainer = this._createContextMenuContainer() + const contextMenuContent = document.createElement('div') + contextMenuContent.classList.add('contextmenu-content') + // 直接子菜单 + let childMenuContainer: HTMLDivElement | null = null + // 父菜单添加子菜单映射关系 + if (parentMenuConatiner) { + this.contextMenuRelationShip.set(parentMenuConatiner, contextMenuContainer) + } + for (let c = 0; c < contextMenuList.length; c++) { + const menu = contextMenuList[c] + if (menu.isDivider) { + // 首尾分隔符不渲染 + if (c !== 0 && c !== contextMenuList.length - 1) { + const divider = document.createElement('div') + divider.classList.add('contextmenu-divider') + contextMenuContent.append(divider) + } + } else { + const menuItem = document.createElement('div') + menuItem.classList.add('contextmenu-item') + // 菜单事件 + if (menu.childMenus) { + menuItem.classList.add('contextmenu-sub-item') + menuItem.onmouseenter = () => { + this._setHoverStatus(menuItem, true) + this._removeSubMenu(contextMenuContainer) + // 子菜单 + const subMenuRect = menuItem.getBoundingClientRect() + const left = subMenuRect.left + subMenuRect.width + const top = subMenuRect.top + childMenuContainer = this._render({ + contextMenuList: menu.childMenus!, + left, + top, + parentMenuConatiner: contextMenuContainer + }) + } + menuItem.onmouseleave = (evt) => { + // 移动到子菜单选项选中状态不变化 + if (!childMenuContainer || !childMenuContainer.contains(evt.relatedTarget as Node)) { + this._setHoverStatus(menuItem, false) + } + } + } else { + menuItem.onmouseenter = () => { + this._setHoverStatus(menuItem, true) + this._removeSubMenu(contextMenuContainer) + } + menuItem.onmouseleave = () => { + this._setHoverStatus(menuItem, false) + } + menuItem.onclick = () => { + if (menu.callback) { + menu.callback(this.command) + } + this.dispose() + } + } + // 图标 + const icon = document.createElement('i') + menuItem.append(icon) + if (menu.icon) { + icon.classList.add(`contextmenu-${menu.icon}`) + } + // 文本 + const span = document.createElement('span') + span.append(document.createTextNode(menu.name!)) + menuItem.append(span) + // 快捷方式提示 + if (menu.shortCut) { + const span = document.createElement('span') + span.classList.add('shortcut') + span.append(document.createTextNode(menu.shortCut)) + menuItem.append(span) + } + contextMenuContent.append(menuItem) + } + } + contextMenuContainer.append(contextMenuContent) + contextMenuContainer.style.display = 'block' + contextMenuContainer.style.left = `${left}px` + contextMenuContainer.style.top = `${top}px` + this.contextMenuContainerList.push(contextMenuContainer) + return contextMenuContainer + } + + private _removeSubMenu(payload: HTMLDivElement) { + const childMenu = this.contextMenuRelationShip.get(payload) + if (childMenu) { + this._removeSubMenu(childMenu) + childMenu.remove() + this.contextMenuRelationShip.delete(payload) + } + } + + private _setHoverStatus(payload: HTMLDivElement, status: boolean) { + if (status) { + payload.parentNode?.querySelectorAll('.contextmenu-item') + .forEach(child => child.classList.remove('hover')) + payload.classList.add('hover') + } else { + payload.classList.remove('hover') + } + } + + public registerContextMenuList(payload: IRegisterContextMenu[]) { + this.contextMenuList.push(...payload) + } + + public dispose() { + this.contextMenuContainerList.forEach(child => child.remove()) + this.contextMenuContainerList = [] + this.contextMenuRelationShip.clear() + } + +} diff --git a/src/editor/core/contextmenu/menus/globalMenus.ts b/src/editor/core/contextmenu/menus/globalMenus.ts new file mode 100644 index 0000000..72e3f89 --- /dev/null +++ b/src/editor/core/contextmenu/menus/globalMenus.ts @@ -0,0 +1,56 @@ +import { IRegisterContextMenu } from "../../../interface/contextmenu/ContextMenu" +import { Command } from "../../command/Command" + +export const globalMenus: IRegisterContextMenu[] = [ + { + name: '剪切', + shortCut: 'Ctrl + X', + when: (payload) => { + return payload.editorHasSelection + }, + callback: (command: Command) => { + command.executeCut() + } + }, + { + name: '复制', + shortCut: 'Ctrl + C', + when: (payload) => { + return payload.editorHasSelection + }, + callback: (command: Command) => { + command.executeCopy() + } + }, + { + name: '粘贴', + shortCut: 'Ctrl + V', + when: (payload) => { + return payload.editorTextFocus + }, + callback: (command: Command) => { + command.executePaste() + } + }, + { + name: '全选', + shortCut: 'Ctrl + A', + when: (payload) => { + return payload.editorTextFocus + }, + callback: (command: Command) => { + command.executeSelectAll() + } + }, + { + isDivider: true + }, + { + icon: 'print', + name: '打印', + when: () => true, + callback: (command: Command) => { + command.executePrint() + } + } +] \ No newline at end of file diff --git a/src/editor/core/cursor/CursorAgent.ts b/src/editor/core/cursor/CursorAgent.ts index 4ac669e..dd495aa 100644 --- a/src/editor/core/cursor/CursorAgent.ts +++ b/src/editor/core/cursor/CursorAgent.ts @@ -40,7 +40,11 @@ export class CursorAgent { } private _paste(evt: ClipboardEvent) { - this.canvasEvent.paste(evt) + const text = evt.clipboardData?.getData('text') + if (text) { + this.canvasEvent.input(text) + } + evt.preventDefault() } private _compositionstart() { diff --git a/src/editor/core/draw/Draw.ts b/src/editor/core/draw/Draw.ts index dc90e8b..68e2f78 100644 --- a/src/editor/core/draw/Draw.ts +++ b/src/editor/core/draw/Draw.ts @@ -39,6 +39,7 @@ export class Draw { private elementList: IElement[] private listener: Listener + private canvasEvent: CanvasEvent private cursor: Cursor private range: RangeManager private margin: Margin @@ -93,10 +94,10 @@ export class Draw { this.pageNumber = new PageNumber(this) new GlobalObserver(this) - const canvasEvent = new CanvasEvent(this) - this.cursor = new Cursor(this, canvasEvent) - canvasEvent.register() - const globalEvent = new GlobalEvent(this, canvasEvent) + this.canvasEvent = new CanvasEvent(this) + this.cursor = new Cursor(this, this.canvasEvent) + this.canvasEvent.register() + const globalEvent = new GlobalEvent(this, this.canvasEvent) globalEvent.register() this.rowList = [] @@ -225,6 +226,10 @@ export class Draw { return this.elementList } + public getCanvasEvent(): CanvasEvent { + return this.canvasEvent + } + public getListener(): Listener { return this.listener } diff --git a/src/editor/core/event/CanvasEvent.ts b/src/editor/core/event/CanvasEvent.ts index 876cdcc..ade7d8d 100644 --- a/src/editor/core/event/CanvasEvent.ts +++ b/src/editor/core/event/CanvasEvent.ts @@ -1,5 +1,6 @@ import { ZERO } from "../../dataset/constant/Common" import { ElementStyleKey } from "../../dataset/enum/ElementStyle" +import { MouseEventButton } from "../../dataset/enum/Event" import { KeyMap } from "../../dataset/enum/Keymap" import { IElement } from "../../interface/Element" import { writeTextByElementList } from "../../utils/clipboard" @@ -106,6 +107,7 @@ export class CanvasEvent { } public mousedown(evt: MouseEvent) { + if (evt.button === MouseEventButton.RIGHT) return const target = evt.target as HTMLDivElement const pageIndex = target.dataset.index // 设置pageNo @@ -301,24 +303,11 @@ export class CanvasEvent { this.historyManager.redo() evt.preventDefault() } else if (evt.ctrlKey && evt.key === KeyMap.C) { - if (!isCollspace) { - writeTextByElementList(elementList.slice(startIndex + 1, endIndex + 1)) - } + this.copy() } else if (evt.ctrlKey && evt.key === KeyMap.X) { - if (!isCollspace) { - writeTextByElementList(elementList.slice(startIndex + 1, endIndex + 1)) - elementList.splice(startIndex + 1, endIndex - startIndex) - const curIndex = startIndex - this.range.setRange(curIndex, curIndex) - this.draw.render({ curIndex }) - } + this.cut() } else if (evt.ctrlKey && evt.key === KeyMap.A) { - this.range.setRange(0, position.length - 1) - this.draw.render({ - isSubmitHistory: false, - isSetCursor: false, - isComputeRowList: false - }) + this.selectAll() } } @@ -326,6 +315,7 @@ export class CanvasEvent { if (!this.cursor) return const cursorPosition = this.position.getCursorPosition() if (!data || !cursorPosition || this.isCompositing) return + const text = data.replaceAll(`\n`, ZERO) const elementList = this.draw.getElementList() const agentDom = this.cursor.getAgentDom() agentDom.value = '' @@ -339,7 +329,7 @@ export class CanvasEvent { const { tdId, trId, tableId } = positionContext restArg = { tdId, trId, tableId } } - const inputData: IElement[] = data.split('').map(value => ({ + const inputData: IElement[] = text.split('').map(value => ({ value, ...restArg })) @@ -359,10 +349,34 @@ export class CanvasEvent { this.draw.render({ curIndex }) } - public paste(evt: ClipboardEvent) { - const text = evt.clipboardData?.getData('text') - this.input(text?.replaceAll(`\n`, ZERO) || '') - evt.preventDefault() + public cut() { + const { startIndex, endIndex } = this.range.getRange() + const elementList = this.draw.getElementList() + if (startIndex !== endIndex) { + writeTextByElementList(elementList.slice(startIndex + 1, endIndex + 1)) + elementList.splice(startIndex + 1, endIndex - startIndex) + const curIndex = startIndex + this.range.setRange(curIndex, curIndex) + this.draw.render({ curIndex }) + } + } + + public copy() { + const { startIndex, endIndex } = this.range.getRange() + const elementList = this.draw.getElementList() + if (startIndex !== endIndex) { + writeTextByElementList(elementList.slice(startIndex + 1, endIndex + 1)) + } + } + + public selectAll() { + const position = this.position.getPositionList() + this.range.setRange(0, position.length - 1) + this.draw.render({ + isSubmitHistory: false, + isSetCursor: false, + isComputeRowList: false + }) } public compositionstart() { diff --git a/src/editor/core/register/Register.ts b/src/editor/core/register/Register.ts new file mode 100644 index 0000000..3a5e4b7 --- /dev/null +++ b/src/editor/core/register/Register.ts @@ -0,0 +1,17 @@ +import { IRegisterContextMenu } from "../../interface/contextmenu/ContextMenu" +import { ContextMenu } from "../contextmenu/ContextMenu" + +interface IRegisterPayload { + contextMenu: ContextMenu +} + +export class Register { + + public contextMenuList: (payload: IRegisterContextMenu[]) => void + + constructor(payload: IRegisterPayload) { + const { contextMenu } = payload + this.contextMenuList = contextMenu.registerContextMenuList.bind(contextMenu) + } + +} \ No newline at end of file diff --git a/src/editor/dataset/enum/Editor.ts b/src/editor/dataset/enum/Editor.ts index 9ec6035..e677f8d 100644 --- a/src/editor/dataset/enum/Editor.ts +++ b/src/editor/dataset/enum/Editor.ts @@ -1,7 +1,8 @@ export enum EditorComponent { MENU = 'menu', MAIN = 'main', - FOOTER = 'footer' + FOOTER = 'footer', + CONTEXTMENU = 'contextmenu' } export enum EditorContext { diff --git a/src/editor/dataset/enum/Event.ts b/src/editor/dataset/enum/Event.ts new file mode 100644 index 0000000..823f6a0 --- /dev/null +++ b/src/editor/dataset/enum/Event.ts @@ -0,0 +1,5 @@ +export enum MouseEventButton { + LEFT = 0, + CENTER = 1, + RIGHT = 2 +} \ No newline at end of file diff --git a/src/editor/index.ts b/src/editor/index.ts index 6599a75..51705a6 100644 --- a/src/editor/index.ts +++ b/src/editor/index.ts @@ -8,11 +8,16 @@ import { Listener } from './core/listener/Listener' import { RowFlex } from './dataset/enum/Row' import { ElementType } from './dataset/enum/Element' import { formatElementList } from './utils/element' +import { Register } from './core/register/Register' +import { globalMenus } from './core/contextmenu/menus/GlobalMenus' +import { ContextMenu } from './core/contextmenu/ContextMenu' +import { IRegisterContextMenu } from './interface/contextmenu/ContextMenu' export default class Editor { public command: Command public listener: Listener + public register: Register constructor(container: HTMLDivElement, elementList: IElement[], options: IEditorOption = {}) { const editorOptions: Required = { @@ -52,6 +57,13 @@ export default class Editor { const draw = new Draw(container, editorOptions, elementList, this.listener) // 命令 this.command = new Command(new CommandAdapt(draw)) + // 菜单 + const contextMenu = new ContextMenu(draw, this.command) + // 注册 + this.register = new Register({ + contextMenu + }) + this.register.contextMenuList(globalMenus) } } @@ -65,5 +77,6 @@ export { // 对外类型 export type { - IElement + IElement, + IRegisterContextMenu } \ No newline at end of file diff --git a/src/editor/interface/contextmenu/ContextMenu.ts b/src/editor/interface/contextmenu/ContextMenu.ts new file mode 100644 index 0000000..419c766 --- /dev/null +++ b/src/editor/interface/contextmenu/ContextMenu.ts @@ -0,0 +1,15 @@ +export interface IContextMenuContext { + editorHasSelection: boolean; + editorTextFocus: boolean; + isInTable: boolean; +} + +export interface IRegisterContextMenu { + isDivider?: boolean; + icon?: string; + name?: string; + shortCut?: string; + when?: (payload: IContextMenuContext) => boolean; + callback?: Function; + childMenus?: IRegisterContextMenu[]; +} \ No newline at end of file