diff --git a/common/changes/@visactor/vtable-plugins/fix-issue-5215-context-menu-canvas_2026-07-28-00-00.json b/common/changes/@visactor/vtable-plugins/fix-issue-5215-context-menu-canvas_2026-07-28-00-00.json new file mode 100644 index 0000000000..54808331df --- /dev/null +++ b/common/changes/@visactor/vtable-plugins/fix-issue-5215-context-menu-canvas_2026-07-28-00-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "fix: support context menu plugin on canvas blank area", + "type": "patch", + "packageName": "@visactor/vtable-plugins" + } + ], + "packageName": "@visactor/vtable-plugins", + "email": "github@visactor.io" +} diff --git a/common/changes/@visactor/vtable/fix-issue-5215-plugin-init_2026-07-28-00-00.json b/common/changes/@visactor/vtable/fix-issue-5215-plugin-init_2026-07-28-00-00.json new file mode 100644 index 0000000000..674f58f075 --- /dev/null +++ b/common/changes/@visactor/vtable/fix-issue-5215-plugin-init_2026-07-28-00-00.json @@ -0,0 +1,11 @@ +{ + "changes": [ + { + "comment": "fix: allow plugins to initialize before first render", + "type": "patch", + "packageName": "@visactor/vtable" + } + ], + "packageName": "@visactor/vtable", + "email": "github@visactor.io" +} diff --git a/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts b/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts index 1c777b352a..b9579f9562 100644 --- a/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts +++ b/packages/vtable-plugins/__tests__/context-menu/handle-menu-helper.test.ts @@ -1,6 +1,7 @@ // @ts-nocheck import { ListTable } from '@visactor/vtable'; import { createDiv } from '../../../vtable/__tests__/dom'; +import { ContextMenuPlugin } from '../../src/context-menu'; import { MenuHandler } from '../../src/contextmenu/handle-menu-helper'; import { TableSeriesNumber } from '../../src/table-series-number'; @@ -63,3 +64,130 @@ describe('Context menu row deletion', () => { expect(table.records.map(record => record.id)).toEqual([0, 4]); }); }); + +describe('Context menu canvas option', () => { + let table: ListTable; + + afterEach(() => { + table?.release(); + document.body.innerHTML = ''; + }); + + test('ContextMenuPlugin enables canvas context menu through contextMenuWorkOnlyCell', () => { + const container = createDiv(); + const contextMenuPlugin = new ContextMenuPlugin({ + contextMenuWorkOnlyCell: false + }); + + table = new ListTable({ + container, + columns: [{ field: 'id', title: 'ID' }], + records: [{ id: 1 }], + plugins: [contextMenuPlugin] + }); + + expect(table.options.menu.contextMenuWorkOnlyCell).toBe(false); + expect(contextMenuPlugin.runTime).toContain(ListTable.EVENT_TYPE.CONTEXTMENU_CANVAS); + }); + + test('ContextMenuPlugin shows canvasMenuItems on CONTEXTMENU_CANVAS event', () => { + const container = createDiv(); + const contextMenuPlugin = new ContextMenuPlugin({ + contextMenuWorkOnlyCell: false, + bodyCellMenuItems: [{ text: 'Body Item', menuKey: 'body_item' }], + canvasMenuItems: [{ text: 'Canvas Item', menuKey: 'canvas_item' }] + }); + const showMenu = jest.spyOn(contextMenuPlugin['menuManager'], 'showMenu'); + const preventDefault = jest.fn(); + + table = new ListTable({ + container, + columns: [{ field: 'id', title: 'ID' }], + records: [{ id: 1 }], + plugins: [contextMenuPlugin] + }); + + contextMenuPlugin.run( + { + col: -1, + row: -1, + event: { + clientX: 10, + clientY: 20, + preventDefault + } + }, + ListTable.EVENT_TYPE.CONTEXTMENU_CANVAS, + table + ); + + expect(preventDefault).toHaveBeenCalled(); + expect(showMenu).toHaveBeenCalledWith( + [{ text: 'Canvas Item', menuKey: 'canvas_item' }], + 10, + 20, + { + rowIndex: -1, + colIndex: -1 + }, + table + ); + }); + + test('ContextMenuPlugin init uses new options when added through updateOption', () => { + const container = createDiv(); + const contextMenuPlugin = new ContextMenuPlugin({ + contextMenuWorkOnlyCell: false + }); + + table = new ListTable({ + container, + columns: [{ field: 'id', title: 'ID' }], + records: [{ id: 1 }] + }); + + table.updateOption({ + container, + columns: [{ field: 'id', title: 'ID' }], + records: [{ id: 1 }], + plugins: [contextMenuPlugin] + }); + + expect(table.options.menu.contextMenuWorkOnlyCell).toBe(false); + }); + + test('PluginManager only releases plugins removed through updateOption', () => { + const container = createDiv(); + const removedPlugin = { + id: 'removed-plugin', + name: 'removed-plugin', + runTime: [], + run: jest.fn(), + release: jest.fn() + }; + const keptPlugin = { + id: 'kept-plugin', + name: 'kept-plugin', + runTime: [], + run: jest.fn(), + release: jest.fn() + }; + + table = new ListTable({ + container, + columns: [{ field: 'id', title: 'ID' }], + records: [{ id: 1 }], + plugins: [removedPlugin, keptPlugin] + }); + + table.updateOption({ + container, + columns: [{ field: 'id', title: 'ID' }], + records: [{ id: 1 }], + plugins: [keptPlugin] + }); + + expect(removedPlugin.release).toHaveBeenCalledTimes(1); + expect(keptPlugin.release).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/vtable-plugins/demo/context-menu/issue-5215-context-menu-canvas.ts b/packages/vtable-plugins/demo/context-menu/issue-5215-context-menu-canvas.ts new file mode 100644 index 0000000000..30e960df6a --- /dev/null +++ b/packages/vtable-plugins/demo/context-menu/issue-5215-context-menu-canvas.ts @@ -0,0 +1,78 @@ +import * as VTable from '@visactor/vtable'; +import { ContextMenuPlugin } from '../../src/context-menu'; + +const CONTAINER_ID = 'vTable'; + +export function createTableInstance() { + const records = [ + { id: 1, name: 'alpha', value: 12 }, + { id: 2, name: 'beta', value: 34 } + ]; + + const plugin = new ContextMenuPlugin({ + contextMenuWorkOnlyCell: false, + bodyCellMenuItems: [ + { text: 'Copy from plugin', menuKey: 'copy' }, + '---', + { text: 'Body cell item', menuKey: 'body_cell_item' } + ], + canvasMenuItems: [ + { text: 'Canvas blank area', menuKey: 'canvas_blank_area' }, + { text: 'Canvas menu item', menuKey: 'canvas_menu_item' } + ], + beforeShowAdjustMenuItems: (menuItems, _table, col, row) => { + if (col === -1 && row === -1) { + return [ + ...menuItems, + { + text: `Blank canvas: col ${col}, row ${row}`, + menuKey: 'canvas_position' + } + ]; + } + return menuItems.filter(item => item !== '---'); + } + }); + + const option: VTable.ListTableConstructorOptions = { + container: document.getElementById(CONTAINER_ID), + records, + columns: [ + { field: 'id', title: 'ID', width: 100 }, + { field: 'name', title: 'Name', width: 160 }, + { field: 'value', title: 'Value', width: 120 } + ], + defaultRowHeight: 40, + defaultHeaderRowHeight: 40, + widthMode: 'standard', + heightMode: 'standard', + plugins: [plugin] + }; + + const tableInstance = new VTable.ListTable(option); + window.tableInstance = tableInstance; + return tableInstance; +} + +export function createTable() { + const container = document.createElement('div'); + container.id = CONTAINER_ID; + container.style.width = '100%'; + container.style.height = '420px'; + document.body.appendChild(container); + + const info = document.createElement('div'); + info.style.margin = '10px'; + info.style.padding = '10px'; + info.style.border = '1px solid #ddd'; + info.style.borderRadius = '4px'; + info.style.backgroundColor = '#f9f9f9'; + info.innerHTML = ` +
Right-click the blank canvas area outside the table cells. The context menu should show canvas-specific items.
+Right-click body cells to verify cell menu items are still independent from blank canvas menu items.
+ `; + + document.body.insertBefore(info, container); + return createTableInstance(); +} diff --git a/packages/vtable-plugins/demo/menu.ts b/packages/vtable-plugins/demo/menu.ts index 655f873fee..b252dd55ad 100644 --- a/packages/vtable-plugins/demo/menu.ts +++ b/packages/vtable-plugins/demo/menu.ts @@ -152,6 +152,10 @@ export const menus = [ path: 'context-menu', name: 'context-menu' }, + { + path: 'context-menu', + name: 'issue-5215-context-menu-canvas' + }, { path: 'context-menu', name: 'issue-5214-reverse-selected-row-delete' diff --git a/packages/vtable-plugins/src/context-menu.ts b/packages/vtable-plugins/src/context-menu.ts index 6ecc46b94f..47f9cf06bc 100644 --- a/packages/vtable-plugins/src/context-menu.ts +++ b/packages/vtable-plugins/src/context-menu.ts @@ -29,6 +29,10 @@ export interface ContextMenuOptions { headerCellMenuItems?: MenuItemOrSeparator[]; /** 表体菜单项 */ bodyCellMenuItems?: MenuItemOrSeparator[]; + /** 空白画布区域菜单项。仅 contextMenuWorkOnlyCell 为 false 时生效。 */ + canvasMenuItems?: MenuItemOrSeparator[]; + /** 右键菜单是否只工作在单元格上。默认 true;配置 false 时空白画布区域也弹出菜单。 */ + contextMenuWorkOnlyCell?: boolean; /** 自定义菜单样式 */ customMenuAttributions?: MenuAttributions; /** 菜单点击回调。如果设置是函数,则忽略内部默认的菜单项处理逻辑。如果这里配置的是个对象(对象的key为menuKey),则有匹配的menuKey时忽略内部默认的菜单项处理逻辑, @@ -56,7 +60,7 @@ export type MenuClickCallback = (args: MenuClickEventArgs, table: ListTable) => export class ContextMenuPlugin implements pluginsDefinition.IVTablePlugin { id = `context-menu`; name = 'Context Menu'; - runTime = [TABLE_EVENT_TYPE.CONTEXTMENU_CELL, TABLE_EVENT_TYPE.PLUGIN_EVENT]; + runTime = [TABLE_EVENT_TYPE.CONTEXTMENU_CELL, TABLE_EVENT_TYPE.CONTEXTMENU_CANVAS, TABLE_EVENT_TYPE.PLUGIN_EVENT]; pluginOptions: ContextMenuOptions; table: ListTable; /** 菜单管理器 */ @@ -156,6 +160,22 @@ export class ContextMenuPlugin implements pluginsDefinition.IVTablePlugin { } }; + /** + * 处理空白画布右键菜单事件 + */ + private handleContextMenuCanvas = (eventArgs: any, table: BaseTableAPI): void => { + let menuItems = this.pluginOptions.canvasMenuItems || []; + const { col = -1, row = -1 } = eventArgs; + + if (this.pluginOptions.beforeShowAdjustMenuItems) { + menuItems = this.pluginOptions.beforeShowAdjustMenuItems(menuItems, table as ListTable, col, row); + } + + if (menuItems.length > 0) { + this.showContextMenu(menuItems, eventArgs.event.clientX, eventArgs.event.clientY, col, row); + } + }; + /** * 处理插件事件 */ @@ -191,6 +211,15 @@ export class ContextMenuPlugin implements pluginsDefinition.IVTablePlugin { /** * 运行插件 */ + init(_table: BaseTableAPI, options: BaseTableAPI['options']) { + if (this.pluginOptions.contextMenuWorkOnlyCell === false) { + options.menu = { + ...options.menu, + contextMenuWorkOnlyCell: false + }; + } + } + run(...args: any[]) { const eventArgs = args[0]; const runTime = args[1]; @@ -203,6 +232,8 @@ export class ContextMenuPlugin implements pluginsDefinition.IVTablePlugin { // 根据事件类型处理不同的右键菜单 if (runTime === TABLE_EVENT_TYPE.CONTEXTMENU_CELL) { this.handleContextMenuCell(eventArgs, table); + } else if (runTime === TABLE_EVENT_TYPE.CONTEXTMENU_CANVAS) { + this.handleContextMenuCanvas(eventArgs, table); } else if (runTime === TABLE_EVENT_TYPE.PLUGIN_EVENT) { this.handlePluginEvent(eventArgs, table); } diff --git a/packages/vtable/src/ListTable.ts b/packages/vtable/src/ListTable.ts index 8366170c65..42083858d9 100644 --- a/packages/vtable/src/ListTable.ts +++ b/packages/vtable/src/ListTable.ts @@ -778,7 +778,7 @@ export class ListTable extends BaseTable implements ListTableAPI { ) { const internalProps = this.internalProps; - this.pluginManager.removeOrAddPlugins(options.plugins); + this.pluginManager.removeOrAddPlugins(options.plugins, options); super.updateOption(options, updateConfig); internalProps.frozenColDragHeaderMode = options.dragOrder?.frozenColDragHeaderMode ?? options.frozenColDragHeaderMode; diff --git a/packages/vtable/src/plugins/interface.ts b/packages/vtable/src/plugins/interface.ts index 8119b3dfb7..2f7adb9998 100644 --- a/packages/vtable/src/plugins/interface.ts +++ b/packages/vtable/src/plugins/interface.ts @@ -1,5 +1,5 @@ import type { TableEvents } from '../core/TABLE_EVENT_TYPE'; -import type { BaseTableAPI } from '../ts-types/base-table'; +import type { BaseTableAPI, BaseTableConstructorOptions } from '../ts-types/base-table'; // 插件生命周期接口 export interface IVTablePlugin { @@ -16,7 +16,9 @@ export interface IVTablePlugin { runTime: TableEvents[keyof TableEvents][]; // // 插件依赖 // dependencies?: string[]; - // 初始化方法,在VTable实例创建后、首次渲染前调用 + // 初始化方法,在 VTable 实例创建后、首次渲染前调用 + init?: (table: BaseTableAPI, options: BaseTableConstructorOptions) => void; + // 运行方法,在订阅的表格事件触发时调用 run: (...args: any[]) => void; // 更新方法,当表格数据或配置更新时调用 update?: () => void; diff --git a/packages/vtable/src/plugins/plugin-manager.ts b/packages/vtable/src/plugins/plugin-manager.ts index e984eac6df..e6d197ab85 100644 --- a/packages/vtable/src/plugins/plugin-manager.ts +++ b/packages/vtable/src/plugins/plugin-manager.ts @@ -10,8 +10,9 @@ export class PluginManager { constructor(table: BaseTableAPI, options: BaseTableConstructorOptions) { this.table = table; - options.plugins?.map(plugin => { + options.plugins?.forEach(plugin => { this.register(plugin); + plugin.init?.(this.table, options); this._bindTableEventForPlugin(plugin); }); } @@ -44,14 +45,15 @@ export class PluginManager { } // 移除或添加插件 - removeOrAddPlugins(plugins?: IVTablePlugin[]): void { + removeOrAddPlugins(plugins?: IVTablePlugin[], options: BaseTableConstructorOptions = this.table.options): void { // 先找到plugins中没有,但this.plugins中有,也就是已经被移除的插件 const removedPlugins = Array.from(this.plugins.values()).filter(plugin => !plugins?.some(p => p.id === plugin.id)); removedPlugins.forEach(plugin => { this.pluginEventMap.get(plugin.id)?.forEach(id => { this.table.off(id); }); - this.release(); + this.pluginEventMap.delete(plugin.id); + plugin.release?.(this.table); this.plugins.delete(plugin.id); }); @@ -59,6 +61,7 @@ export class PluginManager { const addedPlugins = plugins?.filter(plugin => !this.plugins.has(plugin.id)); addedPlugins?.forEach(plugin => { this.register(plugin); + plugin.init?.(this.table, options); this._bindTableEventForPlugin(plugin); }); }