272 lines
5.9 KiB
TypeScript
272 lines
5.9 KiB
TypeScript
/*
|
|
* Copyright 2025 coze-dev Authors
|
|
*
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
* you may not use this file except in compliance with the License.
|
|
* You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
* See the License for the specific language governing permissions and
|
|
* limitations under the License.
|
|
*/
|
|
|
|
import type React from 'react';
|
|
|
|
import { inject, injectable, postConstruct } from 'inversify';
|
|
import { Disposable } from '@flowgram-adapter/common';
|
|
|
|
import { Selector } from '../../lumino/domutils';
|
|
import { ArrayExt } from '../../lumino/algorithm';
|
|
import { Menu, MenuFactory } from './menu';
|
|
|
|
export type CanHandle = string | ((command?: string) => boolean);
|
|
|
|
/**
|
|
* 全局 contextmenu 监听
|
|
*/
|
|
@injectable()
|
|
export class ContextMenu {
|
|
@inject(MenuFactory) menuFactory: MenuFactory;
|
|
|
|
// 全局主菜单
|
|
menu: Menu;
|
|
|
|
@postConstruct()
|
|
init() {
|
|
this.menu = this.menuFactory();
|
|
}
|
|
|
|
/**
|
|
* 删除项
|
|
*/
|
|
deleteItem(canHandle: CanHandle) {
|
|
if (typeof canHandle === 'string') {
|
|
// 精确删除
|
|
const item = this._items.find(i => i.command === canHandle);
|
|
ArrayExt.removeFirstOf(this._items, item);
|
|
} else {
|
|
// 模糊删除
|
|
this._items.forEach(i => {
|
|
if (canHandle(i.command)) {
|
|
ArrayExt.removeFirstOf(this._items, i);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 添加项
|
|
*/
|
|
addItem(options: ContextMenu.IItemOptions): Disposable {
|
|
const item = Private.createItem(options, this._idTick++);
|
|
|
|
this._items.push(item);
|
|
|
|
return Disposable.create(() => {
|
|
ArrayExt.removeFirstOf(this._items, item);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 手动关闭 menu
|
|
*/
|
|
close() {
|
|
this.menu.close();
|
|
}
|
|
|
|
/**
|
|
* 打开事件
|
|
*/
|
|
open(event: React.MouseEvent, args?: any): boolean {
|
|
Menu.saveWindowData();
|
|
|
|
this.menu.clearItems();
|
|
|
|
if (this._items.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
const items = Private.matchItems(
|
|
this._items,
|
|
event,
|
|
this._groupByTarget,
|
|
this._sortBySelector,
|
|
);
|
|
|
|
if (!items || items.length === 0) {
|
|
return false;
|
|
}
|
|
|
|
for (const item of items) {
|
|
if (args) {
|
|
item.args = args;
|
|
}
|
|
if (item.filter && !item.filter(args)) {
|
|
continue;
|
|
}
|
|
this.menu.addItem(item);
|
|
}
|
|
|
|
this.menu.open(event.clientX, event.clientY);
|
|
|
|
return true;
|
|
}
|
|
|
|
private _groupByTarget = true;
|
|
|
|
private _idTick = 0;
|
|
|
|
private _items: Private.IItem[] = [];
|
|
|
|
private _sortBySelector = true;
|
|
}
|
|
|
|
export namespace ContextMenu {
|
|
export interface IOptions {
|
|
renderer?: Menu.IRenderer;
|
|
|
|
sortBySelector?: boolean;
|
|
|
|
groupByTarget?: boolean;
|
|
}
|
|
|
|
/**
|
|
* An options object for creating a context menu item.
|
|
*/
|
|
export interface IItemOptions extends Menu.IItemOptions {
|
|
/**
|
|
* The CSS selector for the context menu item.
|
|
*
|
|
* 只有当当前元素冒泡途径 selector 元素,才会触发这个 contextmenu 事件。
|
|
* 底层通过 querySelector 获取,需要加上 commas
|
|
*/
|
|
selector: string;
|
|
|
|
/**
|
|
* The default rank is `Infinity`.
|
|
*/
|
|
rank?: number;
|
|
}
|
|
}
|
|
|
|
namespace Private {
|
|
export interface IItem extends Menu.IItemOptions {
|
|
selector: string;
|
|
|
|
rank: number;
|
|
|
|
id: number;
|
|
}
|
|
|
|
/**
|
|
* Create a normalized context menu item from an options object.
|
|
*/
|
|
export function createItem(
|
|
options: ContextMenu.IItemOptions,
|
|
id: number,
|
|
): IItem {
|
|
const selector = validateSelector(options.selector);
|
|
const rank = options.rank !== undefined ? options.rank : Infinity;
|
|
return { ...options, selector, rank, id };
|
|
}
|
|
|
|
export function matchItems(
|
|
items: IItem[],
|
|
event: React.MouseEvent,
|
|
groupByTarget: boolean,
|
|
sortBySelector: boolean,
|
|
): IItem[] | null {
|
|
let target = event.target as Element | null;
|
|
|
|
if (!target) {
|
|
return null;
|
|
}
|
|
|
|
const currentTarget = event.currentTarget as Element | null;
|
|
|
|
if (!currentTarget) {
|
|
return null;
|
|
}
|
|
|
|
const result: IItem[] = [];
|
|
|
|
const availableItems: Array<IItem | null> = items.slice();
|
|
|
|
while (target !== null) {
|
|
const matches: IItem[] = [];
|
|
|
|
for (let i = 0, n = availableItems.length; i < n; ++i) {
|
|
const item = availableItems[i];
|
|
|
|
if (!item) {
|
|
continue;
|
|
}
|
|
|
|
if (!Selector.matches(target, item.selector)) {
|
|
continue;
|
|
}
|
|
|
|
matches.push(item);
|
|
|
|
availableItems[i] = null;
|
|
}
|
|
|
|
if (matches.length !== 0) {
|
|
if (groupByTarget) {
|
|
matches.sort(sortBySelector ? itemCmp : itemCmpRank);
|
|
}
|
|
result.push(...matches);
|
|
}
|
|
|
|
if (target === currentTarget) {
|
|
break;
|
|
}
|
|
|
|
target = target.parentElement;
|
|
}
|
|
|
|
if (!groupByTarget) {
|
|
result.sort(sortBySelector ? itemCmp : itemCmpRank);
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
function validateSelector(selector: string): string {
|
|
if (selector.indexOf(',') !== -1) {
|
|
throw new Error(`Selector cannot contain commas: ${selector}`);
|
|
}
|
|
if (!Selector.isValid(selector)) {
|
|
throw new Error(`Invalid selector: ${selector}`);
|
|
}
|
|
return selector;
|
|
}
|
|
|
|
function itemCmpRank(a: IItem, b: IItem): number {
|
|
const r1 = a.rank;
|
|
const r2 = b.rank;
|
|
if (r1 !== r2) {
|
|
return r1 < r2 ? -1 : 1; // Infinity-safe
|
|
}
|
|
|
|
return a.id - b.id;
|
|
}
|
|
|
|
/**
|
|
* A sort comparison function for a context menu item by selectors and ranks.
|
|
*/
|
|
function itemCmp(a: IItem, b: IItem): number {
|
|
const s1 = Selector.calculateSpecificity(a.selector);
|
|
const s2 = Selector.calculateSpecificity(b.selector);
|
|
if (s1 !== s2) {
|
|
return s2 - s1;
|
|
}
|
|
|
|
return itemCmpRank(a, b);
|
|
}
|
|
}
|