refactor: share floating sidebar shell
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-04-12 00:23:45 +08:00
parent 3c45bdfef9
commit 3f4d850815
3 changed files with 200 additions and 178 deletions

View File

@ -28,6 +28,12 @@ import {
parseMindMapFileToDrawnixMind,
} from "@/lib/flow-board/mind-map-import";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
import {
FloatingLogoPill,
FloatingSidebarAction,
FloatingSidebarRail,
FloatingSidebarShell,
} from "@/components/home/floating-sidebar-shell";
import {
CheckCircle2,
ChevronDown,
@ -46,12 +52,6 @@ import {
X,
} from "lucide-react";
import { useTheme } from "next-themes";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import {
type ChangeEvent,
type ClipboardEvent,
@ -121,10 +121,6 @@ const FLOW_BOARD_IMAGE_UPLOAD_TYPES = new Set([
"image/png",
"image/webp",
]);
const FLOW_BOARD_SIDEBAR_BUTTON_CLASS =
"flex h-10 w-10 items-center justify-center rounded-full text-[#4f566b] transition-colors hover:bg-black/5 hover:text-[#111827] disabled:cursor-not-allowed disabled:opacity-45";
const FLOW_BOARD_SIDEBAR_BUTTON_ACTIVE_CLASS =
"bg-[#111827] text-white shadow-sm hover:bg-[#1f2937] hover:text-white";
const FLOW_BOARD_MARKER_GROUPS: FlowBoardMarkerGroup[] = [
{
id: "tag",
@ -375,16 +371,6 @@ function normalizeSnapshot(value: unknown): FlowBoardSnapshot {
};
}
function formatSavedAt(value: Date | null) {
if (!value) {
return "数据库自动保存";
}
return `已保存 ${value.toLocaleTimeString("zh-CN", {
hour: "2-digit",
minute: "2-digit",
})}`;
}
function formatFileUpdatedAt(value?: string | null) {
if (!value) return "刚刚";
const date = new Date(value);
@ -564,7 +550,7 @@ export function DrawnixWorkspace({
const [isFilePanelOpen, setIsFilePanelOpen] = useState(false);
const [isFileListLoading, setIsFileListLoading] = useState(false);
const [isSavingNow, setIsSavingNow] = useState(false);
const [lastSavedAt, setLastSavedAt] = useState<Date | null>(null);
const [, setLastSavedAt] = useState<Date | null>(null);
const snapshotRef = useRef<FlowBoardSnapshot>({ children: [] });
const currentFileIdRef = useRef<string | null>(null);
const currentTitleRef = useRef("流程白板");
@ -1328,15 +1314,6 @@ export function DrawnixWorkspace({
[cloudFiles, hydrateCloudFile, updateBrowserFileParam]
);
const handleTitleCommit = useCallback(async () => {
const normalizedTitle = currentTitle.trim() || "未命名流程白板";
if (normalizedTitle !== currentTitle) {
setCurrentTitle(normalizedTitle);
currentTitleRef.current = normalizedTitle;
}
await flushPendingSave(false);
}, [currentTitle, flushPendingSave]);
if (!isReady) {
return (
<div className="flex h-svh w-full items-center justify-center bg-[#f7f8fb] text-[#697386]">
@ -1383,154 +1360,56 @@ export function DrawnixWorkspace({
ref={mindMapFileInputRef}
type="file"
/>
<aside
className="pointer-events-none absolute top-4 left-4 z-20 flex flex-col items-center gap-2 lg:top-6 lg:left-6"
data-flow-board-shell-control="true"
<FloatingSidebarShell
className="absolute top-4 left-4 z-20 lg:top-6 lg:left-6"
flowBoardControl
>
<TooltipProvider delayDuration={120}>
<Tooltip>
<TooltipTrigger asChild>
<button
className="pointer-events-auto flex h-[52px] w-[52px] items-center justify-center gap-3 rounded-full border border-black/10 bg-white px-2.5 text-left shadow transition hover:bg-black/[0.03] hover:shadow-md lg:w-[164px] lg:justify-start lg:px-4"
onClick={() => {
window.location.href = "/";
}}
title="返回首页"
type="button"
>
{displayLogo ? (
<img
alt={siteName}
className="h-8 w-8 rounded-full object-cover"
src={displayLogo}
/>
) : (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-black font-bold text-white text-sm">
{siteName.slice(0, 1).toUpperCase()}
</div>
)}
<span className="hidden min-w-0 truncate font-semibold text-[#111827] text-[15px] leading-none tracking-tight lg:block">
{siteName}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="right">返回首页</TooltipContent>
</Tooltip>
<FloatingLogoPill
onClick={() => {
window.location.href = "/";
}}
siteLogo={displayLogo}
siteName={siteName}
tooltip="返回首页"
/>
<div className="pointer-events-auto hidden w-[164px] rounded-[22px] border border-black/10 bg-white px-3 py-2 shadow lg:block">
<div className="mb-1 px-1 font-medium text-[#7a8297] text-[10px] uppercase tracking-[0.18em]">
流程白板
</div>
<input
aria-label="流程白板文件名"
className="h-8 w-full rounded-xl border border-transparent bg-transparent px-1 font-semibold text-[#111827] text-sm tracking-wide outline-none transition focus:border-black/10 focus:bg-[#f7f8fb]"
onBlur={handleTitleCommit}
onChange={(event) => setCurrentTitle(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
value={currentTitle}
/>
</div>
<div className="pointer-events-auto flex max-h-[calc(100vh-148px)] w-[56px] flex-col items-center gap-1 overflow-y-auto rounded-[28px] border border-black/10 bg-white px-1.5 py-2.5 shadow lg:max-h-[calc(100vh-236px)]">
<Tooltip>
<TooltipTrigger asChild>
<button
className={FLOW_BOARD_SIDEBAR_BUTTON_CLASS}
disabled={isSavingNow}
onClick={() => flushPendingSave(true)}
title="保存云端"
type="button"
>
{isSavingNow ? (
<Loader2 className="h-[18px] w-[18px] animate-spin" />
) : (
<Save className="h-[18px] w-[18px]" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="right">保存云端</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
className={FLOW_BOARD_SIDEBAR_BUTTON_CLASS}
onClick={handleOpenFilePanel}
title="云文件"
type="button"
>
<FolderOpen className="h-[18px] w-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent side="right">云文件</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
className={FLOW_BOARD_SIDEBAR_BUTTON_CLASS}
disabled={isSavingNow || isImportingMindMap}
onClick={() => mindMapFileInputRef.current?.click()}
title="导入"
type="button"
>
{isImportingMindMap ? (
<Loader2 className="h-[18px] w-[18px] animate-spin" />
) : (
<Upload className="h-[18px] w-[18px]" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="right">导入</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
className={`${FLOW_BOARD_SIDEBAR_BUTTON_CLASS} ${
isMarkerPanelOpen ? FLOW_BOARD_SIDEBAR_BUTTON_ACTIVE_CLASS : ""
}`}
onClick={() => {
setIsFilePanelOpen(false);
setIsMarkerPanelOpen((open) => !open);
refreshMarkerSelectionState();
}}
title="标记"
type="button"
>
<Tag className="h-[18px] w-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent side="right">标记</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<button
className={FLOW_BOARD_SIDEBAR_BUTTON_CLASS}
onClick={handleCreateCloudFile}
title="新建"
type="button"
>
<Plus className="h-[18px] w-[18px]" />
</button>
</TooltipTrigger>
<TooltipContent side="right">新建</TooltipContent>
</Tooltip>
</div>
<div className="pointer-events-auto hidden max-w-[164px] items-center gap-1.5 rounded-full border border-black/10 bg-white px-3 py-1.5 text-[#7a8297] shadow lg:flex">
<Save className="h-3.5 w-3.5 shrink-0" />
<span className="truncate font-medium text-[11px]">
{formatSavedAt(lastSavedAt)}
</span>
</div>
</TooltipProvider>
</aside>
<FloatingSidebarRail className="max-h-[calc(100vh-88px)]">
<FloatingSidebarAction
disabled={isSavingNow}
icon={Save}
label="保存云端"
loading={isSavingNow}
onClick={() => flushPendingSave(true)}
/>
<FloatingSidebarAction
icon={FolderOpen}
label="云文件"
onClick={handleOpenFilePanel}
/>
<FloatingSidebarAction
disabled={isSavingNow || isImportingMindMap}
icon={Upload}
label="导入"
loading={isImportingMindMap}
onClick={() => mindMapFileInputRef.current?.click()}
/>
<FloatingSidebarAction
active={isMarkerPanelOpen}
icon={Tag}
label="标记"
onClick={() => {
setIsFilePanelOpen(false);
setIsMarkerPanelOpen((open) => !open);
refreshMarkerSelectionState();
}}
/>
<FloatingSidebarAction
icon={Plus}
label="新建"
onClick={handleCreateCloudFile}
/>
</FloatingSidebarRail>
</FloatingSidebarShell>
{isFilePanelOpen ? (
<aside

View File

@ -0,0 +1,142 @@
"use client";
import type { ReactNode } from "react";
import { Loader2, type LucideIcon } from "lucide-react";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
type FloatingSidebarShellProps = {
children: ReactNode;
className?: string;
flowBoardControl?: boolean;
};
export function FloatingSidebarShell({
children,
className,
flowBoardControl = false,
}: FloatingSidebarShellProps) {
return (
<aside
className={cn(
"pointer-events-none flex flex-col items-center gap-2",
className
)}
data-flow-board-shell-control={flowBoardControl ? "true" : undefined}
>
<TooltipProvider delayDuration={120}>{children}</TooltipProvider>
</aside>
);
}
type FloatingLogoPillProps = {
siteName: string;
siteLogo?: string | null;
tooltip?: string;
onClick?: () => void;
};
export function FloatingLogoPill({
siteName,
siteLogo,
tooltip = siteName,
onClick,
}: FloatingLogoPillProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
className="pointer-events-auto flex h-[52px] w-[52px] items-center justify-center gap-3 rounded-full border border-black/10 bg-white px-2.5 text-left shadow transition hover:bg-black/[0.03] hover:shadow-md lg:w-[164px] lg:justify-start lg:px-4"
onClick={onClick}
title={tooltip}
type="button"
>
{siteLogo ? (
<img
alt={siteName}
className="h-8 w-8 rounded-full object-cover"
src={siteLogo}
/>
) : (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-black font-bold text-white text-sm">
{siteName.slice(0, 1).toUpperCase()}
</div>
)}
<span className="hidden min-w-0 truncate font-semibold text-[#111827] text-[15px] leading-none tracking-tight lg:block">
{siteName}
</span>
</button>
</TooltipTrigger>
<TooltipContent side="right">{tooltip}</TooltipContent>
</Tooltip>
);
}
type FloatingSidebarRailProps = {
children: ReactNode;
className?: string;
};
export function FloatingSidebarRail({
children,
className,
}: FloatingSidebarRailProps) {
return (
<div
className={cn(
"pointer-events-auto flex w-[56px] flex-col items-center gap-1 overflow-y-auto rounded-[28px] border border-black/10 bg-white px-1.5 py-2.5 shadow",
className
)}
>
{children}
</div>
);
}
type FloatingSidebarActionProps = {
label: string;
icon: LucideIcon;
active?: boolean;
disabled?: boolean;
loading?: boolean;
onClick?: () => void;
};
export function FloatingSidebarAction({
label,
icon: Icon,
active = false,
disabled = false,
loading = false,
onClick,
}: FloatingSidebarActionProps) {
return (
<Tooltip>
<TooltipTrigger asChild>
<button
className={cn(
"flex h-10 w-10 items-center justify-center rounded-full text-[#4f566b] transition-colors hover:bg-black/5 hover:text-[#111827] disabled:cursor-not-allowed disabled:opacity-45",
active &&
"bg-[#111827] text-white shadow-sm hover:bg-[#1f2937] hover:text-white"
)}
disabled={disabled || loading}
onClick={onClick}
title={label}
type="button"
>
{loading ? (
<Loader2 className="h-[18px] w-[18px] animate-spin" />
) : (
<Icon className="h-[18px] w-[18px]" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="right">{label}</TooltipContent>
</Tooltip>
);
}

View File

@ -56,6 +56,7 @@ import {
import type { MembershipConfig } from "@/lib/membership/config";
import { ImageAnnotator } from "@/components/image-annotator";
import { HomeAnimationEmbed } from "@/components/home/home-animation-embed";
import { FloatingSidebarRail } from "@/components/home/floating-sidebar-shell";
import { fetchUploadWithRetry } from "@/lib/client/upload-with-retry";
type HomeUser = {
@ -1432,7 +1433,7 @@ export function LovartHome({
</TooltipTrigger>
<TooltipContent side="right">新建项目</TooltipContent>
</Tooltip>
<div className="flex max-h-[calc(100vh-172px)] w-[56px] flex-col items-center gap-1 overflow-y-auto rounded-[28px] border border-black/10 bg-white px-1.5 py-2.5 shadow">
<FloatingSidebarRail className="max-h-[calc(100vh-172px)]">
<Tooltip>
<TooltipTrigger asChild>
<button
@ -1711,7 +1712,7 @@ export function LovartHome({
<TooltipContent side="right">系统设置</TooltipContent>
</Tooltip>
) : null}
</div>
</FloatingSidebarRail>
</TooltipProvider>
</aside>