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() {
  • 支持输入框数量
  • 长菜单可滚动
  • + +

    菜单自定义图标和样式演示

    + `; 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)[];