ai-chatbot/components/flow-board/xmind-editor-chrome.tsx

1085 lines
38 KiB
TypeScript

"use client";
import {
ArrowLeft,
ArrowUpRight,
Bold,
ChevronDown,
Copy,
FileText,
FolderOpen,
GitBranchPlus,
GitMerge,
Hand,
Image as ImageIcon,
Italic,
ListPlus,
Link2,
StickyNote,
Maximize,
Minus,
MousePointer2,
Network,
PanelRight,
Pencil,
Plus,
Redo2,
Save,
Shapes,
Tag,
Trash2,
Type,
Underline,
Undo2,
Upload,
Download,
X,
type LucideIcon,
} from "lucide-react";
import { type ReactNode, useState } from "react";
export type FlowBoardToolId =
| "selection"
| "hand"
| "mind"
| "text"
| "arrow"
| "shape"
| "image";
export type FlowBoardSheetTab = {
id: string;
title: string;
};
export type FlowBoardLayoutOption = {
id: string;
label: string;
preview:
| "both"
| "right"
| "left"
| "down"
| "up"
| "indent"
| "fishbone"
| "timeline"
| "brace"
| "verticalTimeline"
| "treeTable"
| "treeTableSide"
| "spreadsheet";
};
export type FlowBoardThemeOption = {
id: string;
label: string;
colors: [string, string, string];
};
type ToolbarActionProps = {
active?: boolean;
disabled?: boolean;
icon: LucideIcon;
label: string;
onClick: () => void;
};
function ToolbarAction({
active = false,
disabled = false,
icon: Icon,
label,
onClick,
}: ToolbarActionProps) {
return (
<button
aria-label={label}
className={`group flex h-[50px] min-w-[52px] flex-col items-center justify-center gap-1 px-2 text-[11px] transition-colors disabled:cursor-not-allowed disabled:opacity-35 ${
active
? "bg-[#eef3ff] text-[#3569e8]"
: "text-[#4f5663] hover:bg-[#f3f4f6] hover:text-[#17191d]"
}`}
disabled={disabled}
onClick={onClick}
title={label}
type="button"
>
<Icon className="h-[19px] w-[19px] stroke-[1.7]" />
<span className="whitespace-nowrap">{label}</span>
</button>
);
}
function IconAction({
children,
disabled = false,
label,
tooltip,
onClick,
}: {
children: ReactNode;
disabled?: boolean;
label: string;
tooltip?: string;
onClick: () => void;
}) {
return (
<button
aria-label={label}
className="flex h-8 w-8 items-center justify-center text-[#5b616d] transition-colors hover:bg-[#f1f2f4] hover:text-[#15171a] disabled:cursor-not-allowed disabled:opacity-35"
disabled={disabled}
onClick={onClick}
title={tooltip || label}
type="button"
>
{children}
</button>
);
}
function LayoutPreview({
direction,
}: {
direction: FlowBoardLayoutOption["preview"];
}) {
const horizontal = direction === "right" || direction === "left";
const vertical = direction === "down" || direction === "up";
const leaf = (x: number, y: number) => (
<rect key={`${x}-${y}`} x={x - 3} y={y - 3} width="6" height="6" fill="white" />
);
const root = (x: number, y: number) => (
<rect x={x - 4.5} y={y - 4.5} width="9" height="9" fill="currentColor" stroke="none" />
);
return (
<svg aria-hidden="true" className="block h-9 w-12 shrink-0" data-layout-preview={direction}
viewBox="0 0 48 36" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round">
{direction === "both" ? (
<>
<path d="M19.5 18H14V8H8M14 18V28H8M28.5 18H34V8H40M34 18V28H40" />
{root(24, 18)}
{[leaf(5, 8), leaf(5, 28), leaf(43, 8), leaf(43, 28)]}
</>
) : null}
{horizontal ? (
<g transform={direction === "left" ? "translate(48 0) scale(-1 1)" : undefined}>
<path d="M13.5 18H24V8H35M24 18V28H35" />
{root(9, 18)}
{[leaf(38, 8), leaf(38, 28)]}
</g>
) : null}
{vertical ? (
<g transform={direction === "up" ? "translate(0 36) scale(1 -1)" : undefined}>
<path d="M24 10.5V19M7 25V19H41V25M24 19V25" />
{root(24, 6)}
{[leaf(7, 28), leaf(24, 28), leaf(41, 28)]}
</g>
) : null}
{direction === "indent" ? (
<>
<path d="M9 10.5V30H27M9 18H27" />
{root(9, 6)}
{[leaf(30, 18), leaf(30, 30)]}
<path d="M34 18H44M34 30H44" />
</>
) : null}
{direction === "fishbone" || direction === "timeline" ? <>
{root(6, 18)}<path d="M10.5 18H46" />
<path d={direction === "fishbone" ? "M17 18L23 7M29 18L35 29M34 18L40 7" : "M20 18V7M29 18V29M40 18V7"} />
{[leaf(direction === "fishbone" ? 23 : 20, 5), leaf(direction === "fishbone" ? 35 : 29, 31), leaf(40, 5)]}
</> : null}
{direction === "verticalTimeline" ? <>
{root(24, 5)}<path d="M24 9.5V32M24 15H35M24 23H13M24 31H35" />
{[leaf(38, 15), leaf(10, 23), leaf(38, 31)]}
</> : null}
{direction === "treeTable" ? <>
<rect x="7" y="4" width="34" height="8" fill="currentColor" stroke="none" />
<path d="M7 12H41V32H7ZM7 22H41M25 12V32" />
</> : null}
{direction === "treeTableSide" ? <>
<rect x="5" y="5" width="10" height="26" fill="currentColor" stroke="none" />
<path d="M15 5H43V31H15ZM15 14H43M15 23H43M29 5V31" />
</> : null}
{direction === "spreadsheet" ? <>
<rect x="5" y="4" width="38" height="8" fill="currentColor" stroke="none" />
<path d="M5 12H43V32H5ZM5 19H43M5 25.5H43M16 12V32M25 12V32M34 12V32" />
<rect x="6" y="20" width="9" height="5" fill="currentColor" stroke="none" opacity=".55" />
</> : null}
{direction === "brace" ? <>{root(8,18)}<path d="M30 5Q23 5 23 11V13Q23 18 17 18Q23 18 23 23V25Q23 31 30 31" />{[leaf(37,5),leaf(37,18),leaf(37,31)]}</> : null}
</svg>
);
}
export function XmindEditorTopBar({
activeTool,
canEditMind,
currentTitle,
formatPanelOpen,
isSaving,
onBack,
onImport,
onExport,
isExporting,
onInsertChild,
onInsertSibling,
onRelationship,
onBoundary,
relationshipPending,
onOpenFiles,
onRedo,
onSave,
onSummary,
onTopicDetails,
onTitleChange,
onTitleCommit,
onToggleFormat,
onToggleMarkers,
onToolChange,
onUndo,
siteLogo,
siteName,
}: {
activeTool: FlowBoardToolId;
canEditMind: boolean;
currentTitle: string;
formatPanelOpen: boolean;
isSaving: boolean;
onBack: () => void;
onImport: () => void;
onExport: () => void;
isExporting: boolean;
onInsertChild: () => void;
onInsertSibling: () => void;
onRelationship: () => void;
onBoundary: () => void;
relationshipPending: boolean;
onOpenFiles: () => void;
onRedo: () => void;
onSave: () => void;
onSummary: () => void;
onTopicDetails: (section: "notes" | "links" | "labels") => void;
onTitleChange: (value: string) => void;
onTitleCommit: () => void;
onToggleFormat: () => void;
onToggleMarkers: () => void;
onToolChange: (tool: FlowBoardToolId) => void;
onUndo: () => void;
siteLogo: string | null;
siteName: string;
}) {
return (
<header
className="absolute inset-x-0 top-0 z-40 h-[94px] border-[#dfe2e7] border-b bg-white"
data-flow-board-shell-control="true"
>
<div className="relative flex h-10 items-center border-[#eceef1] border-b px-3">
<div className="flex items-center gap-1">
<IconAction label="返回首页" onClick={onBack}>
<ArrowLeft className="h-[17px] w-[17px]" />
</IconAction>
<div className="mx-1 h-5 w-px bg-[#e3e5e9]" />
<div className="flex h-7 w-7 items-center justify-center overflow-hidden bg-[#17191d] text-[11px] font-semibold text-white">
{siteLogo ? (
<img alt={siteName} className="h-full w-full object-contain" src={siteLogo} />
) : (
siteName.slice(0, 1).toUpperCase()
)}
</div>
<span className="hidden max-w-36 truncate font-medium text-[#343840] text-xs sm:block">
{siteName}
</span>
</div>
<input
aria-label="白板标题"
className="absolute left-1/2 hidden h-7 min-w-[160px] w-[28vw] max-w-[320px] -translate-x-1/2 border border-transparent bg-transparent px-2 text-center font-medium text-[#202329] text-[13px] outline-none transition-colors hover:border-[#dfe2e7] focus:border-[#a9bce8] focus:bg-white sm:block"
maxLength={256}
onBlur={onTitleCommit}
onChange={(event) => onTitleChange(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
event.currentTarget.blur();
}
}}
value={currentTitle}
/>
<div className="ml-auto flex items-center gap-1">
<span className="mr-1 hidden text-[#8b919c] text-[11px] md:inline">
{isSaving ? "正在保存" : "已保存"}
</span>
<IconAction label="保存" onClick={onSave}>
<Save className="h-[16px] w-[16px]" />
</IconAction>
<IconAction label="云文件" onClick={onOpenFiles}>
<FolderOpen className="h-[16px] w-[16px]" />
</IconAction>
<IconAction label="导入" tooltip="导入 XMind、Markdown、OPML 或 FreeMind 文件" onClick={onImport}>
<Upload className="h-[16px] w-[16px]" />
</IconAction>
<IconAction label={isExporting ? "正在导出" : "导出 XMind"} onClick={onExport} disabled={isExporting}>
<Download className="h-[16px] w-[16px]" />
</IconAction>
<button
aria-label="格式"
className={`ml-1 flex h-7 items-center gap-1.5 px-2.5 text-xs transition-colors ${
formatPanelOpen
? "bg-[#eaf0ff] text-[#3569e8]"
: "bg-[#f3f4f6] text-[#444a55] hover:bg-[#e9ebee]"
}`}
onClick={onToggleFormat}
type="button"
>
<PanelRight className="h-3.5 w-3.5" />
格式
</button>
</div>
</div>
<div className="flex h-[53px] items-center overflow-hidden px-3">
<div className="flex items-center gap-0.5">
<IconAction label="撤销" onClick={onUndo}>
<Undo2 className="h-[18px] w-[18px]" />
</IconAction>
<IconAction label="重做" onClick={onRedo}>
<Redo2 className="h-[18px] w-[18px]" />
</IconAction>
</div>
<div className="mx-2 h-7 w-px bg-[#e3e5e9]" />
<nav className="flex h-full min-w-0 flex-1 items-center overflow-x-auto">
<ToolbarAction
active={activeTool === "selection"}
icon={MousePointer2}
label="选择"
onClick={() => onToolChange("selection")}
/>
<ToolbarAction
active={activeTool === "hand"}
icon={Hand}
label="画布"
onClick={() => onToolChange("hand")}
/>
<ToolbarAction
active={activeTool === "mind"}
icon={Network}
label="主题"
onClick={() => onToolChange("mind")}
/>
<ToolbarAction
disabled={!canEditMind}
icon={GitBranchPlus}
label="子主题"
onClick={onInsertChild}
/>
<ToolbarAction
disabled={!canEditMind}
icon={ListPlus}
label="同级主题"
onClick={onInsertSibling}
/>
<ToolbarAction
active={relationshipPending}
disabled={!canEditMind && !relationshipPending}
icon={ArrowUpRight}
label="联系"
onClick={onRelationship}
/>
<ToolbarAction
disabled={!canEditMind}
icon={Shapes}
label="外框"
onClick={onBoundary}
/>
<ToolbarAction
disabled={!canEditMind}
icon={GitMerge}
label="概要"
onClick={onSummary}
/>
<ToolbarAction disabled={!canEditMind} icon={StickyNote} label="备注" onClick={() => onTopicDetails("notes")} />
<ToolbarAction disabled={!canEditMind} icon={Link2} label="链接" onClick={() => onTopicDetails("links")} />
<ToolbarAction disabled={!canEditMind} icon={Tag} label="标签" onClick={() => onTopicDetails("labels")} />
<ToolbarAction icon={Tag} label="标记" onClick={onToggleMarkers} />
<ToolbarAction
active={activeTool === "text"}
icon={Type}
label="文本"
onClick={() => onToolChange("text")}
/>
<ToolbarAction
active={activeTool === "image"}
icon={ImageIcon}
label="图片"
onClick={() => onToolChange("image")}
/>
</nav>
</div>
</header>
);
}
export function XmindFormatPanel({
activeLayoutId,
activeThemeId,
branchColor,
branchShape,
branchWidth,
canvasBackgroundColor,
canvasGridColor,
canvasGridEnabled,
canvasGridSize,
fontSize,
isBold,
isItalic,
isUnderlined,
layouts,
nodeFill,
nodeShape,
onBranchColorChange,
onBranchShapeChange,
onBranchWidthChange,
onCanvasBackgroundColorChange,
onCanvasGridColorChange,
onCanvasGridEnabledChange,
onCanvasGridSizeChange,
onClose,
onFontSizeChange,
onLayoutChange,
onNodeFillChange,
onNodeShapeChange,
onTextColorChange,
onThemeChange,
onToggleTextMark,
selectedCount,
textColor,
themes,
}: {
activeLayoutId: string;
activeThemeId: string;
branchColor: string;
branchShape: "bight" | "polyline";
branchWidth: number;
canvasBackgroundColor: string;
canvasGridColor: string;
canvasGridEnabled: boolean;
canvasGridSize: number;
fontSize: string;
isBold: boolean;
isItalic: boolean;
isUnderlined: boolean;
layouts: FlowBoardLayoutOption[];
nodeFill: string;
nodeShape: "round-rectangle" | "underline";
onBranchColorChange: (value: string) => void;
onBranchShapeChange: (value: "bight" | "polyline") => void;
onBranchWidthChange: (value: number) => void;
onCanvasBackgroundColorChange: (value: string) => void;
onCanvasGridColorChange: (value: string) => void;
onCanvasGridEnabledChange: (value: boolean) => void;
onCanvasGridSizeChange: (value: number) => void;
onClose: () => void;
onFontSizeChange: (value: string) => void;
onLayoutChange: (value: string) => void;
onNodeFillChange: (value: string) => void;
onNodeShapeChange: (value: "round-rectangle" | "underline") => void;
onTextColorChange: (value: string) => void;
onThemeChange: (value: string) => void;
onToggleTextMark: (mark: "bold" | "italic" | "underlined") => void;
selectedCount: number;
textColor: string;
themes: FlowBoardThemeOption[];
}) {
const [activePanel, setActivePanel] = useState<"format" | "canvas">(
"format"
);
const backgroundPresets = [
"#ffffff",
"#f7f8fb",
"#f1f5f9",
"#fff8e7",
"#eef8f1",
"#f5f0ff",
];
return (
<aside
className="absolute right-0 top-[94px] bottom-9 z-30 flex w-full flex-col border-[#dfe2e7] border-l bg-white sm:w-[304px]"
data-flow-board-shell-control="true"
>
<div className="flex h-11 items-stretch border-[#eceef1] border-b px-3">
{[
{ id: "format" as const, label: "格式" },
{ id: "canvas" as const, label: "画布" },
].map((panel) => (
<button
aria-pressed={activePanel === panel.id}
className={`relative min-w-14 px-2 font-medium text-[13px] transition-colors ${
activePanel === panel.id
? "text-[#3569e8] after:absolute after:inset-x-2 after:bottom-0 after:h-0.5 after:bg-[#3569e8]"
: "text-[#69707c] hover:text-[#262a31]"
}`}
key={panel.id}
onClick={() => setActivePanel(panel.id)}
type="button"
>
{panel.label}
</button>
))}
{activePanel === "format" && selectedCount > 0 ? (
<span className="my-auto ml-1 text-[#9aa0aa] text-[11px]">
已选 {selectedCount} 项
</span>
) : null}
<button
aria-label="关闭格式面板"
className="ml-auto flex h-7 w-7 items-center justify-center text-[#737985] hover:bg-[#f2f3f5] hover:text-[#202329]"
onClick={onClose}
type="button"
>
<X className="h-4 w-4" />
</button>
</div>
{activePanel === "format" ? (
<div className="min-h-0 flex-1 overflow-y-auto">
<section className="border-[#eceef1] border-b px-4 py-4">
<div className="mb-3 flex items-center justify-between">
<span className="font-medium text-[#3e434c] text-xs">结构</span>
<ChevronDown className="h-3.5 w-3.5 text-[#9ba1ab]" />
</div>
<div className="grid grid-cols-3 gap-2">
{layouts.map((layout) => (
<button
aria-label={layout.label}
aria-pressed={activeLayoutId === layout.id}
className={`flex h-[62px] flex-col items-center justify-center border text-[10px] transition-colors ${
activeLayoutId === layout.id
? "border-[#4f7ee8] bg-[#f2f6ff] text-[#3569e8]"
: "border-[#e1e4e8] bg-white text-[#6b7280] hover:border-[#b8c6e8]"
}`}
key={layout.id}
onClick={() => onLayoutChange(layout.id)}
title={layout.label}
type="button"
>
<LayoutPreview direction={layout.preview} />
<span>{layout.label}</span>
</button>
))}
</div>
</section>
<section className="border-[#eceef1] border-b px-4 py-4">
<div className="mb-3 font-medium text-[#3e434c] text-xs">彩虹色</div>
<div className="grid grid-cols-3 gap-2">
{themes.map((theme) => (
<button
aria-label={theme.label}
className={`h-14 border px-2 text-left transition-colors ${
activeThemeId === theme.id
? "border-[#4f7ee8] bg-[#f2f6ff]"
: "border-[#e1e4e8] hover:border-[#b8c6e8]"
}`}
key={theme.id}
onClick={() => onThemeChange(theme.id)}
title={theme.label}
type="button"
>
<span className="mb-2 flex h-3 overflow-hidden">
{theme.colors.map((color) => (
<span className="flex-1" key={color} style={{ background: color }} />
))}
</span>
<span className="block text-center text-[#555b66] text-[10px]">
{theme.label}
</span>
</button>
))}
</div>
</section>
<section className="border-[#eceef1] border-b px-4 py-4">
<div className="mb-3 font-medium text-[#3e434c] text-xs">主题样式</div>
<div className="grid grid-cols-[88px_1fr] items-center gap-y-3 text-xs">
<span className="text-[#777e89]">节点形状</span>
<div className="grid grid-cols-2 gap-2">
<button
aria-label="圆角矩形节点"
className={`flex h-8 items-center justify-center border ${
nodeShape === "round-rectangle"
? "border-[#4f7ee8] bg-[#f2f6ff] text-[#3569e8]"
: "border-[#e1e4e8] text-[#747b86] hover:border-[#b8c6e8]"
}`}
disabled={selectedCount === 0}
onClick={() => onNodeShapeChange("round-rectangle")}
title="圆角矩形"
type="button"
>
<span className="h-4 w-9 rounded-[5px] border-2 border-current" />
</button>
<button
aria-label="下划线节点"
className={`flex h-8 items-center justify-center border ${
nodeShape === "underline"
? "border-[#4f7ee8] bg-[#f2f6ff] text-[#3569e8]"
: "border-[#e1e4e8] text-[#747b86] hover:border-[#b8c6e8]"
}`}
disabled={selectedCount === 0}
onClick={() => onNodeShapeChange("underline")}
title="下划线"
type="button"
>
<span className="h-3 w-9 border-current border-b-2" />
</button>
</div>
<span className="text-[#777e89]">填充</span>
<div className="flex items-center gap-2">
<input
aria-label="主题填充颜色"
className="h-7 w-9 cursor-pointer border border-[#dfe2e7] bg-transparent p-0.5"
disabled={selectedCount === 0}
onChange={(event) => onNodeFillChange(event.currentTarget.value)}
type="color"
value={nodeFill}
/>
<span className="font-mono text-[#686f7a] text-[11px] uppercase">
{nodeFill}
</span>
</div>
<span className="text-[#777e89]">分支颜色</span>
<div className="flex items-center gap-2">
<input
aria-label="分支颜色"
className="h-7 w-9 cursor-pointer border border-[#dfe2e7] bg-transparent p-0.5"
disabled={selectedCount === 0}
onChange={(event) => onBranchColorChange(event.currentTarget.value)}
type="color"
value={branchColor}
/>
<span className="font-mono text-[#686f7a] text-[11px] uppercase">
{branchColor}
</span>
</div>
<span className="text-[#777e89]">分支粗细</span>
<div className="flex items-center gap-2">
{[1, 2, 3, 4].map((width) => (
<button
aria-label={`分支粗细 ${width}`}
className={`flex h-7 w-8 items-center justify-center border ${
branchWidth === width
? "border-[#4f7ee8] bg-[#f2f6ff]"
: "border-[#e1e4e8] hover:border-[#b8c6e8]"
}`}
disabled={selectedCount === 0}
key={width}
onClick={() => onBranchWidthChange(width)}
type="button"
>
<span
className="block w-4 bg-[#596170]"
style={{ height: `${width}px` }}
/>
</button>
))}
</div>
<span className="text-[#777e89]">分支形状</span>
<div className="grid grid-cols-2 gap-2">
<button
aria-label="曲线分支"
className={`flex h-8 items-center justify-center border ${
branchShape === "bight"
? "border-[#4f7ee8] bg-[#f2f6ff] text-[#3569e8]"
: "border-[#e1e4e8] text-[#747b86] hover:border-[#b8c6e8]"
}`}
disabled={selectedCount === 0}
onClick={() => onBranchShapeChange("bight")}
title="曲线"
type="button"
>
<span className="h-4 w-9 rounded-br-full border-current border-b-2 border-r-2" />
</button>
<button
aria-label="折线分支"
className={`flex h-8 items-center justify-center border ${
branchShape === "polyline"
? "border-[#4f7ee8] bg-[#f2f6ff] text-[#3569e8]"
: "border-[#e1e4e8] text-[#747b86] hover:border-[#b8c6e8]"
}`}
disabled={selectedCount === 0}
onClick={() => onBranchShapeChange("polyline")}
title="折线"
type="button"
>
<span className="relative h-4 w-9">
<span className="absolute left-0 top-1.5 h-2 w-4 border-current border-b-2 border-r-2" />
<span className="absolute right-0 top-3.5 h-0.5 w-5 bg-current" />
</span>
</button>
</div>
</div>
</section>
<section className="border-[#eceef1] border-b px-4 py-4">
<div className="mb-3 font-medium text-[#3e434c] text-xs">文字</div>
<div className="grid grid-cols-[88px_1fr] items-center gap-y-3 text-xs">
<span className="text-[#777e89]">字号</span>
<select
aria-label="节点字号"
className="h-8 border border-[#dfe2e7] bg-white px-2 text-[#4f5663] outline-none focus:border-[#7798e5] disabled:opacity-45"
disabled={selectedCount === 0}
onChange={(event) => onFontSizeChange(event.currentTarget.value)}
value={fontSize}
>
{[...new Set([12, 13, 14, 15, 16, 18, 20, 24, 28, 32, 40, 48, Number(fontSize)])].filter(Number.isFinite).sort((a, b) => a - b).map(
(size) => (
<option key={size} value={String(size)}>
{size}
</option>
)
)}
</select>
<span className="text-[#777e89]">颜色</span>
<div className="flex items-center gap-2">
<input
aria-label="节点文字颜色"
className="h-7 w-9 cursor-pointer border border-[#dfe2e7] bg-transparent p-0.5"
disabled={selectedCount === 0}
onChange={(event) => onTextColorChange(event.currentTarget.value)}
type="color"
value={textColor}
/>
<span className="font-mono text-[#686f7a] text-[11px] uppercase">
{textColor}
</span>
</div>
<span className="text-[#777e89]">字形</span>
<div className="flex items-center gap-1">
{[
{ active: isBold, icon: Bold, id: "bold" as const, label: "粗体" },
{
active: isItalic,
icon: Italic,
id: "italic" as const,
label: "斜体",
},
{
active: isUnderlined,
icon: Underline,
id: "underlined" as const,
label: "下划线",
},
].map((item) => {
const MarkIcon = item.icon;
return (
<button
aria-label={item.label}
className={`flex h-8 w-9 items-center justify-center border ${
item.active
? "border-[#4f7ee8] bg-[#f2f6ff] text-[#3569e8]"
: "border-[#e1e4e8] text-[#626975] hover:border-[#b8c6e8]"
}`}
disabled={selectedCount === 0}
key={item.id}
onClick={() => onToggleTextMark(item.id)}
title={item.label}
type="button"
>
<MarkIcon className="h-4 w-4" />
</button>
);
})}
</div>
</div>
</section>
</div>
) : (
<div className="min-h-0 flex-1 overflow-y-auto">
<section className="border-[#eceef1] border-b px-4 py-4">
<div className="mb-3 font-medium text-[#3e434c] text-xs">背景</div>
<div className="grid grid-cols-6 gap-2">
{backgroundPresets.map((color) => (
<button
aria-label={`画布背景 ${color}`}
className={`h-8 border transition-shadow ${
canvasBackgroundColor === color
? "border-[#4f7ee8] ring-1 ring-[#4f7ee8]"
: "border-[#dfe2e7] hover:border-[#aeb8ca]"
}`}
key={color}
onClick={() => onCanvasBackgroundColorChange(color)}
style={{ backgroundColor: color }}
title={color}
type="button"
/>
))}
</div>
<div className="mt-3 flex items-center justify-between">
<span className="text-[#777e89] text-xs">自定义颜色</span>
<div className="flex items-center gap-2">
<input
aria-label="画布背景颜色"
className="h-7 w-9 cursor-pointer border border-[#dfe2e7] bg-transparent p-0.5"
onChange={(event) =>
onCanvasBackgroundColorChange(event.currentTarget.value)
}
type="color"
value={canvasBackgroundColor}
/>
<span className="font-mono text-[#686f7a] text-[11px] uppercase">
{canvasBackgroundColor}
</span>
</div>
</div>
</section>
<section className="border-[#eceef1] border-b px-4 py-4">
<div className="mb-4 flex items-center justify-between">
<span className="font-medium text-[#3e434c] text-xs">网格</span>
<button
aria-label="显示画布网格"
aria-checked={canvasGridEnabled}
className={`relative h-5 w-9 transition-colors ${
canvasGridEnabled ? "bg-[#4f7ee8]" : "bg-[#c8ccd3]"
}`}
onClick={() => onCanvasGridEnabledChange(!canvasGridEnabled)}
role="switch"
type="button"
>
<span
className={`absolute top-0.5 h-4 w-4 bg-white shadow-sm transition-transform ${
canvasGridEnabled ? "translate-x-[18px]" : "translate-x-0.5"
}`}
/>
</button>
</div>
<div className="grid grid-cols-[72px_1fr] items-center gap-y-4 text-xs">
<span className="text-[#777e89]">网格颜色</span>
<div className="flex items-center gap-2">
<input
aria-label="网格颜色"
className="h-7 w-9 cursor-pointer border border-[#dfe2e7] bg-transparent p-0.5 disabled:opacity-40"
disabled={!canvasGridEnabled}
onChange={(event) =>
onCanvasGridColorChange(event.currentTarget.value)
}
type="color"
value={canvasGridColor}
/>
<span className="font-mono text-[#686f7a] text-[11px] uppercase">
{canvasGridColor}
</span>
</div>
<span className="text-[#777e89]">网格间距</span>
<div className="grid grid-cols-4 border border-[#dfe2e7]">
{[16, 24, 32, 40].map((size) => (
<button
aria-label={`网格间距 ${size}`}
className={`h-8 border-[#dfe2e7] border-r text-[11px] last:border-r-0 disabled:opacity-40 ${
canvasGridSize === size
? "bg-[#eef3ff] text-[#3569e8]"
: "text-[#68707c] hover:bg-[#f5f6f8]"
}`}
disabled={!canvasGridEnabled}
key={size}
onClick={() => onCanvasGridSizeChange(size)}
type="button"
>
{size}
</button>
))}
</div>
</div>
<div
aria-label="画布样式预览"
className="mt-4 h-28 border border-[#dfe2e7]"
style={{
backgroundColor: canvasBackgroundColor,
backgroundImage: canvasGridEnabled
? `radial-gradient(${canvasGridColor} 1px, transparent 1px)`
: "none",
backgroundPosition: "-1px -1px",
backgroundSize: `${canvasGridSize}px ${canvasGridSize}px`,
}}
/>
</section>
</div>
)}
</aside>
);
}
export function XmindSheetBar({
outlineOpen,
onToggleOutline,
activeSheetId,
formatPanelOpen,
onAddSheet,
onDeleteSheet,
onDuplicateSheet,
onFit,
onRenameSheet,
onSwitchSheet,
onZoomIn,
onZoomOut,
sheets,
zoomPercent,
}: {
outlineOpen: boolean;
onToggleOutline: () => void;
activeSheetId: string;
formatPanelOpen: boolean;
onAddSheet: () => void;
onDeleteSheet: (id: string) => void;
onDuplicateSheet: (id: string) => void;
onFit: () => void;
onRenameSheet: (id: string, title: string) => void;
onSwitchSheet: (id: string) => void;
onZoomIn: () => void;
onZoomOut: () => void;
sheets: FlowBoardSheetTab[];
zoomPercent: number;
}) {
const [renamingSheetId, setRenamingSheetId] = useState<string | null>(null);
const [renameDraft, setRenameDraft] = useState("");
const activeSheet = sheets.find((sheet) => sheet.id === activeSheetId);
const commitRename = () => {
if (!renamingSheetId) {
return;
}
const nextTitle = renameDraft.trim();
if (nextTitle) {
onRenameSheet(renamingSheetId, nextTitle);
}
setRenamingSheetId(null);
setRenameDraft("");
};
return (
<footer
className={`absolute bottom-0 left-0 z-40 flex h-9 items-center border-[#dfe2e7] border-t bg-white transition-[right] ${
formatPanelOpen ? "right-0 sm:right-[304px]" : "right-0"
}`}
data-flow-board-shell-control="true"
>
<div className="flex min-w-0 flex-1 items-stretch overflow-x-auto">
{sheets.map((sheet) => (
<div
className={`relative flex h-9 min-w-[104px] max-w-[190px] items-center border-[#eceef1] border-r text-xs transition-colors ${
sheet.id === activeSheetId
? "bg-white font-medium text-[#2e61d3]"
: "bg-[#f7f8fa] text-[#6f7682] hover:bg-white hover:text-[#2d3138]"
}`}
key={sheet.id}
>
{renamingSheetId === sheet.id ? (
<input
aria-label="重命名画布"
autoFocus
className="mx-1 h-7 min-w-0 flex-1 border border-[#7798e5] bg-white px-2 text-[#2d3138] text-xs outline-none"
maxLength={80}
onBlur={commitRename}
onChange={(event) => setRenameDraft(event.currentTarget.value)}
onKeyDown={(event) => {
if (event.key === "Enter") {
commitRename();
} else if (event.key === "Escape") {
setRenamingSheetId(null);
setRenameDraft("");
}
}}
value={renameDraft}
/>
) : (
<button
className="flex h-full min-w-0 flex-1 items-center gap-2 px-3"
onClick={() => onSwitchSheet(sheet.id)}
onDoubleClick={() => {
setRenamingSheetId(sheet.id);
setRenameDraft(sheet.title);
}}
title={`${sheet.title}(双击重命名)`}
type="button"
>
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{sheet.title}</span>
</button>
)}
{sheet.id === activeSheetId ? (
<span className="absolute inset-x-0 bottom-0 h-0.5 bg-[#4f7ee8]" />
) : null}
</div>
))}
<button
aria-label="新增画布"
className="flex h-9 w-10 shrink-0 items-center justify-center border-[#eceef1] border-r bg-[#f7f8fa] text-[#6f7682] hover:bg-white hover:text-[#2d3138]"
onClick={onAddSheet}
title="新增画布"
type="button"
>
<Plus className="h-4 w-4" />
</button>
<div className="flex h-9 shrink-0 items-center border-[#eceef1] border-r bg-[#f7f8fa] px-0.5">
<IconAction
disabled={!activeSheet}
label="重命名当前画布"
onClick={() => {
if (!activeSheet) {
return;
}
setRenamingSheetId(activeSheet.id);
setRenameDraft(activeSheet.title);
}}
>
<Pencil className="h-3.5 w-3.5" />
</IconAction>
<IconAction
disabled={!activeSheet}
label="复制当前画布"
onClick={() => activeSheet && onDuplicateSheet(activeSheet.id)}
>
<Copy className="h-3.5 w-3.5" />
</IconAction>
<IconAction
disabled={sheets.length <= 1 || !activeSheet}
label="删除当前画布"
onClick={() => activeSheet && onDeleteSheet(activeSheet.id)}
>
<Trash2 className="h-3.5 w-3.5" />
</IconAction>
</div>
</div>
<div className="ml-auto flex h-full shrink-0 items-center border-[#eceef1] border-l bg-white px-1">
<button type="button" aria-label={outlineOpen ? "切换到思维导图" : "切换到大纲"} aria-pressed={outlineOpen} onClick={onToggleOutline} className="h-8 border-r border-[#eceef1] px-3 text-xs text-[#3569e8] hover:bg-[#edf3ff]">{outlineOpen ? "思维导图" : "大纲"}</button>
<IconAction label="缩小" onClick={onZoomOut}>
<Minus className="h-3.5 w-3.5" />
</IconAction>
<button
className="h-8 min-w-12 px-1 text-[#555c67] text-[11px] hover:bg-[#f2f3f5]"
onClick={onFit}
title="适应画布"
type="button"
>
{zoomPercent}%
</button>
<IconAction label="放大" onClick={onZoomIn}>
<Plus className="h-3.5 w-3.5" />
</IconAction>
<IconAction label="适应画布" onClick={onFit}>
<Maximize className="h-3.5 w-3.5" />
</IconAction>
</div>
</footer>
);
}