From 1db5f4611b1255f139357ed0054d65665a160761 Mon Sep 17 00:00:00 2001
From: mealcomes <2013213920@qq.com>
Date: Fri, 10 Apr 2026 00:35:14 +0800
Subject: [PATCH] feat: add custom styles, icons, and class names for
context-menu plugin
---
.../demo/context-menu/context-menu.ts | 206 +++++++++++++-
packages/vtable-plugins/src/context-menu.ts | 9 +-
.../src/contextmenu/menu-manager.ts | 117 ++++----
.../vtable-plugins/src/contextmenu/styles.ts | 254 +++++++++++++++---
.../vtable-plugins/src/contextmenu/types.ts | 30 +++
5 files changed, 523 insertions(+), 93 deletions(-)
diff --git a/packages/vtable-plugins/demo/context-menu/context-menu.ts b/packages/vtable-plugins/demo/context-menu/context-menu.ts
index d581c7cb9b..5cc826988d 100644
--- a/packages/vtable-plugins/demo/context-menu/context-menu.ts
+++ b/packages/vtable-plugins/demo/context-menu/context-menu.ts
@@ -1,7 +1,8 @@
import * as VTable from '@visactor/vtable';
import { ContextMenuPlugin } from '../../src/context-menu';
import { TableSeriesNumber } from '../../src/table-series-number';
-import { DEFAULT_HEADER_MENU_ITEMS, MenuKey } from '../../src';
+import { DEFAULT_HEADER_MENU_ITEMS } from '../../src';
+import type { MenuItem } from '../../src';
const CONTAINER_ID = 'vTable';
@@ -20,6 +21,12 @@ const generateTestData = (count: number) => {
}));
};
+const COPY_SVG = ``;
+
+const DELETE_SVG = ``;
+
+const SETTINGS_SVG = ``;
+
/**
* 创建示例表格
*/
@@ -72,7 +79,7 @@ export function createTableInstance() {
...DEFAULT_HEADER_MENU_ITEMS,
{
text: '设置筛选器',
-
+ customIcon: { svg: SETTINGS_SVG, width: 16, height: 16 },
menuKey: 'set_filter'
}
],
@@ -155,10 +162,181 @@ export function createTableInstance() {
// { text: '合并单元格', menuKey: 'merge_cells', iconName: 'merge' },
// { text: '设置保护范围', menuKey: 'set_protection', iconName: 'protect' }
// ],
+ bodyCellMenuItems: [
+ {
+ text: '复制',
+ customClassName: {
+ item: 'copy-item',
+ icon: 'copy-icon',
+ text: 'copy-text',
+ shortcut: 'copy-shortcut',
+ leftContainer: 'left',
+ rightContainer: 'right'
+ },
+ menuKey: 'copy',
+ customIcon: { svg: COPY_SVG, width: 16, height: 16 },
+ shortcut: 'Ctrl+C'
+ },
+ { text: '剪切', menuKey: 'cut', iconName: 'cut', shortcut: 'Ctrl+X' },
+ { text: '粘贴', menuKey: 'paste', iconName: 'paste', shortcut: 'Ctrl+V' },
+ '---',
+ {
+ text: '删除选中',
+ customIcon: (_menuItem: MenuItem) => {
+ const el = document.createElement('span');
+ el.style.display = 'inline-block';
+ el.style.width = '8px';
+ el.style.height = '8px';
+ el.style.borderRadius = '50%';
+ el.style.backgroundColor = '#ff4d4f';
+ return el;
+ },
+ menuKey: 'delete_row'
+ },
+ {
+ text: '自定义操作',
+ customIcon: (_menuItem: MenuItem) => {
+ const img = document.createElement('img');
+ img.src = 'data:image/svg+xml,' + encodeURIComponent(DELETE_SVG);
+ img.width = 16;
+ img.height = 16;
+ img.style.opacity = '0.6';
+ return img;
+ },
+ menuKey: 'custom_action'
+ },
+ '---',
+ {
+ text: '插入',
+ menuKey: 'insert',
+ iconName: 'insert',
+ children: [
+ {
+ text: '向上插入行数:',
+ menuKey: 'insert_row_above',
+ iconName: 'up-arrow',
+ inputDefaultValue: 1,
+ customClassName: { input: 'input' }
+ },
+ { text: '向下插入行数:', menuKey: 'insert_row_below', iconName: 'down-arrow', inputDefaultValue: 1 },
+ '---',
+ { text: '向左插入列数:', menuKey: 'insert_column_left', iconName: 'left-arrow', inputDefaultValue: 1 },
+ { text: '向右插入列数:', menuKey: 'insert_column_right', iconName: 'right-arrow', inputDefaultValue: 1 }
+ ],
+ customClassName: {
+ arrow: 'arrow'
+ }
+ },
+ '---',
+ { text: '合并单元格', menuKey: 'merge_cells' },
+ '---',
+ {
+ text: '不可用操作',
+ menuKey: 'disabled_action',
+ disabled: true,
+ iconName: 'protect',
+ customClassName: { itemDisabled: 'custom-disabled' }
+ },
+ { text: '不可用粘贴', menuKey: 'paste_disabled', disabled: true, shortcut: 'Ctrl+V' }
+ ],
menuClickCallback: {
- // [MenuKey.COPY]: (args: MenuClickEventArgs, table: VTable.ListTable) => {
- // console.log('复制', args, table);
- // }
+ custom_action: (args, _table) => {
+ alert(`自定义操作被点击: 行${args.rowIndex} 列${args.colIndex}`);
+ },
+ set_filter: (args, table) => {
+ alert(`设置筛选器: 列${args.colIndex}`);
+ }
+ },
+ CustomMenuAttributions: {
+ style: {
+ menuContainer: {
+ backgroundColor: 'rgba(255, 255, 255)',
+ border: '1px solid #ccc',
+ borderRadius: '6px',
+ boxShadow: '0 3px 12px rgba(0, 0, 0, 0.15)',
+ padding: '4px',
+ maxHeight: '400px'
+ },
+ submenuContainer: {
+ borderRadius: '6px',
+ boxShadow: '0 3px 12px rgba(0, 0, 0, 0.15)'
+ },
+ menuItem: {
+ padding: '6px 12px',
+ borderRadius: '4px',
+ cursor: 'pointer'
+ },
+ menuItemHover: {
+ backgroundColor: 'rgba(22, 119, 255, 0.1)'
+ },
+ menuItemDisabled: {
+ opacity: '0.35',
+ cursor: 'not-allowed'
+ },
+ menuItemSeparator: {
+ height: '2px',
+ backgroundColor: 'rgba(0, 0, 0, 0.12)',
+ margin: '4px 0'
+ },
+ menuItemIcon: {
+ marginRight: '10px',
+ width: '18px',
+ height: '18px'
+ },
+ menuItemText: {
+ flex: '1',
+ fontSize: '13px'
+ },
+ menuItemShortcut: {
+ marginLeft: '24px',
+ color: '#aaa',
+ fontSize: '11px'
+ },
+ submenuArrow: {
+ marginLeft: '8px',
+ fontSize: '10px',
+ color: '#999'
+ },
+ inputContainer: {
+ padding: '6px 12px',
+ display: 'flex',
+ alignItems: 'center'
+ },
+ inputLabel: {
+ marginRight: '6px',
+ whiteSpace: 'nowrap',
+ fontSize: '12px'
+ },
+ inputField: {
+ width: '50px',
+ padding: '3px 4px',
+ border: '1px solid #d9d9d9',
+ borderRadius: '4px',
+ fontSize: '12px',
+ outline: 'none'
+ },
+ buttonContainer: {
+ display: 'flex',
+ justifyContent: 'flex-end',
+ padding: '4px 12px'
+ },
+ button: {
+ padding: '4px 12px',
+ backgroundColor: '#1677ff',
+ color: 'white',
+ border: 'none',
+ borderRadius: '4px',
+ cursor: 'pointer',
+ fontSize: '12px'
+ }
+ },
+ class: {
+ menuContainer: 'custom-context-menu-container',
+ menuItem: 'custom-context-menu-item',
+ menuItemSeparator: ['custom1-context-menu-item-separator', 'custom2-context-menu-item-separator'],
+ submenuContainer: 'custom-context-menu-submenu',
+ menuItemDisabled: 'custom-context-menu-item-disabled'
+ }
}
});
const tableSeriesNumberPlugin = new TableSeriesNumber({
@@ -238,6 +416,24 @@ export function createTable() {
支持输入框数量
长菜单可滚动
+
+ 菜单自定义图标和样式演示
+
+ - 表体单元格:展示了 SVG 图标、渲染函数图标、内置 emoji 图标的混合使用
+ - 表头单元格:展示了 SVG 图标(设置图标)
+ - 菜单样式和类名通过
CustomMenuAttributions 统一配置
+ - 图标类型说明
+
+ iconName: 'copy' — 内置 emoji 图标
+ customIcon: { svg: '...', width: 16, height: 16 } — SVG 图标
+ customIcon: (menuItem) => HTMLElement — 渲染函数
+
+ - 自定义类名说明
+
+ CustomMenuAttributions.class — 统一追加类名
+ MenuItem.customClassName — 单项精细化类名
+
+
`;
document.body.insertBefore(info, container);
diff --git a/packages/vtable-plugins/src/context-menu.ts b/packages/vtable-plugins/src/context-menu.ts
index fc9c68fae8..3dd0435996 100644
--- a/packages/vtable-plugins/src/context-menu.ts
+++ b/packages/vtable-plugins/src/context-menu.ts
@@ -3,6 +3,8 @@ import { TABLE_EVENT_TYPE } from '@visactor/vtable';
import type { pluginsDefinition } from '@visactor/vtable';
import { MenuManager } from './contextmenu/menu-manager';
import { MenuHandler } from './contextmenu/handle-menu-helper';
+import { mergeClasses, mergeStyles } from './contextmenu/styles';
+import type { CustomMenuAttributions } from './contextmenu/styles';
import type { MenuItemOrSeparator, MenuClickEventArgs } from './contextmenu/types';
import {
DEFAULT_BODY_MENU_ITEMS,
@@ -27,6 +29,8 @@ export interface ContextMenuOptions {
headerCellMenuItems?: MenuItemOrSeparator[];
/** 表体菜单项 */
bodyCellMenuItems?: MenuItemOrSeparator[];
+ /** 自定义菜单样式 */
+ CustomMenuAttributions?: CustomMenuAttributions;
/** 菜单点击回调。如果设置是函数,则忽略内部默认的菜单项处理逻辑。如果这里配置的是个对象(对象的key为menuKey),则有匹配的menuKey时忽略内部默认的菜单项处理逻辑,
* 以这里配置的为准 ,没有匹配的menuKey时,则使用内部默认的菜单项处理逻辑。*/
menuClickCallback?:
@@ -63,7 +67,10 @@ export class ContextMenuPlugin implements pluginsDefinition.IVTablePlugin {
constructor(pluginOptions: ContextMenuOptions = {}) {
this.id = pluginOptions.id ?? this.id;
this.pluginOptions = pluginOptions;
- this.menuManager = new MenuManager();
+ this.menuManager = new MenuManager(
+ mergeStyles(pluginOptions.CustomMenuAttributions?.style),
+ mergeClasses(pluginOptions.CustomMenuAttributions?.class)
+ );
this.menuHandler = new MenuHandler();
this.initDefaultMenuItems();
}
diff --git a/packages/vtable-plugins/src/contextmenu/menu-manager.ts b/packages/vtable-plugins/src/contextmenu/menu-manager.ts
index 8aec302185..44816c106a 100644
--- a/packages/vtable-plugins/src/contextmenu/menu-manager.ts
+++ b/packages/vtable-plugins/src/contextmenu/menu-manager.ts
@@ -1,17 +1,15 @@
import type { ListTable } from '@visactor/vtable';
import { vglobal } from '@visactor/vtable/es/vrender';
import {
- MENU_CONTAINER_CLASS,
- MENU_ITEM_CLASS,
- MENU_ITEM_SEPARATOR_CLASS,
- MENU_ITEM_SUBMENU_CLASS,
MENU_STYLES,
createElement,
applyStyles,
createIcon,
- createNumberInputItem,
- MENU_ITEM_DISABLED_CLASS
+ MENU_CLASSES,
+ normalizeItemClassNameConfig,
+ normalizeClassName
} from './styles';
+import type { MenuClasses, MenuStyles } from './styles';
import type { MenuItemOrSeparator, MenuKey } from './types';
import type { MenuItem } from './types';
import type { MenuClickEventArgs } from './types';
@@ -35,6 +33,13 @@ export class MenuManager {
private submenuShowDelay = 100;
private submenuHideDelay = 500;
private menuInitializationDelay = 200; // 菜单初始化延迟时间(毫秒)
+ private styles: MenuStyles;
+ private classes: MenuClasses;
+
+ constructor(styles?: MenuStyles, classes?: MenuClasses) {
+ this.styles = styles ?? MENU_STYLES;
+ this.classes = classes ?? MENU_CLASSES;
+ }
/**
* 显示菜单
@@ -48,8 +53,9 @@ export class MenuManager {
this.release();
// 创建菜单容器
- this.menuContainer = createElement('div', MENU_CONTAINER_CLASS);
- applyStyles(this.menuContainer, MENU_STYLES.menuContainer);
+ this.menuContainer = createElement('div');
+ this.menuContainer.classList.add(...normalizeClassName(this.classes.menuContainer));
+ applyStyles(this.menuContainer, this.styles.menuContainer);
document.body.appendChild(this.menuContainer);
this.menuContainer.addEventListener('contextmenu', (e: MouseEvent) => {
e.preventDefault();
@@ -92,60 +98,62 @@ export class MenuManager {
items.forEach(item => {
if (typeof item === 'string' && item === '---') {
// 创建分隔线
- const separator = createElement('div', MENU_ITEM_SEPARATOR_CLASS);
- applyStyles(separator, MENU_STYLES.menuItemSeparator);
+ const separator = createElement('div');
+ separator.classList.add(...normalizeClassName(this.classes.menuItemSeparator));
+ applyStyles(separator, this.styles.menuItemSeparator);
container.appendChild(separator);
- // } else if (typeof item === 'object' && 'type' in item && item.type === 'input') {
- // // 创建输入框菜单项
- // const inputItem = item as MenuItemInput;
- // const wrapper = createNumberInputItem(
- // inputItem.label,
- // inputItem.defaultValue || 1,
- // inputItem.iconName,
- // (value: number) => {
- // this.handleMenuItemClick({
- // menuKey: inputItem.menuKey,
- // menuText: inputItem.label,
- // inputValue: value,
- // ...this.context
- // });
- // }
- // );
- // container.appendChild(wrapper);
} else if (typeof item === 'object') {
// 创建普通菜单项
const menuItem = item as MenuItem;
- const menuItemElement = createElement('div', MENU_ITEM_CLASS);
- applyStyles(menuItemElement, MENU_STYLES.menuItem);
+ const customClassName = normalizeItemClassNameConfig(item.customClassName);
+ const menuItemElement = createElement('div');
+ menuItemElement.classList.add(...normalizeClassName(this.classes.menuItem));
+ if (customClassName.item) {
+ menuItemElement.classList.add(...customClassName.item);
+ }
+ applyStyles(menuItemElement, this.styles.menuItem);
// 创建左侧图标容器
const leftContainer = createElement('div');
+ if (customClassName.leftContainer) {
+ leftContainer.classList.add(...customClassName.leftContainer);
+ }
leftContainer.style.display = 'flex';
leftContainer.style.alignItems = 'center';
- // 添加图标
- if (menuItem.iconName) {
- const icon = createIcon(menuItem.iconName);
+ // 添加图标(优先使用 customIcon)
+ const iconValue = menuItem.customIcon ?? menuItem.iconName;
+ if (iconValue) {
+ const icon = createIcon(iconValue, menuItem, this.styles.menuItemIcon);
+ if (customClassName.icon) {
+ icon.classList.add(...customClassName.icon);
+ }
leftContainer.appendChild(icon);
} else if (menuItem.iconPlaceholder) {
// 占位图标,保持对齐
const placeholder = createElement('span');
- applyStyles(placeholder, MENU_STYLES.menuItemIcon);
+ applyStyles(placeholder, this.styles.menuItemIcon);
leftContainer.appendChild(placeholder);
}
// 添加文本
const text = createElement('span');
+ if (customClassName.text) {
+ text.classList.add(...customClassName.text);
+ }
text.textContent = menuItem.text;
- applyStyles(text, MENU_STYLES.menuItemText);
+ applyStyles(text, this.styles.menuItemText);
leftContainer.appendChild(text);
if (item.inputDefaultValue) {
// 创建输入框
const input = createElement('input') as HTMLInputElement;
+ if (customClassName.input) {
+ input.classList.add(...customClassName.input);
+ }
input.type = 'number';
input.min = '1';
input.value = item.inputDefaultValue.toString();
- applyStyles(input, MENU_STYLES.inputField);
+ applyStyles(input, this.styles.inputField);
leftContainer.appendChild(input);
//监听enter 回车确认
input.addEventListener('keydown', (e: KeyboardEvent) => {
@@ -164,23 +172,32 @@ export class MenuManager {
// 创建右侧容器
const rightContainer = createElement('div');
+ if (customClassName.rightContainer) {
+ rightContainer.classList.add(...customClassName.rightContainer);
+ }
rightContainer.style.display = 'flex';
rightContainer.style.alignItems = 'center';
// 添加快捷键
if (menuItem.shortcut) {
const shortcut = createElement('span');
+ if (customClassName.shortcut) {
+ shortcut.classList.add(...customClassName.shortcut);
+ }
shortcut.textContent = menuItem.shortcut;
- applyStyles(shortcut, MENU_STYLES.menuItemShortcut);
+ applyStyles(shortcut, this.styles.menuItemShortcut);
rightContainer.appendChild(shortcut);
}
// 添加子菜单箭头
if (menuItem.children && menuItem.children.length > 0) {
- menuItemElement.classList.add(MENU_ITEM_SUBMENU_CLASS);
+ menuItemElement.classList.add(...normalizeClassName(this.classes.menuItemSubmenu));
const arrow = createElement('span');
+ if (customClassName.arrow) {
+ arrow.classList.add(...customClassName.arrow);
+ }
arrow.textContent = '▶';
- applyStyles(arrow, MENU_STYLES.submenuArrow);
+ applyStyles(arrow, this.styles.submenuArrow);
rightContainer.appendChild(arrow);
}
@@ -188,8 +205,11 @@ export class MenuManager {
// 禁用状态
if (menuItem.disabled) {
- menuItemElement.classList.add(MENU_ITEM_DISABLED_CLASS);
- applyStyles(menuItemElement, MENU_STYLES.menuItemDisabled);
+ menuItemElement.classList.add(...normalizeClassName(this.classes.menuItemDisabled));
+ if (customClassName.itemDisabled) {
+ menuItemElement.classList.add(...customClassName.itemDisabled);
+ }
+ applyStyles(menuItemElement, this.styles.menuItemDisabled);
}
// 添加事件监听
@@ -211,9 +231,8 @@ export class MenuManager {
// 鼠标悬停事件
menuItemElement.addEventListener('mouseenter', () => {
- console.log('mouseenter', menuItem.text);
// 添加悬停样式
- applyStyles(menuItemElement, MENU_STYLES.menuItemHover);
+ applyStyles(menuItemElement, this.styles.menuItemHover);
// 清除隐藏定时器
if (this.hideTimeout !== null) {
@@ -244,10 +263,8 @@ export class MenuManager {
// 鼠标离开事件
menuItemElement.addEventListener('mouseleave', () => {
- console.log('mouseleave', menuItem.text);
- // 移除悬停样式,使用与添加时相同的方式
- // 通过设置空对象来重置之前应用的menuItemHover样式的属性
- Object.keys(MENU_STYLES.menuItemHover).forEach(key => {
+ // 移除悬停样式
+ Object.keys(this.styles.menuItemHover).forEach(key => {
(menuItemElement.style as any)[key] = '';
});
@@ -272,8 +289,12 @@ export class MenuManager {
const parentRect = parentElement.getBoundingClientRect();
// 创建子菜单容器
- const submenu = createElement('div', MENU_CONTAINER_CLASS);
- applyStyles(submenu, MENU_STYLES.submenuContainer);
+ const submenu = createElement('div');
+ submenu.classList.add(
+ ...normalizeClassName(this.classes.menuContainer),
+ ...normalizeClassName(this.classes.submenuContainer)
+ );
+ applyStyles(submenu, this.styles.submenuContainer);
// 创建子菜单项
this.createMenuItems(items, submenu, parentItem);
diff --git a/packages/vtable-plugins/src/contextmenu/styles.ts b/packages/vtable-plugins/src/contextmenu/styles.ts
index f1ad92f049..6e237ed7b7 100644
--- a/packages/vtable-plugins/src/contextmenu/styles.ts
+++ b/packages/vtable-plugins/src/contextmenu/styles.ts
@@ -1,12 +1,17 @@
+import { isArray } from '@visactor/vutils';
/**
* 右键菜单的样式定义
*/
-export const MENU_CONTAINER_CLASS = 'vtable-context-menu-container';
-export const MENU_ITEM_CLASS = 'vtable-context-menu-item';
-export const MENU_ITEM_DISABLED_CLASS = 'vtable-context-menu-item-disabled';
-export const MENU_ITEM_SEPARATOR_CLASS = 'vtable-context-menu-item-separator';
-export const MENU_ITEM_SUBMENU_CLASS = 'vtable-context-menu-item-submenu';
+import type { ClassName, MenuItem, MenuItemClassConfig, MenuItemIcon, SvgIconConfig } from './types';
+
+export type MenuStyleDef = { [K in keyof CSSStyleDeclaration]?: CSSStyleDeclaration[K] };
+export type MenuStyles = typeof MENU_STYLES;
+export type MenuClasses = typeof MENU_CLASSES;
+export type CustomMenuAttributions = {
+ style?: { [K in keyof MenuStyles]?: Partial };
+ class?: { [K in keyof MenuClasses]?: Partial };
+};
export const MENU_STYLES = {
menuContainer: {
@@ -15,12 +20,12 @@ export const MENU_STYLES = {
boxShadow: '0 2px 10px rgba(0, 0, 0, 0.2)',
borderRadius: '4px',
padding: '5px 0',
- zIndex: 1000,
+ zIndex: '1000',
minWidth: '180px',
maxHeight: '300px', // 设置最大高度
overflowY: 'auto', // 添加垂直滚动
fontSize: '12px'
- },
+ } as MenuStyleDef,
menuItem: {
padding: '6px 20px',
cursor: 'pointer',
@@ -29,19 +34,19 @@ export const MENU_STYLES = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between'
- },
+ } as MenuStyleDef,
menuItemHover: {
backgroundColor: '#f5f5f5'
- },
+ } as MenuStyleDef,
menuItemDisabled: {
- opacity: 0.5,
+ opacity: '0.5',
cursor: 'not-allowed'
- },
+ } as MenuStyleDef,
menuItemSeparator: {
height: '1px',
backgroundColor: '#e0e0e0',
margin: '5px 0'
- },
+ } as MenuStyleDef,
menuItemIcon: {
marginRight: '8px',
width: '16px',
@@ -49,20 +54,20 @@ export const MENU_STYLES = {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center'
- },
+ } as MenuStyleDef,
menuItemText: {
- flex: 1
- },
+ flex: '1'
+ } as MenuStyleDef,
menuItemShortcut: {
marginLeft: '20px',
color: '#999',
fontSize: '11px'
- },
+ } as MenuStyleDef,
submenuArrow: {
marginLeft: '5px',
fontSize: '12px',
color: '#666'
- },
+ } as MenuStyleDef,
submenuContainer: {
position: 'absolute',
left: '100%',
@@ -71,30 +76,30 @@ export const MENU_STYLES = {
boxShadow: '0 2px 10px rgba(0, 0, 0, 0.2)',
borderRadius: '4px',
padding: '5px 0',
- zIndex: 1001,
+ zIndex: '1001',
minWidth: '180px',
fontSize: '12px'
- },
+ } as MenuStyleDef,
inputContainer: {
padding: '8px 12px',
display: 'flex',
alignItems: 'center'
- },
+ } as MenuStyleDef,
inputLabel: {
marginRight: '8px',
whiteSpace: 'nowrap'
- },
+ } as MenuStyleDef,
inputField: {
width: '60px',
padding: '4px',
border: '1px solid #ddd',
borderRadius: '3px'
- },
+ } as MenuStyleDef,
buttonContainer: {
display: 'flex',
justifyContent: 'flex-end',
padding: '5px 12px'
- },
+ } as MenuStyleDef,
button: {
padding: '4px 8px',
backgroundColor: '#1890ff',
@@ -103,9 +108,115 @@ export const MENU_STYLES = {
borderRadius: '3px',
cursor: 'pointer',
fontSize: '12px'
- }
+ } as MenuStyleDef
};
+export const MENU_CLASSES = {
+ menuContainer: 'vtable-context-menu-container' as ClassName,
+ submenuContainer: 'vtable-context-submenu-container' as ClassName,
+ menuItem: 'vtable-context-menu-item' as ClassName,
+ menuItemSeparator: 'vtable-context-menu-item-separator' as ClassName,
+ menuItemSubmenu: 'vtable-context-menu-item-submenu' as ClassName,
+ menuItemDisabled: 'vtable-context-menu-item-disabled' as ClassName
+};
+
+/**
+ * 移除用户传入的 SVG 中可能的危险元素和属性
+ */
+const DANGEROUS_SVG_ELEMENTS = new Set([
+ 'script',
+ 'iframe',
+ 'object',
+ 'embed',
+ 'form',
+ 'input',
+ 'textarea',
+ 'select',
+ 'button'
+]);
+const DANGEROUS_ATTR_PREFIXES = ['on'];
+const DANGEROUS_URL_ATTRS = new Set(['href', 'xlink:href']);
+
+export function sanitizeSvg(svgString: string): string {
+ const parser = new DOMParser();
+ const doc = parser.parseFromString(svgString, 'image/svg+xml');
+ const errorNode = doc.querySelector('parsererror');
+ if (errorNode) {
+ return '';
+ }
+ const svg = doc.documentElement;
+ if (svg.tagName.toLowerCase() !== 'svg') {
+ return '';
+ }
+ sanitizeNode(svg);
+ const serializer = new XMLSerializer();
+ return serializer.serializeToString(svg);
+}
+
+function sanitizeNode(node: Element): void {
+ const children = Array.from(node.children);
+ for (const child of children) {
+ if (DANGEROUS_SVG_ELEMENTS.has(child.tagName.toLowerCase())) {
+ node.removeChild(child);
+ } else {
+ sanitizeNode(child);
+ }
+ }
+
+ const attrs = Array.from(node.attributes);
+ for (const attr of attrs) {
+ const name = attr.name.toLowerCase();
+ if (DANGEROUS_ATTR_PREFIXES.some(prefix => name.startsWith(prefix))) {
+ node.removeAttribute(attr.name);
+ continue;
+ }
+ if (DANGEROUS_URL_ATTRS.has(name)) {
+ const value = attr.value.trim().toLowerCase();
+ if (value.startsWith('javascript:') || value.startsWith('data:')) {
+ node.removeAttribute(attr.name);
+ }
+ }
+ }
+}
+
+/**
+ * 合并用户自定义样式与默认样式
+ */
+export function mergeStyles(styles?: Partial): MenuStyles {
+ if (!styles) {
+ return { ...MENU_STYLES };
+ }
+ const result: any = {};
+ for (const _key in MENU_STYLES) {
+ const key = _key as keyof MenuStyles;
+ if (styles[key]) {
+ result[key] = { ...MENU_STYLES[key], ...styles[key] };
+ } else {
+ result[key] = { ...MENU_STYLES[key] };
+ }
+ }
+ return result as MenuStyles;
+}
+
+/**
+ * 合并用户自定义类名与默认类名
+ */
+export function mergeClasses(classes?: Partial): MenuClasses {
+ if (!classes) {
+ return { ...MENU_CLASSES };
+ }
+ const result: any = {};
+ for (const _key in MENU_CLASSES) {
+ const key = _key as keyof MenuClasses;
+ if (classes[key]) {
+ result[key] = `${MENU_CLASSES[key]} ${normalizeClassName(classes[key]).join(' ')}`;
+ } else {
+ result[key] = MENU_CLASSES[key];
+ }
+ }
+ return result as MenuClasses;
+}
+
/**
* 创建DOM元素
*/
@@ -123,19 +234,57 @@ export function createElement(tag: string, className?: string, styles?: Record): void {
- Object.entries(styles).forEach(([key, value]) => {
- (element.style as any)[key] = value;
- });
+export function applyStyles(
+ element: HTMLElement,
+ styles: { [K in keyof CSSStyleDeclaration]?: CSSStyleDeclaration[K] }
+): void {
+ for (const key in styles) {
+ const value = styles[key];
+ if (value !== undefined) {
+ element.style[key] = value;
+ }
+ }
}
/**
* 创建图标元素
*/
-export function createIcon(iconName: string): HTMLElement {
+export function createIcon(
+ icon: MenuItemIcon,
+ menuItem?: MenuItem,
+ iconStyles: MenuStyleDef = MENU_STYLES.menuItemIcon
+): HTMLElement {
+ // 自定义渲染
+ if (typeof icon === 'function') {
+ const element = icon(menuItem!);
+ applyStyles(element, iconStyles);
+ return element;
+ }
+
+ // SVG 配置
+ if (typeof icon === 'object' && 'svg' in icon) {
+ const svgConfig = icon as SvgIconConfig;
+ const iconElement = createElement('span');
+ const safeSvg = sanitizeSvg(svgConfig.svg);
+ if (safeSvg) {
+ iconElement.innerHTML = safeSvg;
+ const svgEl = iconElement.querySelector('svg');
+ if (svgEl) {
+ if (svgConfig.width !== undefined) {
+ svgEl.setAttribute('width', String(svgConfig.width));
+ }
+ if (svgConfig.height !== undefined) {
+ svgEl.setAttribute('height', String(svgConfig.height));
+ }
+ }
+ }
+ applyStyles(iconElement, iconStyles);
+ return iconElement;
+ }
+
+ const iconName = icon as string;
const iconElement = createElement('span');
- // 根据图标名称设置不同的内容
switch (iconName) {
case 'copy':
iconElement.innerHTML = '📋';
@@ -186,7 +335,7 @@ export function createIcon(iconName: string): HTMLElement {
iconElement.innerHTML = '•';
}
- applyStyles(iconElement, MENU_STYLES.menuItemIcon);
+ applyStyles(iconElement, iconStyles);
return iconElement;
}
@@ -196,24 +345,25 @@ export function createIcon(iconName: string): HTMLElement {
export function createNumberInputItem(
label: string,
defaultValue: number = 1,
- iconName: string,
- callback: (value: number) => void
+ icon: MenuItemIcon | undefined,
+ callback: (value: number) => void,
+ styles: MenuStyles = MENU_STYLES
): HTMLElement {
// 创建容器
const container = createElement('div');
- applyStyles(container, MENU_STYLES.inputContainer);
+ applyStyles(container, styles.inputContainer);
// 创建左侧图标容器
// 添加图标
- if (iconName) {
- const icon = createIcon(iconName);
- container.appendChild(icon);
+ if (icon) {
+ const iconEl = createIcon(icon, undefined, styles.menuItemIcon);
+ container.appendChild(iconEl);
}
// 创建标签
const labelElement = createElement('label');
labelElement.textContent = label;
- applyStyles(labelElement, MENU_STYLES.inputLabel);
+ applyStyles(labelElement, styles.inputLabel);
container.appendChild(labelElement);
// 创建输入框
@@ -221,7 +371,7 @@ export function createNumberInputItem(
input.type = 'number';
input.min = '1';
input.value = defaultValue.toString();
- applyStyles(input, MENU_STYLES.inputField);
+ applyStyles(input, styles.inputField);
container.appendChild(input);
// 创建包装容器
const wrapper = createElement('div');
@@ -229,3 +379,29 @@ export function createNumberInputItem(
return wrapper;
}
+
+/**
+ * 格式化用户传入的类名配置
+ */
+export function normalizeItemClassNameConfig(classNames?: MenuItemClassConfig) {
+ const normalized: MenuItemClassConfig = {};
+ if (classNames) {
+ Object.keys(classNames).forEach(item => {
+ const className = classNames[item as keyof MenuItemClassConfig];
+ if (className) {
+ normalized[item as keyof MenuItemClassConfig] = normalizeClassName(className);
+ }
+ });
+ }
+ return normalized;
+}
+
+/**
+ * 格式化类名,将类名统一转为数组
+ */
+export function normalizeClassName(className: ClassName): string[] {
+ if (!className) {
+ return [];
+ }
+ return isArray(className) ? className : className.split(' ').filter(Boolean);
+}
diff --git a/packages/vtable-plugins/src/contextmenu/types.ts b/packages/vtable-plugins/src/contextmenu/types.ts
index 68454f3a32..640fba78b8 100644
--- a/packages/vtable-plugins/src/contextmenu/types.ts
+++ b/packages/vtable-plugins/src/contextmenu/types.ts
@@ -30,12 +30,42 @@ export enum MenuKey {
SORT = 'sort'
}
+/** SVG 图标配置 */
+export interface SvgIconConfig {
+ svg: string;
+ width?: number;
+ height?: number;
+}
+
+export type ClassName = string | string[];
+
+export interface MenuItemClassConfig {
+ item?: ClassName;
+ itemDisabled?: ClassName;
+ leftContainer?: ClassName;
+ icon?: ClassName;
+ text?: ClassName;
+ input?: ClassName;
+ rightContainer?: ClassName;
+ arrow?: ClassName;
+ shortcut?: ClassName;
+}
+
+/** 自定义图标渲染函数,接收菜单项信息返回一个 HTMLElement */
+export type IconRenderFunction = (menuItem: MenuItem) => HTMLElement;
+
+/** 菜单项图标:支持内置名称字符串 / SVG 配置 / 渲染函数 */
+export type MenuItemIcon = string | SvgIconConfig | IconRenderFunction;
+
export interface MenuItem {
text: string;
menuKey: MenuKey | string;
disabled?: boolean;
shortcut?: string;
iconName?: string;
+ /** 图标:内置名称字符串 / SVG 配置 / 自定义渲染函数 */
+ customIcon?: MenuItemIcon;
+ customClassName?: MenuItemClassConfig;
iconPlaceholder?: boolean; //如果没有iconName时 是否显示占位图标位置 让他与其他有图标的item对齐
inputDefaultValue?: number;
children?: (MenuItem | string)[];