| |
| |
| |
| |
| 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) { |
| |
| 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`); |
|
|
| |
| 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() { |
| |
| 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 }; |
| } |
|
|
|
|