453 lines
15 KiB
TypeScript
453 lines
15 KiB
TypeScript
"use client";
|
|
|
|
import { ChevronRight, X } from "lucide-react";
|
|
import {
|
|
Children,
|
|
cloneElement,
|
|
forwardRef,
|
|
isValidElement,
|
|
useState,
|
|
type HTMLAttributes,
|
|
type MouseEvent,
|
|
type ReactElement,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { toast } from "sonner";
|
|
import { ReferenceIcon } from "@/components/lovart/reference-icon";
|
|
import { CanvasToolbarIcon } from "@/components/canvas/CanvasToolbarIcon";
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuSeparator,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipTrigger,
|
|
} from "@/components/ui/tooltip";
|
|
import {
|
|
saveImageToolbarPreferences,
|
|
useImageToolbarPreferences,
|
|
type ImageToolbarPreferences,
|
|
} from "@/lib/canvas-image-toolbar-preferences";
|
|
|
|
type ToolProps = HTMLAttributes<HTMLButtonElement> & {
|
|
"data-tool-key"?: string;
|
|
disabled?: boolean;
|
|
children?: ReactNode;
|
|
};
|
|
type Tool = ReactElement<ToolProps>;
|
|
type Props = HTMLAttributes<HTMLDivElement> & {
|
|
referencePresentation: boolean;
|
|
defaultPinnedKeys: string[];
|
|
};
|
|
|
|
function toolLabel(tool: Tool) {
|
|
return tool.props.title || "";
|
|
}
|
|
function toolIcon(tool: Tool, size: number) {
|
|
const icon = Children.toArray(tool.props.children).find(isValidElement);
|
|
if (
|
|
!isValidElement<{
|
|
className?: string;
|
|
style?: object;
|
|
referenceSize?: number;
|
|
}>(icon)
|
|
)
|
|
return null;
|
|
return cloneElement(icon, {
|
|
className: "shrink-0",
|
|
style: { width: size, height: size },
|
|
...(icon.type === CanvasToolbarIcon ? { referenceSize: size } : {}),
|
|
});
|
|
}
|
|
function toolCredit(tool: Tool) {
|
|
return Children.toArray(tool.props.children).find(
|
|
(child) =>
|
|
isValidElement<{ compact?: boolean; label?: string }>(child) &&
|
|
child.props.compact &&
|
|
child.props.label
|
|
) as
|
|
| ReactElement<{ compact?: boolean; label?: string; className?: string }>
|
|
| undefined;
|
|
}
|
|
function tooltipButton(tool: Tool, showNames: boolean) {
|
|
const name = toolLabel(tool);
|
|
// Clone the existing action so its handler, busy state and administrator icon override remain intact.
|
|
const button = cloneElement(tool, {
|
|
title: undefined,
|
|
"aria-label": name,
|
|
children: (
|
|
<>
|
|
{toolIcon(tool, 25)}
|
|
{showNames && <span className="lovart-image-tool-label">{name}</span>}
|
|
</>
|
|
),
|
|
});
|
|
return (
|
|
<Tooltip key={tool.props["data-tool-key"] || name}>
|
|
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
|
<TooltipContent
|
|
className="lovart-image-tool-tooltip"
|
|
side="top"
|
|
sideOffset={8}
|
|
>
|
|
{name}
|
|
{toolCredit(tool) &&
|
|
cloneElement(toolCredit(tool)!, {
|
|
compact: false,
|
|
className: "lovart-image-tool-cost",
|
|
})}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
export const ReferenceImageToolbar = forwardRef<HTMLDivElement, Props>(
|
|
function ReferenceImageToolbar(
|
|
{ referencePresentation, defaultPinnedKeys, children, ...rootProps },
|
|
ref
|
|
) {
|
|
const saved = useImageToolbarPreferences();
|
|
const [draft, setDraft] = useState<ImageToolbarPreferences | null>(null);
|
|
const [draggedKey, setDraggedKey] = useState<string | null>(null);
|
|
const elements = Children.toArray(children).filter(
|
|
isValidElement
|
|
) as ReactElement<ToolProps>[];
|
|
const tools = elements.filter(
|
|
(element) =>
|
|
element.props["data-tool-key"] &&
|
|
!["addToChat", "download"].includes(element.props["data-tool-key"]!)
|
|
);
|
|
const toolMap = new Map(
|
|
tools.map((tool) => [tool.props["data-tool-key"]!, tool])
|
|
);
|
|
const preferences = saved || {
|
|
pinnedKeys: defaultPinnedKeys,
|
|
showNames: false,
|
|
};
|
|
const pinned = preferences.pinnedKeys.filter((key) => toolMap.has(key));
|
|
const moreTools = tools.filter(
|
|
(tool) => !pinned.includes(tool.props["data-tool-key"]!)
|
|
);
|
|
const addToChat = elements.find(
|
|
(element) => element.props["data-tool-key"] === "addToChat"
|
|
);
|
|
const download = elements.find(
|
|
(element) => element.props["data-tool-key"] === "download"
|
|
);
|
|
const originalMenu = elements.find(
|
|
(element) => element.type === DropdownMenu
|
|
);
|
|
const menuChildren = originalMenu
|
|
? (Children.toArray(originalMenu.props.children).filter(
|
|
isValidElement
|
|
) as ReactElement<{ children?: ReactNode }>[])
|
|
: [];
|
|
const menuTrigger = menuChildren.find(
|
|
(element) => element.type === DropdownMenuTrigger
|
|
);
|
|
const moreButton =
|
|
menuTrigger &&
|
|
(Children.toArray(menuTrigger.props.children).find(isValidElement) as
|
|
| Tool
|
|
| undefined);
|
|
const extras = menuChildren.find(
|
|
(element) => element.type === DropdownMenuContent
|
|
)?.props.children;
|
|
const openCustomizer = () =>
|
|
setDraft({
|
|
pinnedKeys: [...preferences.pinnedKeys],
|
|
showNames: preferences.showNames,
|
|
});
|
|
const toggle = (key: string) =>
|
|
setDraft((current) =>
|
|
current
|
|
? {
|
|
...current,
|
|
pinnedKeys: current.pinnedKeys.includes(key)
|
|
? current.pinnedKeys.filter((item) => item !== key)
|
|
: [...current.pinnedKeys, key],
|
|
}
|
|
: current
|
|
);
|
|
const move = (key: string, target: string) =>
|
|
setDraft((current) => {
|
|
if (!current || key === target) return current;
|
|
const next = [...current.pinnedKeys];
|
|
const from = next.indexOf(key),
|
|
to = next.indexOf(target);
|
|
if (from < 0 || to < 0) return current;
|
|
next.splice(from, 1);
|
|
next.splice(to, 0, key);
|
|
return { ...current, pinnedKeys: next };
|
|
});
|
|
if (!referencePresentation)
|
|
return (
|
|
<div {...rootProps} ref={ref}>
|
|
{children}
|
|
</div>
|
|
);
|
|
return (
|
|
<>
|
|
<div
|
|
{...rootProps}
|
|
ref={ref}
|
|
style={{ ...rootProps.style, overflowX: "auto" }}
|
|
data-show-tool-names={preferences.showNames ? "true" : "false"}
|
|
>
|
|
{addToChat && tooltipButton(addToChat, preferences.showNames)}
|
|
{addToChat && <div className="w-px" />}
|
|
{pinned.map((key) =>
|
|
tooltipButton(toolMap.get(key)!, preferences.showNames)
|
|
)}
|
|
<DropdownMenu>
|
|
{moreButton && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<DropdownMenuTrigger asChild>
|
|
{cloneElement(moreButton, {
|
|
title: undefined,
|
|
"aria-label": toolLabel(moreButton),
|
|
children: toolIcon(moreButton, 25),
|
|
})}
|
|
</DropdownMenuTrigger>
|
|
</TooltipTrigger>
|
|
<TooltipContent
|
|
className="lovart-image-tool-tooltip"
|
|
sideOffset={8}
|
|
>
|
|
{toolLabel(moreButton)}
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
<DropdownMenuContent
|
|
className="lovart-image-action-menu lovart-image-custom-more"
|
|
side="bottom"
|
|
align="end"
|
|
>
|
|
{moreTools.map((tool) => (
|
|
<DropdownMenuItem
|
|
key={tool.props["data-tool-key"]}
|
|
disabled={tool.props.disabled}
|
|
onClick={(event) =>
|
|
tool.props.onClick?.(
|
|
event as unknown as MouseEvent<HTMLButtonElement>
|
|
)
|
|
}
|
|
>
|
|
{toolIcon(tool, 18)}
|
|
<span>{toolLabel(tool)}</span>
|
|
{toolCredit(tool) &&
|
|
cloneElement(toolCredit(tool)!, {
|
|
compact: false,
|
|
className: "lovart-image-tool-cost",
|
|
})}
|
|
</DropdownMenuItem>
|
|
))}
|
|
{extras}
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
className="lovart-customize-entry"
|
|
onSelect={openCustomizer}
|
|
>
|
|
自定义工具栏
|
|
<ChevronRight size={14} />
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
{download && (
|
|
<>
|
|
<div className="w-px" />
|
|
{tooltipButton(download, preferences.showNames)}
|
|
</>
|
|
)}
|
|
</div>
|
|
<Dialog
|
|
open={Boolean(draft)}
|
|
onOpenChange={(open) => {
|
|
if (!open) setDraft(null);
|
|
}}
|
|
>
|
|
<DialogContent
|
|
hideCloseButton
|
|
className="lovart-toolbar-customizer"
|
|
overlayClassName="lovart-toolbar-customizer-overlay"
|
|
onPointerDown={(event) => event.stopPropagation()}
|
|
onClick={(event) => event.stopPropagation()}
|
|
>
|
|
<header className="lovart-customizer-header">
|
|
<div>
|
|
<DialogTitle>自定义工具栏</DialogTitle>
|
|
<DialogDescription>
|
|
选择你想在编辑栏中使用的工具
|
|
</DialogDescription>
|
|
</div>
|
|
<div className="lovart-customizer-header-actions">
|
|
<button
|
|
type="button"
|
|
aria-label="恢复默认工具栏"
|
|
onClick={() =>
|
|
setDraft({
|
|
pinnedKeys: [...defaultPinnedKeys],
|
|
showNames: false,
|
|
})
|
|
}
|
|
>
|
|
<ReferenceIcon name="toolbar-reset" size={16} />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label="关闭自定义工具栏"
|
|
onClick={() => setDraft(null)}
|
|
>
|
|
<ReferenceIcon name="toolbar-close" size={16} />
|
|
</button>
|
|
</div>
|
|
</header>
|
|
<div className="lovart-customizer-body">
|
|
<div
|
|
className="lovart-customizer-preview"
|
|
aria-label="工具栏预览"
|
|
>
|
|
<div className="lovart-customizer-preview-bar">
|
|
{addToChat && (
|
|
<span className="lovart-preview-fixed">
|
|
{toolIcon(addToChat, 18)}
|
|
{draft?.showNames && toolLabel(addToChat)}
|
|
</span>
|
|
)}
|
|
{draft?.pinnedKeys
|
|
.filter((key) => toolMap.has(key))
|
|
.map((key, index, keys) => {
|
|
const tool = toolMap.get(key)!;
|
|
return (
|
|
<div
|
|
className="lovart-preview-chip"
|
|
key={key}
|
|
draggable
|
|
onDragStart={(event) => {
|
|
setDraggedKey(key);
|
|
event.dataTransfer.setData("text/plain", key);
|
|
event.dataTransfer.effectAllowed = "move";
|
|
}}
|
|
onDragEnd={() => setDraggedKey(null)}
|
|
onDragOver={(event) => event.preventDefault()}
|
|
onDrop={(event) => {
|
|
event.preventDefault();
|
|
if (draggedKey) move(draggedKey, key);
|
|
setDraggedKey(null);
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
className="lovart-preview-handle"
|
|
aria-label={`${toolLabel(tool)},按左右方向键调整顺序`}
|
|
onKeyDown={(event) => {
|
|
const offset =
|
|
event.key === "ArrowLeft"
|
|
? -1
|
|
: event.key === "ArrowRight"
|
|
? 1
|
|
: 0;
|
|
if (offset && keys[index + offset]) {
|
|
event.preventDefault();
|
|
move(key, keys[index + offset]);
|
|
}
|
|
}}
|
|
>
|
|
{toolIcon(tool, 18)}
|
|
{draft.showNames && <span>{toolLabel(tool)}</span>}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
aria-label={`移除${toolLabel(tool)}`}
|
|
onClick={() => toggle(key)}
|
|
>
|
|
<X size={12} />
|
|
</button>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
<div className="lovart-customizer-tools">
|
|
{tools.map((tool) => {
|
|
const key = tool.props["data-tool-key"]!;
|
|
const isPinned = Boolean(draft?.pinnedKeys.includes(key));
|
|
return (
|
|
<button
|
|
key={key}
|
|
type="button"
|
|
aria-label={`${isPinned ? "取消固定" : "固定"}${toolLabel(tool)}`}
|
|
aria-pressed={isPinned}
|
|
onClick={() => toggle(key)}
|
|
>
|
|
{toolIcon(tool, 18)}
|
|
<span>{toolLabel(tool)}</span>
|
|
<ReferenceIcon
|
|
name={
|
|
isPinned
|
|
? "toolbar-pin-filled"
|
|
: "toolbar-pin-outline"
|
|
}
|
|
size={13}
|
|
/>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
<footer className="lovart-customizer-footer">
|
|
<label>
|
|
<button
|
|
type="button"
|
|
role="switch"
|
|
aria-label="显示工具名称"
|
|
aria-checked={Boolean(draft?.showNames)}
|
|
className="lovart-customizer-switch"
|
|
onClick={() =>
|
|
setDraft((current) =>
|
|
current
|
|
? { ...current, showNames: !current.showNames }
|
|
: current
|
|
)
|
|
}
|
|
>
|
|
<span />
|
|
</button>
|
|
显示工具名称
|
|
</label>
|
|
<div>
|
|
<button type="button" onClick={() => setDraft(null)}>
|
|
取消
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="lovart-customizer-save"
|
|
onClick={() => {
|
|
if (draft && saveImageToolbarPreferences(draft))
|
|
setDraft(null);
|
|
else
|
|
toast.error("无法保存工具栏设置,请检查浏览器存储权限");
|
|
}}
|
|
>
|
|
保存
|
|
</button>
|
|
</div>
|
|
</footer>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
);
|