1085 lines
38 KiB
TypeScript
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>
|
|
);
|
|
}
|