feat:add context menu

pr675
黄云飞 4 years ago
parent c87801cb5d
commit 80f671697d

@ -193,4 +193,80 @@
z-index: 9; z-index: 9;
position: absolute; position: absolute;
border: 1px dotted #000000; 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);
} }

@ -0,0 +1 @@
<svg width="16" height="16" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><g fill="#3D4757" fill-rule="evenodd"><path d="M12 4h-1V2H5v2H4V2a1 1 0 011-1h6a1 1 0 011 1v2zm0 5v4a1 1 0 01-1 1H5a1 1 0 01-1-1V9h1v4h6V9h1z"/><path d="M12 12v-1h2V5H2v6h2v1H2a1 1 0 01-1-1V5a1 1 0 011-1h12a1 1 0 011 1v6a1 1 0 01-1 1h-2z"/><path d="M3 8h10v1H3zm8-2h2v1h-2z"/></g></svg>

After

Width:  |  Height:  |  Size: 369 B

@ -0,0 +1 @@
<svg width="16" height="16" xmlns="http://www.w3.org/2000/svg"><g fill="none" fill-rule="evenodd"><path d="M0 0h16v16H0z"/><g fill="#767C85"><path d="M7 12.243l-.707-.707 4.243-4.243.707.707z"/><path d="M6.293 4.464L7 3.757 11.243 8l-.707.707z"/></g></g></svg>

After

Width:  |  Height:  |  Size: 260 B

@ -4,6 +4,10 @@ import { CommandAdapt } from "./CommandAdapt"
export class Command { 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 undo: Function
private static redo: Function private static redo: Function
private static painter: Function private static painter: Function
@ -30,6 +34,10 @@ export class Command {
private static pageScaleAdd: Function private static pageScaleAdd: Function
constructor(adapt: CommandAdapt) { 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.undo = adapt.undo.bind(adapt)
Command.redo = adapt.redo.bind(adapt) Command.redo = adapt.redo.bind(adapt)
Command.painter = adapt.painter.bind(adapt) Command.painter = adapt.painter.bind(adapt)
@ -56,6 +64,23 @@ export class Command {
Command.pageScaleAdd = adapt.pageScaleAdd.bind(adapt) 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() { public executeUndo() {
return Command.undo() return Command.undo()

@ -15,6 +15,7 @@ import { getUUID } from "../../utils"
import { formatElementList } from "../../utils/element" import { formatElementList } from "../../utils/element"
import { printImageBase64 } from "../../utils/print" import { printImageBase64 } from "../../utils/print"
import { Draw } from "../draw/Draw" import { Draw } from "../draw/Draw"
import { CanvasEvent } from "../event/CanvasEvent"
import { HistoryManager } from "../history/HistoryManager" import { HistoryManager } from "../history/HistoryManager"
import { Position } from "../position/Position" import { Position } from "../position/Position"
import { RangeManager } from "../range/RangeManager" import { RangeManager } from "../range/RangeManager"
@ -25,6 +26,7 @@ export class CommandAdapt {
private range: RangeManager private range: RangeManager
private position: Position private position: Position
private historyManager: HistoryManager private historyManager: HistoryManager
private canvasEvent: CanvasEvent
private options: Required<IEditorOption> private options: Required<IEditorOption>
constructor(draw: Draw) { constructor(draw: Draw) {
@ -32,15 +34,35 @@ export class CommandAdapt {
this.range = draw.getRange() this.range = draw.getRange()
this.position = draw.getPosition() this.position = draw.getPosition()
this.historyManager = draw.getHistoryManager() this.historyManager = draw.getHistoryManager()
this.canvasEvent = draw.getCanvasEvent()
this.options = draw.getOptions() 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() { public undo() {
return this.historyManager.undo() this.historyManager.undo()
} }
public redo() { public redo() {
return this.historyManager.redo() this.historyManager.redo()
} }
public painter() { public painter() {

@ -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<HTMLDivElement, HTMLDivElement>
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()
}
}

@ -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()
}
}
]

@ -40,7 +40,11 @@ export class CursorAgent {
} }
private _paste(evt: ClipboardEvent) { private _paste(evt: ClipboardEvent) {
this.canvasEvent.paste(evt) const text = evt.clipboardData?.getData('text')
if (text) {
this.canvasEvent.input(text)
}
evt.preventDefault()
} }
private _compositionstart() { private _compositionstart() {

@ -39,6 +39,7 @@ export class Draw {
private elementList: IElement[] private elementList: IElement[]
private listener: Listener private listener: Listener
private canvasEvent: CanvasEvent
private cursor: Cursor private cursor: Cursor
private range: RangeManager private range: RangeManager
private margin: Margin private margin: Margin
@ -93,10 +94,10 @@ export class Draw {
this.pageNumber = new PageNumber(this) this.pageNumber = new PageNumber(this)
new GlobalObserver(this) new GlobalObserver(this)
const canvasEvent = new CanvasEvent(this) this.canvasEvent = new CanvasEvent(this)
this.cursor = new Cursor(this, canvasEvent) this.cursor = new Cursor(this, this.canvasEvent)
canvasEvent.register() this.canvasEvent.register()
const globalEvent = new GlobalEvent(this, canvasEvent) const globalEvent = new GlobalEvent(this, this.canvasEvent)
globalEvent.register() globalEvent.register()
this.rowList = [] this.rowList = []
@ -225,6 +226,10 @@ export class Draw {
return this.elementList return this.elementList
} }
public getCanvasEvent(): CanvasEvent {
return this.canvasEvent
}
public getListener(): Listener { public getListener(): Listener {
return this.listener return this.listener
} }

@ -1,5 +1,6 @@
import { ZERO } from "../../dataset/constant/Common" import { ZERO } from "../../dataset/constant/Common"
import { ElementStyleKey } from "../../dataset/enum/ElementStyle" import { ElementStyleKey } from "../../dataset/enum/ElementStyle"
import { MouseEventButton } from "../../dataset/enum/Event"
import { KeyMap } from "../../dataset/enum/Keymap" import { KeyMap } from "../../dataset/enum/Keymap"
import { IElement } from "../../interface/Element" import { IElement } from "../../interface/Element"
import { writeTextByElementList } from "../../utils/clipboard" import { writeTextByElementList } from "../../utils/clipboard"
@ -106,6 +107,7 @@ export class CanvasEvent {
} }
public mousedown(evt: MouseEvent) { public mousedown(evt: MouseEvent) {
if (evt.button === MouseEventButton.RIGHT) return
const target = evt.target as HTMLDivElement const target = evt.target as HTMLDivElement
const pageIndex = target.dataset.index const pageIndex = target.dataset.index
// 设置pageNo // 设置pageNo
@ -301,24 +303,11 @@ export class CanvasEvent {
this.historyManager.redo() this.historyManager.redo()
evt.preventDefault() evt.preventDefault()
} else if (evt.ctrlKey && evt.key === KeyMap.C) { } else if (evt.ctrlKey && evt.key === KeyMap.C) {
if (!isCollspace) { this.copy()
writeTextByElementList(elementList.slice(startIndex + 1, endIndex + 1))
}
} else if (evt.ctrlKey && evt.key === KeyMap.X) { } else if (evt.ctrlKey && evt.key === KeyMap.X) {
if (!isCollspace) { this.cut()
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 })
}
} else if (evt.ctrlKey && evt.key === KeyMap.A) { } else if (evt.ctrlKey && evt.key === KeyMap.A) {
this.range.setRange(0, position.length - 1) this.selectAll()
this.draw.render({
isSubmitHistory: false,
isSetCursor: false,
isComputeRowList: false
})
} }
} }
@ -326,6 +315,7 @@ export class CanvasEvent {
if (!this.cursor) return if (!this.cursor) return
const cursorPosition = this.position.getCursorPosition() const cursorPosition = this.position.getCursorPosition()
if (!data || !cursorPosition || this.isCompositing) return if (!data || !cursorPosition || this.isCompositing) return
const text = data.replaceAll(`\n`, ZERO)
const elementList = this.draw.getElementList() const elementList = this.draw.getElementList()
const agentDom = this.cursor.getAgentDom() const agentDom = this.cursor.getAgentDom()
agentDom.value = '' agentDom.value = ''
@ -339,7 +329,7 @@ export class CanvasEvent {
const { tdId, trId, tableId } = positionContext const { tdId, trId, tableId } = positionContext
restArg = { tdId, trId, tableId } restArg = { tdId, trId, tableId }
} }
const inputData: IElement[] = data.split('').map(value => ({ const inputData: IElement[] = text.split('').map(value => ({
value, value,
...restArg ...restArg
})) }))
@ -359,10 +349,34 @@ export class CanvasEvent {
this.draw.render({ curIndex }) this.draw.render({ curIndex })
} }
public paste(evt: ClipboardEvent) { public cut() {
const text = evt.clipboardData?.getData('text') const { startIndex, endIndex } = this.range.getRange()
this.input(text?.replaceAll(`\n`, ZERO) || '') const elementList = this.draw.getElementList()
evt.preventDefault() 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() { public compositionstart() {

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

@ -1,7 +1,8 @@
export enum EditorComponent { export enum EditorComponent {
MENU = 'menu', MENU = 'menu',
MAIN = 'main', MAIN = 'main',
FOOTER = 'footer' FOOTER = 'footer',
CONTEXTMENU = 'contextmenu'
} }
export enum EditorContext { export enum EditorContext {

@ -0,0 +1,5 @@
export enum MouseEventButton {
LEFT = 0,
CENTER = 1,
RIGHT = 2
}

@ -8,11 +8,16 @@ import { Listener } from './core/listener/Listener'
import { RowFlex } from './dataset/enum/Row' import { RowFlex } from './dataset/enum/Row'
import { ElementType } from './dataset/enum/Element' import { ElementType } from './dataset/enum/Element'
import { formatElementList } from './utils/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 { export default class Editor {
public command: Command public command: Command
public listener: Listener public listener: Listener
public register: Register
constructor(container: HTMLDivElement, elementList: IElement[], options: IEditorOption = {}) { constructor(container: HTMLDivElement, elementList: IElement[], options: IEditorOption = {}) {
const editorOptions: Required<IEditorOption> = { const editorOptions: Required<IEditorOption> = {
@ -52,6 +57,13 @@ export default class Editor {
const draw = new Draw(container, editorOptions, elementList, this.listener) const draw = new Draw(container, editorOptions, elementList, this.listener)
// 命令 // 命令
this.command = new Command(new CommandAdapt(draw)) 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 { export type {
IElement IElement,
IRegisterContextMenu
} }

@ -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[];
}
Loading…
Cancel
Save