c329c438d9
Project CI / AI game creator shell Rust shard 4/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust smoke (pull_request) Has been cancelled
Project CI / AI game creator shell Rust crates (pull_request) Has been cancelled
Project CI / Backend tests (pull_request) Has been cancelled
Project CI / Native shell tests (pull_request) Has been cancelled
Project CI / Frontend tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 3/4 (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 2/4 (pull_request) Has been cancelled
Project CI / Repository checks (pull_request) Has been cancelled
Project CI / AI game creator shell web tests (pull_request) Has been cancelled
Project CI / AI game creator shell Rust shard 1/4 (pull_request) Has been cancelled
修复活动回合空快照引用及停用后晚到请求覆盖问题 对齐共享卡片角标与窗口发布次数回归断言 同步原生HTTP权限检查与图集显式切片测试契约 整理菜单组件导入顺序并记录本地验证范围
238 lines
7.5 KiB
TypeScript
238 lines
7.5 KiB
TypeScript
import {
|
|
Children,
|
|
cloneElement,
|
|
Fragment,
|
|
isValidElement,
|
|
type ReactNode,
|
|
useEffect,
|
|
useId,
|
|
useLayoutEffect,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import { createPortal } from 'react-dom';
|
|
|
|
type ActionProps = { children?: ReactNode; 'aria-hidden'?: boolean | 'true' };
|
|
|
|
function flatten(nodes: ReactNode, prefix = ''): ReactNode[] {
|
|
return Children.toArray(nodes).flatMap((node, index) =>
|
|
isValidElement<ActionProps>(node) && node.type === Fragment
|
|
? flatten(node.props.children, `${prefix}${index}.`)
|
|
: [
|
|
isValidElement(node)
|
|
? cloneElement(node, { key: `${prefix}${index}` })
|
|
: node,
|
|
],
|
|
);
|
|
}
|
|
|
|
function isDivider(node: ReactNode) {
|
|
return (
|
|
isValidElement<ActionProps>(node) &&
|
|
(node.props['aria-hidden'] === true || node.props['aria-hidden'] === 'true')
|
|
);
|
|
}
|
|
|
|
/** 只负责展示收纳;动作权限、禁用与执行仍由调用方提供。 */
|
|
export function OverflowActions({
|
|
children,
|
|
maxVisible = Infinity,
|
|
label = '更多',
|
|
}: {
|
|
children: ReactNode;
|
|
maxVisible?: number;
|
|
label?: string;
|
|
}) {
|
|
const [open, setOpen] = useState(false);
|
|
const [position, setPosition] = useState({ left: 8, top: 8, maxHeight: 320 });
|
|
const trigger = useRef<HTMLButtonElement>(null);
|
|
const panel = useRef<HTMLDivElement>(null);
|
|
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const id = useId();
|
|
const limit = Number.isFinite(maxVisible)
|
|
? Math.max(0, Math.floor(maxVisible))
|
|
: Infinity;
|
|
const nodes = flatten(children);
|
|
let count = 0;
|
|
const split = nodes.findIndex((node) => !isDivider(node) && ++count > limit);
|
|
const primary = split < 0 ? nodes : nodes.slice(0, split);
|
|
while (primary.length && isDivider(primary[primary.length - 1]))
|
|
primary.pop();
|
|
const overflow =
|
|
split < 0 ? [] : nodes.slice(split).filter((node) => !isDivider(node));
|
|
useEffect(() => {
|
|
if (overflow.length === 0) setOpen(false);
|
|
}, [overflow.length]);
|
|
const cancelClose = () => {
|
|
if (timer.current !== null) clearTimeout(timer.current);
|
|
timer.current = null;
|
|
};
|
|
const show = () => {
|
|
cancelClose();
|
|
setOpen(true);
|
|
};
|
|
const scheduleClose = () => {
|
|
cancelClose();
|
|
timer.current = setTimeout(() => {
|
|
if (!panel.current?.contains(document.activeElement)) setOpen(false);
|
|
}, 150);
|
|
};
|
|
useEffect(
|
|
() => () => {
|
|
if (timer.current !== null) clearTimeout(timer.current);
|
|
},
|
|
[],
|
|
);
|
|
useLayoutEffect(() => {
|
|
if (!open || !overflow.length) return;
|
|
const update = () => {
|
|
const anchor = trigger.current?.getBoundingClientRect();
|
|
if (!anchor) return;
|
|
const width = panel.current?.getBoundingClientRect().width ?? 200;
|
|
const height = panel.current?.scrollHeight ?? 320;
|
|
const below = window.innerHeight - anchor.bottom - 12;
|
|
const above = anchor.top - 12;
|
|
// 优先上展,留出卡片预览;窗口顶边空间不足时才向下避让。
|
|
const down = above < 80 && below > above;
|
|
const maxHeight = Math.max(40, Math.min(360, down ? below : above));
|
|
setPosition({
|
|
left: Math.max(
|
|
8,
|
|
Math.min(anchor.right - width, window.innerWidth - width - 8),
|
|
),
|
|
top: down
|
|
? anchor.bottom + 4
|
|
: Math.max(8, anchor.top - Math.min(height, maxHeight) - 4),
|
|
maxHeight,
|
|
});
|
|
};
|
|
update();
|
|
window.addEventListener('resize', update);
|
|
window.addEventListener('scroll', update, true);
|
|
return () => {
|
|
window.removeEventListener('resize', update);
|
|
window.removeEventListener('scroll', update, true);
|
|
};
|
|
}, [open, overflow.length, children]);
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const outside = (event: PointerEvent) => {
|
|
if (
|
|
event.target instanceof Node &&
|
|
!trigger.current?.contains(event.target) &&
|
|
!panel.current?.contains(event.target)
|
|
) {
|
|
setOpen(false);
|
|
}
|
|
};
|
|
const escape = (event: KeyboardEvent) => {
|
|
if (event.key !== 'Escape' || event.defaultPrevented) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setOpen(false);
|
|
trigger.current?.focus();
|
|
};
|
|
document.addEventListener('pointerdown', outside);
|
|
document.addEventListener('keydown', escape);
|
|
return () => {
|
|
document.removeEventListener('pointerdown', outside);
|
|
document.removeEventListener('keydown', escape);
|
|
};
|
|
}, [open]);
|
|
if (!overflow.length) return <>{children}</>;
|
|
return (
|
|
<>
|
|
{primary}
|
|
<button
|
|
ref={trigger}
|
|
type="button"
|
|
className="shared-overflow-trigger image-canvas-editor__floating-toolbar-text-button"
|
|
aria-label={label}
|
|
aria-expanded={open}
|
|
aria-controls={open ? id : undefined}
|
|
onMouseEnter={show}
|
|
onMouseLeave={scheduleClose}
|
|
onClick={show}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'ArrowDown') {
|
|
event.preventDefault();
|
|
show();
|
|
requestAnimationFrame(() =>
|
|
panel.current
|
|
?.querySelector<HTMLButtonElement>('button:not(:disabled)')
|
|
?.focus(),
|
|
);
|
|
}
|
|
if (event.key === 'Escape') {
|
|
event.stopPropagation();
|
|
setOpen(false);
|
|
}
|
|
}}
|
|
>
|
|
{label} <span aria-hidden="true">▴</span>
|
|
</button>
|
|
{open
|
|
? createPortal(
|
|
<div
|
|
ref={panel}
|
|
id={id}
|
|
role="group"
|
|
aria-label={`${label}操作`}
|
|
className="shared-overflow-panel image-canvas-editor__portal-menu"
|
|
style={position}
|
|
onMouseEnter={cancelClose}
|
|
onMouseLeave={scheduleClose}
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
onClick={(event) => {
|
|
event.stopPropagation();
|
|
if ((event.target as Element).closest('button:not(:disabled)'))
|
|
setOpen(false);
|
|
}}
|
|
onBlur={(event) => {
|
|
if (
|
|
!event.currentTarget.contains(event.relatedTarget) &&
|
|
event.relatedTarget !== trigger.current
|
|
)
|
|
setOpen(false);
|
|
}}
|
|
onKeyDown={(event) => {
|
|
if (event.key === 'Escape') {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
setOpen(false);
|
|
trigger.current?.focus();
|
|
}
|
|
if (
|
|
['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)
|
|
) {
|
|
event.preventDefault();
|
|
const buttons = Array.from(
|
|
event.currentTarget.querySelectorAll<HTMLButtonElement>(
|
|
'button:not(:disabled)',
|
|
),
|
|
);
|
|
const index = buttons.indexOf(
|
|
document.activeElement as HTMLButtonElement,
|
|
);
|
|
const next =
|
|
event.key === 'Home'
|
|
? 0
|
|
: event.key === 'End'
|
|
? buttons.length - 1
|
|
: (index +
|
|
(event.key === 'ArrowDown' ? 1 : -1) +
|
|
buttons.length) %
|
|
buttons.length;
|
|
buttons[next]?.focus();
|
|
}
|
|
}}
|
|
>
|
|
{overflow}
|
|
</div>,
|
|
document.body,
|
|
)
|
|
: null}
|
|
</>
|
|
);
|
|
}
|