perf(*): 创作工作台 - 优化首页按需加载与画布交互并修复历史迁移冲突
This commit is contained in:
@@ -1,13 +1,13 @@
|
||||
import { useEffect, useLayoutEffect, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import { App, Button, Dropdown, Input, Modal, Tag } from "antd";
|
||||
import { App, Button, Dropdown, Input, Modal, Tag, Tooltip } from "antd";
|
||||
import type { MenuProps } from "antd";
|
||||
import { ChevronDown, Ellipsis, Lock, Plus, Unlock } from "lucide-react";
|
||||
import { Check, ChevronDown, Ellipsis, Images, Plus, SlidersHorizontal, UserRound } from "lucide-react";
|
||||
|
||||
import { canvasDockStyle } from "@/lib/canvas/canvas-aceternity-style";
|
||||
import { ASSET_CATEGORY_OPTIONS } from "@/lib/asset-category";
|
||||
import { canvasThemes } from "@/lib/canvas-theme";
|
||||
import { resolveToolbarTools, type ToolContext, type ToolbarHandlers } from "@/lib/canvas/tool-registry";
|
||||
import { subscribeCanvasViewportPreview } from "@/lib/canvas/canvas-live-viewport";
|
||||
import { resolveNodeToolbarPlacement, resolveToolbarTools, type NodeToolbarGroup, type ToolContext, type ToolbarHandlers } from "@/lib/canvas/tool-registry";
|
||||
import { subscribeCanvasGraphicsViewportPreview } from "@/lib/canvas/canvas-live-viewport";
|
||||
import { canvasNodeAssetCategory } from "@/lib/canvas/canvas-node-asset";
|
||||
import { formatBytes, getDataUrlByteSize } from "@/lib/image-utils";
|
||||
import { generationErrorMessage } from "@/lib/generation-error";
|
||||
@@ -62,10 +62,14 @@ type CanvasAssetCategory = NonNullable<NonNullable<CanvasNodeData["metadata"]>["
|
||||
const assetCategoryOptions: Array<{ value: CanvasAssetCategory; label: string }> = ASSET_CATEGORY_OPTIONS;
|
||||
|
||||
type ToolbarTool = {
|
||||
section?: string;
|
||||
description?: string;
|
||||
id: string;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
onClick: () => void;
|
||||
group: NodeToolbarGroup;
|
||||
order: number;
|
||||
active?: boolean;
|
||||
danger?: boolean;
|
||||
disabled?: boolean;
|
||||
@@ -112,6 +116,7 @@ export function CanvasNodeToolbar({
|
||||
workspaceMode = "professional",
|
||||
}: CanvasNodeToolbarProps) {
|
||||
const [openMenuId, setOpenMenuId] = useState<string | null>(null);
|
||||
const [containerWidth, setContainerWidth] = useState(1000);
|
||||
const [anchor, setAnchor] = useState<{ left: number; top: number } | null>(null);
|
||||
const toolbarRef = useRef<HTMLDivElement>(null);
|
||||
const { message } = App.useApp();
|
||||
@@ -135,37 +140,64 @@ export function CanvasNodeToolbar({
|
||||
setAnchor(null);
|
||||
return;
|
||||
}
|
||||
let disposed = false;
|
||||
let queued = false;
|
||||
let containerRect = container.getBoundingClientRect();
|
||||
let toolbarWidth = toolbarRef.current?.offsetWidth || 0;
|
||||
let toolbarHeight = toolbarRef.current?.offsetHeight || 44;
|
||||
const update = () => {
|
||||
const nodeRect = element.getBoundingClientRect();
|
||||
const containerRect = container.getBoundingClientRect();
|
||||
const preferredLeft = nodeRect.left - containerRect.left + nodeRect.width / 2;
|
||||
const toolbarWidth = toolbarRef.current?.offsetWidth || 0;
|
||||
const halfToolbar = toolbarWidth / 2;
|
||||
const canClamp = toolbarWidth > 0 && toolbarWidth <= containerRect.width - 20;
|
||||
const left = canClamp ? Math.min(Math.max(preferredLeft, halfToolbar + 10), containerRect.width - halfToolbar - 10) : preferredLeft;
|
||||
const top = nodeRect.top - containerRect.top - 30;
|
||||
let left = canClamp ? Math.min(Math.max(preferredLeft, halfToolbar + 10), containerRect.width - halfToolbar - 10) : preferredLeft;
|
||||
const above = nodeRect.top - containerRect.top - 30;
|
||||
let top = Math.max(toolbarHeight + 8, Math.min(above, containerRect.height - 8));
|
||||
for (const panel of container.querySelectorAll<HTMLElement>("[data-canvas-node-panel]")) {
|
||||
const panelRect = panel.getBoundingClientRect();
|
||||
const panelLeft = panelRect.left - containerRect.left;
|
||||
const panelRight = panelRect.right - containerRect.left;
|
||||
const panelTop = panelRect.top - containerRect.top;
|
||||
const panelBottom = panelRect.bottom - containerRect.top;
|
||||
if (left + halfToolbar <= panelLeft || left - halfToolbar >= panelRight || top <= panelTop || top - toolbarHeight >= panelBottom) continue;
|
||||
if (panelLeft >= toolbarWidth + 18) left = panelLeft - halfToolbar - 8;
|
||||
else if (containerRect.width - panelRight >= toolbarWidth + 18) left = panelRight + halfToolbar + 8;
|
||||
else if (panelTop >= toolbarHeight + 16) top = panelTop - 8;
|
||||
else if (containerRect.height - panelBottom >= toolbarHeight + 16) top = panelBottom + toolbarHeight + 8;
|
||||
}
|
||||
if (toolbarRef.current) {
|
||||
toolbarRef.current.style.left = `${left}px`;
|
||||
toolbarRef.current.style.top = `${top}px`;
|
||||
toolbarRef.current.style.transform = `translate3d(${left}px, ${top}px, 0)`;
|
||||
return;
|
||||
}
|
||||
setAnchor((current) => current?.left === left && current.top === top ? current : { left, top });
|
||||
};
|
||||
update();
|
||||
const resizeObserver = new ResizeObserver(update);
|
||||
const scheduleUpdate = () => {
|
||||
if (queued || disposed) return;
|
||||
queued = true;
|
||||
queueMicrotask(() => {
|
||||
queued = false;
|
||||
if (!disposed) update();
|
||||
});
|
||||
};
|
||||
const measure = () => {
|
||||
containerRect = container.getBoundingClientRect();
|
||||
toolbarWidth = toolbarRef.current?.offsetWidth || 0;
|
||||
toolbarHeight = toolbarRef.current?.offsetHeight || 44;
|
||||
setContainerWidth(containerRect.width);
|
||||
scheduleUpdate();
|
||||
};
|
||||
measure();
|
||||
const resizeObserver = new ResizeObserver(measure);
|
||||
resizeObserver.observe(element);
|
||||
resizeObserver.observe(container);
|
||||
if (toolbarRef.current) resizeObserver.observe(toolbarRef.current);
|
||||
const viewportLayer = element.parentElement;
|
||||
const mutationObserver = new MutationObserver(update);
|
||||
if (viewportLayer) mutationObserver.observe(viewportLayer, { attributes: true, attributeFilter: ["style"] });
|
||||
const unsubscribeViewport = subscribeCanvasViewportPreview(container, update);
|
||||
window.addEventListener("resize", update);
|
||||
const unsubscribeViewport = subscribeCanvasGraphicsViewportPreview(container, scheduleUpdate);
|
||||
window.addEventListener("resize", measure);
|
||||
return () => {
|
||||
disposed = true;
|
||||
resizeObserver.disconnect();
|
||||
mutationObserver.disconnect();
|
||||
unsubscribeViewport();
|
||||
window.removeEventListener("resize", update);
|
||||
window.removeEventListener("resize", measure);
|
||||
};
|
||||
}, [anchor === null, containerRef, node, viewport.k, viewport.x, viewport.y]);
|
||||
|
||||
@@ -175,9 +207,6 @@ export function CanvasNodeToolbar({
|
||||
const isVideo = node.type === CanvasNodeType.Video;
|
||||
const isAudio = node.type === CanvasNodeType.Audio;
|
||||
const hasImage = isImage && Boolean(node.metadata?.content);
|
||||
const isText = node.type === CanvasNodeType.Text;
|
||||
const isCharacterReference = isText && node.metadata?.workflowKind === "character" && Boolean(node.metadata.characterAssetId);
|
||||
const isEditableText = isText && !isCharacterReference;
|
||||
const copyImagePrompt = (target: CanvasNodeData) => {
|
||||
const prompt = target.metadata?.prompt?.trim();
|
||||
if (!prompt) {
|
||||
@@ -219,52 +248,43 @@ export function CanvasNodeToolbar({
|
||||
handlers: nodeHoverHandlers,
|
||||
};
|
||||
|
||||
// 注册表只负责动作合同与适用性,Dock 的业务分组在此处唯一确定。
|
||||
// 注册表统一提供动作合同、适用性和节点 Dock 层级。
|
||||
const registryTools = resolveToolbarTools("node-hover", nodeHoverCtx, null);
|
||||
// 锁定始终放在菜单末尾,避免与业务工具混排。
|
||||
const otherRegistryTools = registryTools.filter((tool) => tool.id !== "node-lock");
|
||||
// 转为 ToolbarTool 供组件内部逻辑使用
|
||||
const otherTools: ToolbarTool[] = otherRegistryTools.map((tool) => ({
|
||||
id: tool.id,
|
||||
label: tool.displayLabel ? (typeof tool.displayLabel === "function" ? tool.displayLabel(nodeHoverCtx) : tool.displayLabel) : (typeof tool.label === "function" ? tool.label(nodeHoverCtx) : tool.label),
|
||||
icon: typeof tool.icon === "function" ? tool.icon(nodeHoverCtx) : tool.icon,
|
||||
active: tool.active?.(nodeHoverCtx),
|
||||
danger: tool.danger,
|
||||
disabled: tool.disabled?.(nodeHoverCtx),
|
||||
onClick: () => tool.run(nodeHoverCtx),
|
||||
}));
|
||||
const allTools: ToolbarTool[] = hasImage && !simpleMode
|
||||
? [...otherTools, ...imageTools.map((tool) => ({ id: tool.id, label: tool.label, icon: tool.icon, onClick: tool.onClick }))]
|
||||
: otherTools;
|
||||
const toolById = new Map(allTools.map((tool) => [tool.id, tool]));
|
||||
const takeTools = (ids: string[]) => ids.map((id) => toolById.get(id)).filter((tool): tool is ToolbarTool => Boolean(tool));
|
||||
const imageBaseTools = takeTools(hasImage ? ["delete", "download"] : ["delete", "uploadImage"]);
|
||||
const imageEditTools = takeTools(["maskEdit", "crop", "split"]);
|
||||
const imagePortraitTools = takeTools(["emotion", "portraitTexture"]).map((tool) => tool.id === "emotion" ? { ...tool, label: "人物情绪" } : tool);
|
||||
const imageAngleTool = toolById.get("angle");
|
||||
const videoTools = takeTools(["delete", "download", "subtitles", "timeline", "extractFrames", "extractAudio", "trimRegenerate", "uploadVideo"]).map((tool) => {
|
||||
if (tool.id === "extractFrames") return { ...tool, label: "提取画面" };
|
||||
if (tool.id === "trimRegenerate") return { ...tool, label: "截取片段" };
|
||||
return tool;
|
||||
const registryToolbarTools: ToolbarTool[] = registryTools.map((tool) => {
|
||||
const placement = resolveNodeToolbarPlacement(tool, nodeHoverCtx);
|
||||
return {
|
||||
id: tool.id,
|
||||
label: tool.displayLabel ? (typeof tool.displayLabel === "function" ? tool.displayLabel(nodeHoverCtx) : tool.displayLabel) : (typeof tool.label === "function" ? tool.label(nodeHoverCtx) : tool.label),
|
||||
icon: typeof tool.icon === "function" ? tool.icon(nodeHoverCtx) : tool.icon,
|
||||
group: placement.group,
|
||||
order: placement.order,
|
||||
section: tool.nodeToolbar?.section,
|
||||
description: tool.nodeToolbar?.description,
|
||||
active: tool.active?.(nodeHoverCtx),
|
||||
danger: tool.danger,
|
||||
disabled: tool.disabled?.(nodeHoverCtx),
|
||||
onClick: () => tool.run(nodeHoverCtx),
|
||||
};
|
||||
});
|
||||
const genericTools = takeTools(isAudio ? ["delete", "download", "timeline", "uploadAudio"] : isEditableText ? ["delete", "edit", "editText", "generateImage", "saveAsset"] : ["delete", "info", "config"]);
|
||||
const visibleToolIds = new Set([
|
||||
...(isImage ? [...imageBaseTools, ...imageEditTools, ...imagePortraitTools, ...(imageAngleTool ? [imageAngleTool] : [])] : isVideo ? videoTools : genericTools).map((tool) => tool.id),
|
||||
]);
|
||||
const overflowTools = allTools
|
||||
.filter((tool) => !visibleToolIds.has(tool.id))
|
||||
.map((tool) => tool.id === "edit" && (isImage || isVideo) ? { ...tool, label: "生成设置" } : tool);
|
||||
const lockTool: ToolbarTool = {
|
||||
id: "node-lock",
|
||||
label: node.metadata?.locked ? "解锁" : "锁定",
|
||||
icon: node.metadata?.locked ? <Unlock className="size-3.5" /> : <Lock className="size-3.5" />,
|
||||
active: Boolean(node.metadata?.locked),
|
||||
onClick: () => onToggleLocked(node),
|
||||
};
|
||||
const allTools: ToolbarTool[] = hasImage && !simpleMode
|
||||
? [...registryToolbarTools, ...imageTools]
|
||||
: registryToolbarTools;
|
||||
const compact = containerWidth < 640;
|
||||
const narrow = containerWidth < 420;
|
||||
const inGroup = (group: NodeToolbarGroup) => allTools.filter((tool) => tool.group === group).sort(compareToolbarTools);
|
||||
const primary = inGroup("primary");
|
||||
const primaryTools = narrow ? primary.slice(0, 1) : primary;
|
||||
const portraitTools = compact ? [] : inGroup("portrait");
|
||||
const viewpointTools = compact ? [] : inGroup("viewpoint");
|
||||
const processTools = compact ? [...inGroup("portrait"), ...inGroup("viewpoint"), ...inGroup("process")] : inGroup("process");
|
||||
const workspaceTools = narrow ? [] : inGroup("workspace");
|
||||
const utilityTools = inGroup("utility");
|
||||
const moreTools = [...(narrow ? [...primary.slice(1), ...inGroup("workspace")] : []), ...inGroup("more")];
|
||||
const processMenuLabel = compact ? "工具" : isVideo ? "提取素材" : isImage ? "图片工具" : isAudio ? "音频处理" : "文本调整";
|
||||
const handleMenuOpenChange = (menuId: string, open: boolean) => {
|
||||
setOpenMenuId((current) => open ? menuId : current === menuId ? null : current);
|
||||
if (open) onKeep(node.id);
|
||||
else onLeave();
|
||||
else if (!toolbarRef.current?.contains(document.activeElement)) onLeave();
|
||||
};
|
||||
const dockStyle = canvasDockStyle(theme, theme.node.text);
|
||||
|
||||
@@ -272,66 +292,102 @@ export function CanvasNodeToolbar({
|
||||
<div
|
||||
ref={toolbarRef}
|
||||
className="canvas-node-toolbar absolute z-[var(--z-node-toolbar)] -translate-x-1/2 -translate-y-full"
|
||||
style={{ left: anchor.left, top: anchor.top, width: "max-content", maxWidth: "min(calc(100% - 20px), 960px)", color: theme.node.text }}
|
||||
style={{ left: 0, top: 0, transform: `translate3d(${anchor.left}px, ${anchor.top}px, 0)`, width: "max-content", maxWidth: "calc(100% - 20px)", color: theme.node.text }}
|
||||
onMouseEnter={() => onKeep(node.id)}
|
||||
onMouseLeave={() => { if (!openMenuId) onLeave(); }}
|
||||
onMouseDown={(event) => event.stopPropagation()}
|
||||
onPointerDown={(event) => event.stopPropagation()}
|
||||
data-canvas-no-zoom
|
||||
onKeyDown={(event) => event.stopPropagation()}
|
||||
onFocus={() => onKeep(node.id)}
|
||||
onBlur={(event) => { if (!event.currentTarget.contains(event.relatedTarget) && !openMenuId) onLeave(); }}
|
||||
>
|
||||
<div
|
||||
role="toolbar"
|
||||
aria-label="节点快捷工具"
|
||||
className="thin-scrollbar flex h-11 max-w-full items-center gap-0.5 overflow-x-auto rounded-[var(--dock-radius-tight)] px-2 backdrop-blur-2xl"
|
||||
className="flex h-11 max-w-full items-center gap-0.5 overflow-visible rounded-[var(--dock-radius-tight)] px-2 backdrop-blur-2xl"
|
||||
style={{ ...dockStyle, border: 0 }}
|
||||
>
|
||||
{isImage ? (
|
||||
<>
|
||||
{imageBaseTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
|
||||
{imageEditTools.length ? <NodeDockMenuButton menuId="image-edit" label="编辑" icon={imageEditTools[0].icon} tools={imageEditTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
|
||||
{imagePortraitTools.length ? <NodeDockMenuButton menuId="image-portrait" label="人物调整" icon={imagePortraitTools[0].icon} tools={imagePortraitTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
|
||||
{imageAngleTool ? <NodeDockToolButton tool={imageAngleTool} /> : null}
|
||||
</>
|
||||
) : isVideo ? videoTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />) : genericTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
|
||||
<span aria-hidden className="aceternity-dock-separator mx-1.5 h-6 w-px shrink-0" />
|
||||
<NodeDockToolButton tool={lockTool} />
|
||||
{overflowTools.length ? (
|
||||
<NodeDockMenuButton menuId="more" label="更多" icon={<Ellipsis className="size-3.5" />} tools={overflowTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} placement="topRight" />
|
||||
{primaryTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
|
||||
{portraitTools.length ? <NodeDockMenuButton menuId="portrait" label="人像调整" icon={<UserRound className="size-3.5" />} tools={portraitTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
|
||||
{viewpointTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
|
||||
{processTools.length ? <NodeDockMenuButton menuId="process" label={processMenuLabel} icon={isVideo ? <Images className="size-3.5" /> : <SlidersHorizontal className="size-3.5" />} tools={processTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} /> : null}
|
||||
{workspaceTools.length ? <span aria-hidden className="aceternity-dock-separator mx-1 h-5 w-px shrink-0" /> : null}
|
||||
{workspaceTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} />)}
|
||||
{utilityTools.length || moreTools.length ? <span aria-hidden className="aceternity-dock-separator mx-1 h-5 w-px shrink-0" /> : null}
|
||||
{utilityTools.map((tool) => <NodeDockToolButton key={tool.id} tool={tool} iconOnly />)}
|
||||
{moreTools.length ? (
|
||||
<NodeDockMenuButton menuId="more" label="更多" icon={<Ellipsis className="size-3.5" />} tools={moreTools} openMenuId={openMenuId} onOpenChange={handleMenuOpenChange} placement="topRight" iconOnly />
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeDockToolButton({ tool }: { tool: ToolbarTool }) {
|
||||
function NodeDockToolButton({ tool, iconOnly = false }: { tool: ToolbarTool; iconOnly?: boolean }) {
|
||||
return (
|
||||
<Tooltip title={tool.description ? `${tool.label}:${tool.description}` : tool.label}>
|
||||
<button
|
||||
type="button"
|
||||
className={`aceternity-dock-command is-labeled pointer-events-auto inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-[var(--dock-item-radius)] px-2.5 outline-none ${tool.active ? "is-active" : ""} ${tool.danger ? "is-danger" : ""}`}
|
||||
aria-label={tool.label}
|
||||
aria-pressed={tool.active || undefined}
|
||||
aria-pressed={tool.active}
|
||||
disabled={tool.disabled}
|
||||
onClick={tool.onClick}
|
||||
>
|
||||
<span className="grid size-3.5 shrink-0 place-items-center">{tool.icon}</span>
|
||||
<span className="inline-flex h-4 items-center whitespace-nowrap text-[var(--fs-label)] font-medium leading-none">{tool.label}</span>
|
||||
{!iconOnly ? <span className="inline-flex h-4 items-center whitespace-nowrap text-[var(--fs-label)] font-medium leading-none">{tool.label}</span> : null}
|
||||
</button>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function NodeDockMenuButton({ menuId, label, icon, tools, openMenuId, onOpenChange, placement = "top" }: { menuId: string; label: string; icon: ReactNode; tools: ToolbarTool[]; openMenuId: string | null; onOpenChange: (menuId: string, open: boolean) => void; placement?: "top" | "topRight" }) {
|
||||
function compareToolbarTools(left: ToolbarTool, right: ToolbarTool) {
|
||||
if (left.danger !== right.danger) return left.danger ? 1 : -1;
|
||||
return left.order - right.order;
|
||||
}
|
||||
|
||||
function NodeDockMenuButton({ menuId, label, icon, tools, openMenuId, onOpenChange, placement = "top", iconOnly = false }: { menuId: string; label: string; icon: ReactNode; tools: ToolbarTool[]; openMenuId: string | null; onOpenChange: (menuId: string, open: boolean) => void; placement?: "top" | "topRight"; iconOnly?: boolean }) {
|
||||
const open = openMenuId === menuId;
|
||||
const items: MenuProps["items"] = tools.map((tool) => ({ key: tool.id, icon: tool.icon, label: tool.label, disabled: tool.disabled, onClick: tool.onClick }));
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const frame = requestAnimationFrame(() => triggerRef.current?.focus());
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [open]);
|
||||
const sections = new Map<string, ToolbarTool[]>();
|
||||
for (const tool of tools) {
|
||||
const section = tool.danger ? "危险操作" : tool.section || "常用操作";
|
||||
sections.set(section, [...(sections.get(section) || []), tool]);
|
||||
}
|
||||
const items: MenuProps["items"] = [...sections].sort(([left], [right]) => Number(left === "危险操作") - Number(right === "危险操作")).map(([section, entries]) => ({
|
||||
type: "group", key: section, label: section,
|
||||
children: entries.map((tool) => ({ key: tool.id, icon: tool.icon, label: <div><span className="inline-flex items-center gap-2">{tool.label}{tool.active ? <Check className="size-3.5" /> : null}</span>{tool.description ? <div className="text-[var(--fs-tiny)] opacity-60">{tool.description}</div> : null}</div>, disabled: tool.disabled, danger: tool.danger, onClick: () => { onOpenChange(menuId, false); tool.onClick(); } })),
|
||||
}));
|
||||
return (
|
||||
<Dropdown open={open} trigger={["click"]} placement={placement} onOpenChange={(nextOpen) => onOpenChange(menuId, nextOpen)} menu={{ items }}>
|
||||
<Dropdown open={open} trigger={["click"]} placement={placement} onOpenChange={(nextOpen) => onOpenChange(menuId, nextOpen)} menu={{ items }} autoFocus popupRender={(menu) => <div className="canvas-node-toolbar-menu" data-canvas-no-zoom data-canvas-wheel-scroll onPointerDown={(event) => event.stopPropagation()} onMouseDown={(event) => event.stopPropagation()} onWheel={(event) => event.stopPropagation()} onKeyDownCapture={(event) => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); triggerRef.current?.focus(); onOpenChange(menuId, false); } }} onKeyDown={(event) => event.stopPropagation()}>{menu}</div>}>
|
||||
<button
|
||||
ref={triggerRef}
|
||||
type="button"
|
||||
className={`aceternity-dock-command is-labeled pointer-events-auto inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-[var(--dock-item-radius)] px-2.5 outline-none ${open ? "is-active" : ""}`}
|
||||
aria-label={label}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="menu"
|
||||
title={label}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowDown" && open) {
|
||||
event.preventDefault();
|
||||
document.querySelector<HTMLElement>(".ant-dropdown:not(.ant-dropdown-hidden) .canvas-node-toolbar-menu [role='menuitem']:not([aria-disabled='true'])")?.focus();
|
||||
}
|
||||
if (event.key === "Escape" && open) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
onOpenChange(menuId, false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<span className="grid size-3.5 shrink-0 place-items-center">{icon}</span>
|
||||
<span className="inline-flex h-4 items-center whitespace-nowrap text-[var(--fs-label)] font-medium leading-none">{label}</span>
|
||||
<ChevronDown className="size-3 shrink-0 opacity-55" />
|
||||
{!iconOnly ? <><span className="inline-flex h-4 items-center whitespace-nowrap text-[var(--fs-label)] font-medium leading-none">{label}</span><ChevronDown className="size-3 shrink-0 opacity-55" /></> : null}
|
||||
</button>
|
||||
</Dropdown>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user