Initial commit: img-infinite-canvas AI design workbench MVP

Moteva-style AI design workbench replica. Home prompt creates a project;
the project page provides an infinite canvas with node drag, zoom/pan,
a design chat panel, history replay, image asset upload, and project
save/regenerate.

- Backend: go-zero, DDD layering, sqlc/pgx, optional PostgreSQL; memory
  or Redis cache; asynq job queue; MinIO/S3/R2/OSS object storage;
  sky-valley/pi agent runtime adapter.
- Frontend: Next.js App Router + Vite artifact build, TypeScript, i18n,
  shadcn/ui components, auth (OTP/Turnstile/Google/WeChat).
- Deploy: Docker Compose and k3s manifests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 23:15:37 +08:00
commit 44406b72db
349 changed files with 73265 additions and 0 deletions
@@ -0,0 +1,545 @@
.toolbar-customize-backdrop {
position: fixed;
inset: 0;
z-index: 120;
display: grid;
place-items: center;
padding: 20px;
background: rgba(18, 24, 32, 0.18);
}
.toolbar-customize-dialog {
width: min(calc(100vw - 32px), 720px);
max-height: calc(100vh - 40px);
display: flex;
flex-direction: column;
overflow: hidden;
border: 1px solid rgba(221, 225, 232, 0.96);
border-radius: 16px;
background: #fff;
box-shadow: 0 26px 72px rgba(17, 24, 39, 0.22);
outline: none;
}
.toolbar-customize-dialog-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 18px;
padding: 24px 24px 12px;
}
.toolbar-customize-dialog-header h3 {
margin: 0;
color: #191c22;
font-size: 20px;
font-weight: 780;
line-height: 1.25;
}
.toolbar-customize-dialog-header p {
margin: 6px 0 0;
color: rgba(25, 28, 34, 0.88);
font-size: 16px;
line-height: 1.35;
}
.toolbar-customize-dialog-actions {
display: inline-flex;
align-items: center;
gap: 4px;
}
.toolbar-customize-dialog-actions button {
width: 30px;
height: 30px;
display: inline-grid;
place-items: center;
border-radius: 8px;
color: #262b33;
background: transparent;
}
.toolbar-customize-dialog-actions button:hover,
.toolbar-customize-dialog-actions button:focus-visible {
background: #f3f4f6;
}
.toolbar-customize-preview {
margin: 28px 24px 24px;
height: 180px;
display: flex;
align-items: center;
overflow: hidden;
border-radius: 10px;
background-color: #f4f7f7;
background-image:
linear-gradient(90deg, rgba(148, 163, 184, 0.2) 1px, transparent 1px),
linear-gradient(rgba(148, 163, 184, 0.2) 1px, transparent 1px);
background-position: center;
background-size: 24px 24px;
}
.toolbar-customize-preview-scroll {
width: 100%;
height: 100%;
display: flex;
align-items: center;
overflow-x: auto;
overflow-y: hidden;
box-sizing: border-box;
padding: 0 18px;
cursor: grab;
scrollbar-color: rgba(126, 137, 143, 0.64) transparent;
scrollbar-width: thin;
}
.toolbar-customize-preview-scroll.is-scrolling {
cursor: grabbing;
}
.toolbar-customize-preview-scroll::-webkit-scrollbar {
height: 8px;
}
.toolbar-customize-preview-scroll::-webkit-scrollbar-track {
margin: 0 120px;
background: transparent;
}
.toolbar-customize-preview-scroll::-webkit-scrollbar-thumb {
border-radius: 999px;
background: rgba(126, 137, 143, 0.64);
}
.toolbar-customize-preview-bar {
width: fit-content;
min-width: 0;
flex: 0 0 auto;
height: 44px;
display: inline-flex;
align-items: center;
gap: 4px;
margin: 0 auto;
padding: 0 6px;
border: 1px solid rgba(222, 226, 233, 0.95);
border-radius: 14px;
background: rgba(255, 255, 255, 0.98);
box-shadow: 0 12px 30px rgba(17, 24, 39, 0.13);
transition: width 180ms ease, max-width 180ms ease;
}
.toolbar-customize-preview-slot {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
align-self: stretch;
gap: 4px;
max-width: 320px;
overflow: visible;
opacity: 1;
transform: scale(1);
transition:
max-width 180ms ease,
opacity 160ms ease,
transform 180ms ease;
user-select: none;
animation: toolbar-customize-tag-enter 180ms ease-out both;
}
.toolbar-customize-preview-slot.is-exiting {
max-width: 0;
overflow: hidden;
opacity: 0;
transform: scale(0.94);
pointer-events: none;
}
.toolbar-customize-preview-sortable {
position: relative;
display: inline-flex;
flex: 0 0 auto;
cursor: grab;
touch-action: none;
}
.toolbar-customize-preview-sortable:active {
cursor: grabbing;
}
.toolbar-customize-preview-sortable.is-fixed {
cursor: default;
}
.toolbar-customize-preview-sortable.is-dragging {
opacity: 0.48;
}
.toolbar-customize-preview-item {
position: relative;
z-index: 0;
display: inline-flex;
animation: 0.7s ease-in-out var(--wiggle-delay, -0.365693s) infinite normal none running lo-wiggle;
}
.toolbar-customize-preview-item.is-fixed {
animation: none;
}
.toolbar-customize-preview-tag {
position: relative;
height: 32px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 10px;
border-radius: 8px;
color: #242932;
background: rgba(244, 245, 247, 0.94);
font-size: 14px;
font-weight: 720;
outline-offset: -1px;
white-space: nowrap;
cursor: inherit;
}
.toolbar-customize-preview-tag::after {
position: absolute;
inset: 0;
z-index: -1;
border-radius: 8px;
background: #fff;
content: "";
}
.toolbar-customize-preview-tag:hover,
.toolbar-customize-preview-tag:focus-visible,
.toolbar-customize-preview-item:hover .toolbar-customize-preview-tag {
background: #fff;
outline: 1px solid #dde1e8;
}
.toolbar-customize-preview-tag-inner {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 6px;
}
.toolbar-customize-preview-icon {
width: 20px;
height: 20px;
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
color: #2d323a;
}
.toolbar-customize-preview-icon svg {
width: 100%;
height: 100%;
}
.toolbar-customize-preview-label {
min-width: 0;
flex: 1;
overflow: hidden;
color: #242932;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbar-customize-preview-item.is-fixed .toolbar-customize-preview-tag {
cursor: default;
background: transparent;
}
.toolbar-customize-preview-label em {
margin-left: 4px;
color: rgba(36, 41, 50, 0.42);
font-style: normal;
font-weight: 620;
}
.toolbar-customize-preview-divider {
align-self: stretch;
width: 1px;
margin: 0 2px;
background: rgba(222, 226, 233, 0.95);
}
.toolbar-customize-preview-static-actions {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
align-self: stretch;
gap: 0;
}
.toolbar-customize-preview-static-action {
width: 44px;
height: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
color: #242932;
background: transparent;
pointer-events: none;
}
.toolbar-customize-remove {
position: absolute;
right: -12px;
top: -12px;
z-index: 2;
width: 32px;
height: 32px;
display: inline-flex;
place-items: center;
align-items: center;
justify-content: center;
color: #8b929f;
background: transparent;
cursor: pointer;
}
.toolbar-customize-remove span {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid #dde1e8;
border-radius: 999px;
background: rgba(255, 255, 255, 0.94);
box-shadow: 0 3px 10px rgba(17, 24, 39, 0.12);
backdrop-filter: blur(12px);
}
.toolbar-customize-remove:hover,
.toolbar-customize-remove:focus-visible {
color: #20242c;
}
.toolbar-customize-remove:hover span,
.toolbar-customize-remove:focus-visible span {
background: #f5f6f8;
}
@keyframes lo-wiggle {
0%,
100% {
transform: translateX(var(--wiggle-from, -0.8px));
}
50% {
transform: translateX(var(--wiggle-to, 0.8px));
}
}
@keyframes toolbar-customize-tag-enter {
from {
opacity: 0;
transform: scale(0.96) translateY(2px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.toolbar-customize-grid {
min-height: 0;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
overflow: auto;
padding: 0 24px;
}
.toolbar-customize-card {
height: 36px;
min-width: 0;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 0 12px;
border: 1px solid #dde1e8;
border-radius: 9px;
color: #242932;
background: #fff;
font-size: 14px;
font-weight: 720;
}
.toolbar-customize-card:hover,
.toolbar-customize-card:focus-visible {
background: #f7f8fa;
}
.toolbar-customize-card > svg {
flex: 0 0 auto;
color: #2d323a;
}
.toolbar-customize-card-label {
min-width: 0;
display: inline-flex;
align-items: center;
gap: 10px;
}
.toolbar-customize-card-label svg {
flex: 0 0 auto;
}
.toolbar-customize-card-label span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.toolbar-customize-card:not(.is-pinned) > svg {
color: #343941;
}
.toolbar-customize-footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 32px 24px 24px;
}
.toolbar-customize-switch {
display: inline-flex;
align-items: center;
gap: 10px;
color: #242932;
font-size: 15px;
font-weight: 660;
cursor: pointer;
}
.toolbar-customize-switch .ui-switch {
width: 32px;
height: 18px;
background: #d8dde5;
}
.toolbar-customize-switch .ui-switch[data-state="checked"] {
background: #303030;
}
.toolbar-customize-switch .ui-switch-thumb {
width: 14px;
height: 14px;
transform: translateX(2px);
}
.toolbar-customize-switch .ui-switch[data-state="checked"] .ui-switch-thumb {
transform: translateX(16px);
}
.toolbar-customize-footer-actions {
display: inline-flex;
align-items: center;
gap: 8px;
}
.toolbar-customize-cancel,
.toolbar-customize-save {
min-width: 80px;
height: 36px;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 8px;
padding: 0 16px;
font-size: 15px;
font-weight: 700;
}
.toolbar-customize-cancel {
border: 1px solid #dde1e8;
color: #242932;
background: #fff;
}
.toolbar-customize-cancel:hover,
.toolbar-customize-cancel:focus-visible {
background: #f7f8fa;
}
.toolbar-customize-save {
color: #fff;
background: #303030;
}
.toolbar-customize-save:hover,
.toolbar-customize-save:focus-visible {
background: #111;
}
.toolbar-customize-menu-button {
width: 100%;
height: 38px;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
border-radius: 9px;
color: #252a32;
background: #f0f0f0;
font-size: 14px;
font-weight: 720;
}
.toolbar-customize-menu-button:hover,
.toolbar-customize-menu-button:focus-visible {
background: #e8e9eb;
}
@media (max-width: 720px) {
.toolbar-customize-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 520px) {
.toolbar-customize-backdrop {
padding: 12px;
}
.toolbar-customize-dialog {
width: calc(100vw - 24px);
}
.toolbar-customize-dialog-header,
.toolbar-customize-footer {
padding-left: 16px;
padding-right: 16px;
}
.toolbar-customize-preview {
margin-left: 16px;
margin-right: 16px;
}
.toolbar-customize-grid {
grid-template-columns: 1fr;
padding: 0 16px;
}
.toolbar-customize-footer {
align-items: stretch;
flex-direction: column;
}
.toolbar-customize-footer-actions {
justify-content: flex-end;
}
}
@@ -0,0 +1,374 @@
"use client";
import { ChevronRight, Download, MoreHorizontal, Pin, PinOff, RefreshCw, X, type LucideIcon } from "lucide-react";
import { useEffect, useRef, useState, type CSSProperties, type DragEvent, type PointerEvent as ReactPointerEvent } from "react";
import { Switch } from "@/components/ui/switch";
import { useI18n } from "@/i18n/i18n";
import "./index.css";
export type ToolbarCustomizeItem<TId extends string> = {
id: TId;
label: string;
icon: LucideIcon;
fixed?: boolean;
};
type PreviewScrollDrag = {
pointerId: number;
x: number;
scrollLeft: number;
};
export function ToolbarCustomizeDialog<TId extends string>({
open,
items,
fixedItem,
visibility,
defaultVisibility,
order,
defaultOrder,
showToolNames,
onOpenChange,
onSave
}: {
open: boolean;
items: Array<ToolbarCustomizeItem<TId>>;
fixedItem: ToolbarCustomizeItem<TId>;
visibility: Record<TId, boolean>;
defaultVisibility: Record<TId, boolean>;
order: TId[];
defaultOrder: TId[];
showToolNames: boolean;
onOpenChange: (open: boolean) => void;
onSave: (visibility: Record<TId, boolean>, showToolNames: boolean, order: TId[]) => void;
}) {
const { t } = useI18n();
const previewScrollRef = useRef<HTMLDivElement | null>(null);
const previewScrollDragRef = useRef<PreviewScrollDrag | null>(null);
const previousVisibleIdsRef = useRef<TId[]>([]);
const exitTimersRef = useRef(new Map<TId, number>());
const [draftVisibility, setDraftVisibility] = useState<Record<TId, boolean>>(visibility);
const [draftOrder, setDraftOrder] = useState<TId[]>(() => normalizeOrder(order, items));
const [draftShowToolNames, setDraftShowToolNames] = useState(showToolNames);
const [draggingId, setDraggingId] = useState<TId | null>(null);
const [previewScrolling, setPreviewScrolling] = useState(false);
const [exitingIds, setExitingIds] = useState<Set<TId>>(() => new Set());
const orderedItems = orderItems(draftOrder, items);
const visibleItems = orderedItems.filter((item) => draftVisibility[item.id]);
const previewItems = orderedItems.filter((item) => draftVisibility[item.id] || exitingIds.has(item.id));
const visibleItemIds = visibleItems.map((item) => item.id);
const visibleItemIdsKey = visibleItemIds.join("|");
useEffect(() => {
if (!open) return;
setDraftVisibility(visibility);
setDraftOrder(normalizeOrder(order, items));
setDraftShowToolNames(showToolNames);
setDraggingId(null);
setExitingIds(new Set());
}, [items, open, order, showToolNames, visibility]);
useEffect(() => {
return () => {
exitTimersRef.current.forEach((timer) => window.clearTimeout(timer));
exitTimersRef.current.clear();
};
}, []);
useEffect(() => {
if (!open) {
previousVisibleIdsRef.current = [];
return;
}
const previousIds = previousVisibleIdsRef.current;
const added = previousIds.length > 0 && visibleItemIds.length > previousIds.length && visibleItemIds.some((id) => !previousIds.includes(id));
previousVisibleIdsRef.current = visibleItemIds;
if (!added) return;
const scroll = previewScrollRef.current;
if (!scroll) return;
const frame = window.requestAnimationFrame(() => {
scroll.scrollTo({
left: scroll.scrollWidth - scroll.clientWidth,
behavior: "smooth"
});
});
return () => window.cancelAnimationFrame(frame);
}, [open, visibleItemIdsKey]);
if (!open) return null;
const toggleItem = (id: TId) => {
setDraftVisibility((current) => {
const pinned = current[id];
const existingTimer = exitTimersRef.current.get(id);
if (existingTimer) {
window.clearTimeout(existingTimer);
exitTimersRef.current.delete(id);
}
if (pinned) {
setExitingIds((currentIds) => new Set(currentIds).add(id));
const timer = window.setTimeout(() => {
setExitingIds((currentIds) => {
const nextIds = new Set(currentIds);
nextIds.delete(id);
return nextIds;
});
exitTimersRef.current.delete(id);
}, 180);
exitTimersRef.current.set(id, timer);
} else {
setExitingIds((currentIds) => {
if (!currentIds.has(id)) return currentIds;
const nextIds = new Set(currentIds);
nextIds.delete(id);
return nextIds;
});
}
return { ...current, [id]: !pinned };
});
};
const reset = () => {
exitTimersRef.current.forEach((timer) => window.clearTimeout(timer));
exitTimersRef.current.clear();
setDraftVisibility(defaultVisibility);
setDraftOrder(normalizeOrder(defaultOrder, items));
setDraftShowToolNames(true);
setExitingIds(new Set());
};
const save = () => {
onSave(draftVisibility, draftShowToolNames, normalizeOrder(draftOrder, items));
onOpenChange(false);
};
const close = () => onOpenChange(false);
const beginPreviewScroll = (event: ReactPointerEvent<HTMLDivElement>) => {
if ((event.target as HTMLElement).closest(".toolbar-customize-preview-item")) return;
previewScrollDragRef.current = {
pointerId: event.pointerId,
x: event.clientX,
scrollLeft: event.currentTarget.scrollLeft
};
setPreviewScrolling(true);
event.currentTarget.setPointerCapture(event.pointerId);
};
const movePreviewScroll = (event: ReactPointerEvent<HTMLDivElement>) => {
const drag = previewScrollDragRef.current;
if (!drag || drag.pointerId !== event.pointerId) return;
event.currentTarget.scrollLeft = drag.scrollLeft - (event.clientX - drag.x);
};
const endPreviewScroll = (event: ReactPointerEvent<HTMLDivElement>) => {
if (previewScrollDragRef.current?.pointerId !== event.pointerId) return;
previewScrollDragRef.current = null;
setPreviewScrolling(false);
};
const beginItemDrag = (event: DragEvent<HTMLElement>, id: TId) => {
setDraggingId(id);
event.dataTransfer.effectAllowed = "move";
event.dataTransfer.setData("text/plain", id);
};
const reorderItem = (event: DragEvent<HTMLElement>, targetId: TId) => {
event.preventDefault();
const sourceId = draggingId ?? (event.dataTransfer.getData("text/plain") as TId);
if (!sourceId || sourceId === targetId) return;
const rect = event.currentTarget.getBoundingClientRect();
const side: "before" | "after" = event.clientX < rect.left + rect.width / 2 ? "before" : "after";
setDraftOrder((current) => moveOrderItem(current, sourceId, targetId, side));
};
return (
<div className="toolbar-customize-backdrop" role="presentation" onPointerDown={close}>
<section className="toolbar-customize-dialog" role="dialog" aria-modal="true" aria-labelledby="toolbar-customize-title" onPointerDown={(event) => event.stopPropagation()}>
<header className="toolbar-customize-dialog-header">
<div>
<h3 id="toolbar-customize-title">{t("customizeToolbar")}</h3>
<p>{t("customizeToolbarDesc")}</p>
</div>
<div className="toolbar-customize-dialog-actions">
<button type="button" aria-label={t("reset")} onClick={reset}>
<RefreshCw size={17} />
</button>
<button type="button" aria-label={t("close")} onClick={close}>
<X size={18} />
</button>
</div>
</header>
<div className="toolbar-customize-preview">
<div
ref={previewScrollRef}
className={`toolbar-customize-preview-scroll ${previewScrolling ? "is-scrolling" : ""}`}
onPointerDown={beginPreviewScroll}
onPointerMove={movePreviewScroll}
onPointerUp={endPreviewScroll}
onPointerCancel={endPreviewScroll}
onWheel={(event) => {
event.currentTarget.scrollLeft += event.deltaX + event.deltaY;
}}
>
<div className={`toolbar-customize-preview-bar ${draftShowToolNames ? "" : "hide-tool-names"}`}>
<PreviewTool item={fixedItem} labelSuffix="Tab" fixed showLabel={draftShowToolNames} />
<span className="toolbar-customize-preview-divider" />
{previewItems.map((item) => (
<PreviewTool
key={item.id}
item={item}
showLabel={draftShowToolNames}
dragging={draggingId === item.id}
exiting={exitingIds.has(item.id)}
onDragStart={(event) => beginItemDrag(event, item.id)}
onDragOver={(event) => reorderItem(event, item.id)}
onDrop={(event) => reorderItem(event, item.id)}
onDragEnd={() => setDraggingId(null)}
onRemove={() => toggleItem(item.id)}
/>
))}
<span className="toolbar-customize-preview-divider" />
<span className="toolbar-customize-preview-static-actions">
<span className="toolbar-customize-preview-static-action" aria-label={t("more")}>
<MoreHorizontal size={20} />
</span>
<span className="toolbar-customize-preview-divider" />
<span className="toolbar-customize-preview-static-action" aria-label={t("download")}>
<Download size={20} />
</span>
</span>
</div>
</div>
</div>
<div className="toolbar-customize-grid">
{orderedItems.map((item) => {
const Icon = item.icon;
const pinned = draftVisibility[item.id];
const PinIcon = pinned ? Pin : PinOff;
return (
<button key={item.id} type="button" className={`toolbar-customize-card ${pinned ? "is-pinned" : ""}`} onClick={() => toggleItem(item.id)}>
<span className="toolbar-customize-card-label">
<Icon size={20} />
<span>{item.label}</span>
</span>
<PinIcon size={18} fill={pinned ? "currentColor" : "none"} />
</button>
);
})}
</div>
<footer className="toolbar-customize-footer">
<label className="toolbar-customize-switch">
<Switch checked={draftShowToolNames} onCheckedChange={setDraftShowToolNames} />
<span>{t("showToolNames")}</span>
</label>
<div className="toolbar-customize-footer-actions">
<button type="button" className="toolbar-customize-cancel" onClick={close}>
{t("cancel")}
</button>
<button type="button" className="toolbar-customize-save" onClick={save}>
{t("save")}
</button>
</div>
</footer>
</section>
</div>
);
}
export function ToolbarCustomizeMenuButton({ onClick }: { onClick: () => void }) {
const { t } = useI18n();
return (
<button type="button" className="toolbar-customize-menu-button" onClick={onClick}>
<span>{t("customizeToolbar")}</span>
<ChevronRight size={15} />
</button>
);
}
function PreviewTool<TId extends string>({
item,
fixed = false,
labelSuffix,
showLabel,
dragging = false,
exiting = false,
onDragStart,
onDragOver,
onDrop,
onDragEnd,
onRemove
}: {
item: ToolbarCustomizeItem<TId>;
fixed?: boolean;
labelSuffix?: string;
showLabel: boolean;
dragging?: boolean;
exiting?: boolean;
onDragStart?: (event: DragEvent<HTMLElement>) => void;
onDragOver?: (event: DragEvent<HTMLElement>) => void;
onDrop?: (event: DragEvent<HTMLElement>) => void;
onDragEnd?: () => void;
onRemove?: () => void;
}) {
const Icon = item.icon;
const style = {
"--wiggle-from": "-0.8px",
"--wiggle-to": "0.8px",
"--wiggle-delay": `-${(item.id.length % 6) * 0.083 + 0.033693}s`
} as CSSProperties;
return (
<span className={`toolbar-customize-preview-slot ${exiting ? "is-exiting" : ""}`}>
<span
className={`toolbar-customize-preview-sortable ${fixed ? "is-fixed" : ""} ${dragging ? "is-dragging" : ""}`}
draggable={!fixed && !exiting}
role={fixed ? undefined : "button"}
tabIndex={fixed ? undefined : 0}
aria-disabled={fixed ? true : undefined}
aria-roledescription={fixed ? undefined : "sortable"}
onDragStart={exiting ? undefined : onDragStart}
onDragOver={exiting ? undefined : onDragOver}
onDrop={exiting ? undefined : onDrop}
onDragEnd={exiting ? undefined : onDragEnd}
>
<span className={`toolbar-customize-preview-item ${fixed ? "is-fixed" : ""}`} style={style}>
<button type="button" className="toolbar-customize-preview-tag" disabled={fixed}>
<span className="toolbar-customize-preview-tag-inner">
<span className="toolbar-customize-preview-icon">
<Icon size={20} />
</span>
{showLabel && (
<span className="toolbar-customize-preview-label">
{item.label}
{labelSuffix && <em>{labelSuffix}</em>}
</span>
)}
</span>
</button>
{!fixed && (
<button type="button" className="toolbar-customize-remove" aria-label={item.label} onClick={onRemove}>
<span>
<X size={13} />
</span>
</button>
)}
</span>
</span>
</span>
);
}
function normalizeOrder<TId extends string>(order: TId[], items: Array<ToolbarCustomizeItem<TId>>) {
const itemIds = items.map((item) => item.id);
return [...order.filter((id) => itemIds.includes(id)), ...itemIds.filter((id) => !order.includes(id))];
}
function orderItems<TId extends string>(order: TId[], items: Array<ToolbarCustomizeItem<TId>>) {
const itemById = new Map(items.map((item) => [item.id, item]));
return normalizeOrder(order, items).map((id) => itemById.get(id)).filter((item): item is ToolbarCustomizeItem<TId> => Boolean(item));
}
function moveOrderItem<TId extends string>(order: TId[], sourceId: TId, targetId: TId, side: "before" | "after") {
const withoutSource = order.filter((id) => id !== sourceId);
const targetIndex = withoutSource.indexOf(targetId);
if (targetIndex < 0) return order;
const insertIndex = side === "before" ? targetIndex : targetIndex + 1;
const nextOrder = [...withoutSource];
nextOrder.splice(insertIndex, 0, sourceId);
return nextOrder;
}