feat(web): add spaces, grouped channels and file chat
This commit is contained in:
1 parent
c7d8ef15bc
commit
f6cfb12716
111 files changed
+4371
-506
No files matched your search
@@ -0,0 +1,40 @@
|
||||
.menu {
|
||||
position: fixed;
|
||||
z-index: 80;
|
||||
width: 210px;
|
||||
max-width: calc(100vw - 16px);
|
||||
max-height: calc(100dvh - 16px);
|
||||
overflow: auto;
|
||||
padding: var(--space-1);
|
||||
border: 1px solid var(--line-strong);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
box-shadow: var(--shadow-pop);
|
||||
}
|
||||
|
||||
.menu button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: var(--space-2);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.menu button:hover,
|
||||
.menu button:focus-visible {
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
|
||||
.menu button:disabled {
|
||||
color: var(--text-dim);
|
||||
cursor: default;
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { fitFloatingPoint } from '@/lib/explorer';
|
||||
import { Icon, type IconName } from './Icon';
|
||||
import styles from './ContextMenu.module.css';
|
||||
|
||||
export interface MenuAction { label: string; icon?: IconName; disabled?: boolean; action: () => void }
|
||||
export function ContextMenu({ point, actions, onClose }: { point: { x: number; y: number }; actions: MenuAction[]; onClose: () => void }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [position, setPosition] = useState(point);
|
||||
useLayoutEffect(() => {
|
||||
const node = ref.current; if (!node) return;
|
||||
const previous = document.activeElement as HTMLElement | null;
|
||||
setPosition(fitFloatingPoint(point.x, point.y, node.offsetWidth, node.offsetHeight, innerWidth, innerHeight));
|
||||
node.querySelector<HTMLButtonElement>('button:not(:disabled)')?.focus();
|
||||
return () => { if (previous?.isConnected && (document.activeElement === document.body || node.contains(document.activeElement))) previous.focus(); };
|
||||
}, [point.x, point.y]);
|
||||
useEffect(() => {
|
||||
const outside = (event: PointerEvent) => { if (!ref.current?.contains(event.target as Node)) onClose(); };
|
||||
const close = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); onClose(); } };
|
||||
document.addEventListener('pointerdown', outside); document.addEventListener('keydown', close);
|
||||
window.addEventListener('resize', onClose); window.addEventListener('blur', onClose);
|
||||
return () => { document.removeEventListener('pointerdown', outside); document.removeEventListener('keydown', close);
|
||||
window.removeEventListener('resize', onClose); window.removeEventListener('blur', onClose); };
|
||||
}, [onClose]);
|
||||
return createPortal(<div ref={ref} className={styles.menu} role="menu" aria-label="文件操作菜单" style={{ left: position.x, top: position.y }} onContextMenu={(event) => event.preventDefault()}
|
||||
onKeyDown={(event) => {
|
||||
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
|
||||
event.preventDefault(); const buttons = [...(ref.current?.querySelectorAll<HTMLButtonElement>('button:not(:disabled)') ?? [])];
|
||||
const current = buttons.indexOf(document.activeElement as HTMLButtonElement);
|
||||
const index = event.key === 'Home' ? 0 : event.key === 'End' ? buttons.length - 1 : (current + (event.key === 'ArrowDown' ? 1 : -1) + buttons.length) % buttons.length;
|
||||
buttons[index]?.focus();
|
||||
}}>{actions.map((item) => <button type="button" key={item.label} role="menuitem" disabled={item.disabled} onClick={() => { onClose(); item.action(); }}>
|
||||
{item.icon ? <Icon name={item.icon} size={16} /> : null}<span>{item.label}</span>
|
||||
</button>)}</div>, document.body);
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
const PATHS = {
|
||||
folder: 'M3 7V5h6l2 2h10v13H3z',
|
||||
lock: 'M8 11V8a4 4 0 018 0v3 M6 11h12v9H6z',
|
||||
share: 'M12 4v11 M8 8l4-4 4 4 M5 14v6h14v-6',
|
||||
copy: 'M9 9h10v11H9z M5 15V4h10',
|
||||
@@ -30,9 +31,11 @@ const PATHS = {
|
||||
paste: 'M9 4h6v3H9z M7 5H5v16h14V5h-2',
|
||||
history: 'M4 12a8 8 0 108-8 M4 4v5h5 M12 8v4l3 2',
|
||||
chevronRight: 'M9 6l6 6-6 6',
|
||||
chevronDown: 'M6 9l6 6 6-6',
|
||||
chevronLeft: 'M15 6l-6 6 6 6',
|
||||
panelRight: 'M4 5h16v14H4z M15 5v14',
|
||||
bell: 'M6 16V11a6 6 0 0112 0v5l2 2H4z M10 20h4',
|
||||
settings: 'M12 8a4 4 0 100 8 4 4 0 000-8z M19.4 15a1.7 1.7 0 00.34 1.87l.06.06-1.7 2.94-.08-.02a1.7 1.7 0 00-1.72.58l-.05.07h-3.4l-.04-.08a1.7 1.7 0 00-1.7-.6l-.09.03-1.7-2.94.06-.07A1.7 1.7 0 009.7 15l-.08-.05v-3.4l.08-.04a1.7 1.7 0 00.6-1.7l-.03-.09 2.94-1.7.07.06a1.7 1.7 0 001.87.34l.06-.04h3.4l.04.08a1.7 1.7 0 001.7.6l.09-.03 1.7 2.94-.06.07A1.7 1.7 0 0021.8 14l.08.05v3.4z',
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof PATHS;
|
||||
|
||||
Reference in new issue
Block a user