InfoRadar / client /src /ts /ui /itemMenu.ts
dqy08's picture
多语言国际化支持
5b3181a
/**
* 菜单按钮组件
* 为每个demo项或文件夹项提供操作菜单(移动、重命名、删除)
*/
import * as d3 from 'd3';
import { isMobileDevice } from '../utils/responsive';
import { tr } from '../lang/i18n-lite';
// 全局菜单管理器:确保同时只有一个菜单打开
let currentOpenMenu: ItemMenu | null = null;
export type ItemMenu = {
show: () => void;
hide: () => void;
remove: () => void;
showButton: () => void;
hideButton: () => void;
};
export function createItemMenu(
item: { type: 'folder' | 'file', name: string, path: string },
onMove: () => void,
onRename: () => void,
onDelete: () => void,
buttonNode?: HTMLElement | null // 可选的按钮节点,用于定位菜单
): ItemMenu {
let menuVisible = false;
let menuElement: d3.Selection<HTMLDivElement, any, any, any> | null = null;
let actualButtonNode: HTMLElement | null = buttonNode || null;
// 检测是否为移动端
const isMobile = isMobileDevice();
// 创建菜单按钮(汉堡图标)
const menuButton = d3.create('button')
.attr('class', 'demo-item-menu-btn')
.html('☰')
.attr('title', tr('More actions'))
.style('background', 'transparent')
.style('border', 'none')
.style('color', 'var(--text-color)')
.style('cursor', 'pointer')
.style('font-size', '16px')
.style('line-height', '1')
.style('padding', '0 4px')
.style('opacity', isMobile ? '0.4' : '0')
.style('transition', 'opacity 0.2s')
.style('flex-shrink', '0')
.style('margin-left', '1px')
.on('mouseenter', function() {
if (!menuVisible) {
d3.select(this).style('opacity', '1');
}
})
.on('mouseleave', function() {
if (!menuVisible) {
// 不在这里隐藏按钮,由demo-item的mouseleave事件控制
d3.select(this).style('opacity', '0.6');
}
})
.on('click', function(event) {
event.stopPropagation();
// 更新实际按钮节点
actualButtonNode = this as HTMLElement;
if (menuVisible) {
hide();
} else {
show();
}
});
// 创建菜单实例对象(用于全局管理)
const menuInstance: ItemMenu = {
show: () => {},
hide: () => {},
remove: () => {},
showButton: () => {},
hideButton: () => {}
};
const show = () => {
if (menuVisible) return;
// 关闭之前打开的菜单
if (currentOpenMenu && currentOpenMenu !== menuInstance) {
currentOpenMenu.hide();
}
currentOpenMenu = menuInstance;
menuVisible = true;
if (actualButtonNode) {
d3.select(actualButtonNode).style('opacity', '1');
} else {
menuButton.style('opacity', '1');
}
// 使用实际按钮节点进行定位
const button = actualButtonNode || menuButton.node();
if (!button) return;
const rect = button.getBoundingClientRect();
const menuWidth = 120; // 菜单宽度
const spacing = 4; // 间距
// 计算菜单位置:显示在按钮下方,右对齐到按钮
let left = rect.right - menuWidth;
let top = rect.bottom + spacing;
// 确保菜单不超出视口边界
if (left < 0) left = rect.left;
if (left + menuWidth > window.innerWidth) left = window.innerWidth - menuWidth - spacing;
if (top < 0) top = spacing;
menuElement = d3.select('body').append('div')
.attr('class', 'demo-item-menu')
.style('position', 'fixed')
.style('background', 'var(--bg-color, #fff)')
.style('border', '1px solid var(--border-color, #ddd)')
.style('border-radius', '4px')
.style('box-shadow', '0 2px 8px rgba(0,0,0,0.15)')
.style('z-index', '1000')
.style('min-width', `${menuWidth}px`)
.style('padding', '4px 0')
.style('left', `${left}px`)
.style('top', `${top}px`);
// 菜单项(简约风格 SVG 图标)
const menuItems = [
{
icon: '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M2 6h8M7 3l3 3-3 3"/></svg>',
label: tr('Move to...'),
action: onMove
},
{
icon: '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M8 2l2 2-6 6H2V8l6-6z"/></svg>',
label: tr('Rename'),
action: onRename
},
{
icon: '<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3l6 6M9 3l-6 6"/></svg>',
label: tr('Delete'),
action: onDelete
}
];
const menuItemSelection = menuElement.selectAll('.menu-item')
.data(menuItems)
.join('div')
.attr('class', 'menu-item')
.style('padding', '6px 16px')
.style('cursor', 'pointer')
.style('color', 'var(--text-color)')
.style('font-size', '13px')
.style('transition', 'background 0.2s')
.style('display', 'flex')
.style('align-items', 'center')
.style('gap', '8px')
.on('mouseenter', function() {
d3.select(this).style('background', 'var(--hover-bg-color, #f0f0f0)');
})
.on('mouseleave', function() {
d3.select(this).style('background', 'transparent');
})
.on('click', function(event, d) {
event.stopPropagation();
hide();
d.action();
});
// 添加图标
menuItemSelection.each(function(d) {
const container = d3.select(this);
container.append('span')
.style('display', 'inline-flex')
.style('align-items', 'center')
.style('opacity', '0.7')
.html(d.icon);
container.append('span')
.text(d.label);
});
// 点击外部关闭菜单
const clickHandler = (event: MouseEvent) => {
const target = event.target as Node;
if (menuElement && menuElement.node() && !menuElement.node()?.contains(target) &&
button && !button.contains(target)) {
hide();
document.removeEventListener('click', clickHandler);
}
};
// 延迟添加事件监听,避免立即触发
setTimeout(() => {
document.addEventListener('click', clickHandler);
}, 0);
};
const hide = () => {
if (!menuVisible) return;
menuVisible = false;
if (actualButtonNode) {
d3.select(actualButtonNode).style('opacity', '0.6');
} else {
menuButton.style('opacity', '0.6');
}
if (menuElement) {
menuElement.remove();
menuElement = null;
}
// 清除全局引用
if (currentOpenMenu === menuInstance) {
currentOpenMenu = null;
}
};
const remove = () => {
hide();
menuButton.remove();
};
const showButton = () => {
if (menuVisible) return; // 如果菜单已打开,不改变按钮透明度
if (actualButtonNode) {
d3.select(actualButtonNode).style('opacity', '0.6');
} else {
menuButton.style('opacity', '0.6');
}
};
const hideButton = () => {
if (menuVisible) return; // 如果菜单已打开,保持按钮可见
// 移动端不隐藏按钮
if (isMobile) return;
if (actualButtonNode) {
d3.select(actualButtonNode).style('opacity', '0');
} else {
menuButton.style('opacity', '0');
}
};
// 更新菜单实例对象
menuInstance.show = show;
menuInstance.hide = hide;
menuInstance.remove = remove;
menuInstance.showButton = showButton;
menuInstance.hideButton = hideButton;
return menuInstance;
}
// 导出菜单按钮的创建函数,供外部使用
export function createMenuButton(
item: { type: 'folder' | 'file', name: string, path: string },
onMove: () => void,
onRename: () => void,
onDelete: () => void
): { button: d3.Selection<HTMLButtonElement, any, any, any>, menu: ItemMenu } {
// 检测是否为移动端
const isMobile = isMobileDevice();
// 创建菜单按钮(汉堡图标)
const menuButton = d3.create('button')
.attr('class', 'demo-item-menu-btn')
.html('☰')
.attr('title', tr('More actions'))
.style('background', 'transparent')
.style('border', 'none')
.style('color', 'var(--text-color)')
.style('cursor', 'pointer')
.style('font-size', '16px')
.style('line-height', '1')
.style('padding', '0 4px')
.style('opacity', isMobile ? '0.4' : '0')
.style('transition', 'opacity 0.2s')
.style('flex-shrink', '0')
.style('margin-left', '1px')
.on('mouseenter', function() {
d3.select(this).style('opacity', '1');
})
.on('mouseleave', function() {
// 不在这里隐藏按钮,由demo-item的mouseleave事件控制
d3.select(this).style('opacity', '0.6');
});
// 创建菜单,传递按钮节点
const menu = createItemMenu(item, onMove, onRename, onDelete, menuButton.node());
// 设置按钮点击事件
menuButton.on('click', function(event) {
event.stopPropagation();
menu.show();
});
return { button: menuButton, menu };
}