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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user