Add native-style menus, search and presentation controls, typography resources, and Xmind import/export styling support. Bundle canvas styles for preview and production, show marquee borders and selection fills, and align editor density with the project UI. Include interaction regression coverage and client comparison notes.
459 lines
24 KiB
TypeScript
459 lines
24 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";
|
||
import { Home, Smile, Scan, Search, MoreHorizontal, AlignLeft, AlignCenter, AlignRight, Presentation, SlidersHorizontal } from "lucide-react";
|
||
import { XmindCommandMenu } from "./xmind-command-menu";
|
||
|
||
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];
|
||
};
|
||
|
||
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, siteName,
|
||
onSearch, onToggleZen, onToggleOutline, onPresentation,
|
||
}: {
|
||
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;
|
||
onSearch: () => void;
|
||
onToggleZen: () => void;
|
||
onToggleOutline: () => void;
|
||
onPresentation: () => void;
|
||
}) {
|
||
const nativeIcons: Record<string,string> = {"同级主题":"topic","子主题":"subtopic","联系":"relationship","概要":"summary","外框":"boundary","ZEN 模式":"zen","演示":"pitch","格式":"sidebar","导出 XMind":"export"};
|
||
const action = (label: string, Icon: LucideIcon, run: () => void, disabled = false, active = false) => (
|
||
<button aria-label={label} title={label} type="button" disabled={disabled} aria-pressed={active}
|
||
className={`xmind-icon-button ${active ? "is-active" : ""}`} onClick={run}>{nativeIcons[label] ? <span aria-hidden="true" className="xmind-native-icon" style={{maskImage:`url(/icons/xmind/${nativeIcons[label]}.svg)`}} /> : <Icon size={20} strokeWidth={1.8} />}</button>
|
||
);
|
||
return <header className="xmind-topbar" data-flow-board-shell-control="true">
|
||
<div className="xmind-document-heading">
|
||
<button type="button" aria-label="返回首页" title={`返回 ${siteName}`} className="xmind-home-button" onClick={onBack}><Home size={21} strokeWidth={1.8} /></button>
|
||
<div className="xmind-document-name">
|
||
<input aria-label="白板标题" value={currentTitle} maxLength={256} onChange={e => onTitleChange(e.target.value)}
|
||
onBlur={onTitleCommit} onKeyDown={e => { if (e.key === "Enter") e.currentTarget.blur(); }} />
|
||
<span role="status">{isSaving ? "正在保存…" : "已编辑"}</span>
|
||
</div>
|
||
<XmindCommandMenu label="文件与编辑" className="xmind-document-menu" commands={[
|
||
{label:"保存",icon:Save,shortcut:"⌘S",run:onSave},
|
||
{label:"云文件",icon:FolderOpen,run:onOpenFiles},
|
||
{label:"导入",icon:Upload,run:onImport},
|
||
{label:"导出 XMind",icon:Download,disabled:isExporting,run:onExport},
|
||
{label:"撤销",icon:Undo2,shortcut:"⌘Z",separator:true,run:onUndo},
|
||
{label:"重做",icon:Redo2,shortcut:"⇧⌘Z",run:onRedo},
|
||
{label:"查找与替换",icon:Search,shortcut:"⌘F",separator:true,run:onSearch},
|
||
{label:"选择",icon:MousePointer2,checked:activeTool === "selection",separator:true,run:() => onToolChange("selection")},
|
||
{label:"画布",icon:Hand,checked:activeTool === "hand",run:() => onToolChange("hand")},
|
||
]}><MoreHorizontal size={17} /></XmindCommandMenu>
|
||
</div>
|
||
<nav className="xmind-pointer-toolbar" aria-label="画布工具">
|
||
<button type="button" aria-label="选择工具" title="选择 · V(空白处拖动框选)"
|
||
aria-pressed={activeTool === "selection"} className={`xmind-icon-button ${activeTool === "selection" ? "is-active" : ""}`} onClick={() => onToolChange("selection")}><MousePointer2 size={17} strokeWidth={1.8} /></button>
|
||
<button type="button" aria-label="拖动画布" title="拖动画布 · H(也可按住空格拖动)"
|
||
aria-pressed={activeTool === "hand"} className={`xmind-icon-button ${activeTool === "hand" ? "is-active" : ""}`} onClick={() => onToolChange("hand")}><Hand size={17} strokeWidth={1.8} /></button>
|
||
</nav>
|
||
<nav className="xmind-primary-tools" aria-label="主题工具">
|
||
<div className="xmind-toolbar-pill">
|
||
{action("同级主题", ListPlus, onInsertSibling, !canEditMind)}
|
||
{action("子主题", GitBranchPlus, onInsertChild, !canEditMind)}
|
||
</div>
|
||
<div className="xmind-toolbar-pill">
|
||
{action("联系", ArrowUpRight, onRelationship, !canEditMind && !relationshipPending, relationshipPending)}
|
||
{action("概要", GitMerge, onSummary, !canEditMind)}
|
||
{action("外框", Shapes, onBoundary, !canEditMind)}
|
||
<XmindCommandMenu label="插入" commands={[
|
||
{label:"主题",icon:Network,run:() => onToolChange("mind")},
|
||
{label:"备注",icon:StickyNote,shortcut:"⇧⌘N",disabled:!canEditMind,separator:true,run:() => onTopicDetails("notes")},
|
||
{label:"链接",icon:Link2,disabled:!canEditMind,run:() => onTopicDetails("links")},
|
||
{label:"标签",icon:Tag,disabled:!canEditMind,run:() => onTopicDetails("labels")},
|
||
{label:"图片",icon:ImageIcon,separator:true,run:() => onToolChange("image")},
|
||
{label:"文本",icon:Type,run:() => onToolChange("text")},
|
||
{label:"形状",icon:Shapes,run:() => onToolChange("shape")},
|
||
{label:"箭头",icon:ArrowUpRight,run:() => onToolChange("arrow")},
|
||
]}><Plus size={23} strokeWidth={1.7} /></XmindCommandMenu>
|
||
</div>
|
||
</nav>
|
||
<div className="xmind-utility-tools">
|
||
<div className="xmind-toolbar-pill">{action("导出 XMind", Download, onExport, isExporting)}</div>
|
||
<div className="xmind-toolbar-pill">
|
||
{action("ZEN 模式", Scan, onToggleZen)}
|
||
{action("大纲视图", FileText, onToggleOutline)}
|
||
{action("演示", Presentation, onPresentation)}
|
||
</div>
|
||
<div className="xmind-toolbar-pill">
|
||
{action("标记", Smile, onToggleMarkers)}
|
||
{action("格式", PanelRight, onToggleFormat, false, formatPanelOpen)}
|
||
</div>
|
||
</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 [structuresOpen, setStructuresOpen] = useState(false);
|
||
const activeLayout = layouts.find(layout => layout.id === activeLayoutId) || layouts[0];
|
||
const colorControl = (label: string, value: string, change: (value: string) => void, disabled = false) => (
|
||
<input type="color" aria-label={label} value={/^#[0-9a-f]{6}$/i.test(value) ? value : "#ffffff"}
|
||
disabled={disabled} onChange={e => change(e.target.value)} className="xmind-color-control" />
|
||
);
|
||
return <aside className="xmind-format-panel" aria-label="格式面板" data-flow-board-shell-control="true">
|
||
<div className="xmind-panel-heading"><span>格式</span><button type="button" aria-label="关闭格式面板" onClick={onClose}><X size={16} /></button></div>
|
||
<div className="xmind-panel-tabs" role="tablist" aria-label="格式分类">
|
||
<button type="button" role="tab" aria-selected={activePanel === "format"} onClick={() => setActivePanel("format")}>样式</button>
|
||
<button type="button" role="tab" aria-selected={activePanel === "canvas"} onClick={() => setActivePanel("canvas")}>画布</button>
|
||
</div>
|
||
<div className="xmind-panel-content">
|
||
{activePanel === "format" ? <>
|
||
<div className="xmind-topic-preview"><span style={{ backgroundColor: nodeFill, color: textColor,
|
||
fontWeight: isBold ? 700 : 500, fontStyle: isItalic ? "italic" : "normal", borderRadius: nodeShape === "underline" ? 0 : 8 }}>{selectedCount ? "分支主题" : "中心主题"}</span><ChevronDown size={14} /></div>
|
||
<section className="xmind-format-section">
|
||
<h3><ChevronDown size={13} />形状</h3>
|
||
<div className="xmind-segmented">
|
||
<button type="button" aria-label="圆角矩形节点" aria-pressed={nodeShape === "round-rectangle"} disabled={!selectedCount} onClick={() => onNodeShapeChange("round-rectangle")}><span className="xmind-shape-rectangle" /></button>
|
||
<button type="button" aria-label="下划线节点" aria-pressed={nodeShape === "underline"} disabled={!selectedCount} onClick={() => onNodeShapeChange("underline")}><span className="xmind-shape-line" /></button>
|
||
</div>
|
||
<label className="xmind-setting-row"><span>填充</span>{colorControl("节点填充颜色", nodeFill, onNodeFillChange, !selectedCount)}</label>
|
||
</section>
|
||
<section className="xmind-format-section">
|
||
<h3><ChevronDown size={13} />文本</h3>
|
||
<label className="xmind-setting-row"><span>字号</span><select aria-label="节点字号" value={fontSize} disabled={!selectedCount} onChange={e => onFontSizeChange(e.target.value)}>
|
||
{Array.from(new Set([12,13,14,15,16,18,20,24,28,30,32,40,48,Number(fontSize)].filter(size => size > 0))).sort((a,b) => a-b).map(size => <option value={size} key={size}>{size}</option>)}
|
||
</select></label>
|
||
<label className="xmind-setting-row"><span>颜色</span>{colorControl("文字颜色", textColor, onTextColorChange, !selectedCount)}</label>
|
||
<div className="xmind-segmented xmind-text-marks">
|
||
{[{icon:Bold,id:"bold" as const,label:"粗体",active:isBold},{icon:Italic,id:"italic" as const,label:"斜体",active:isItalic},{icon:Underline,id:"underlined" as const,label:"下划线",active:isUnderlined}].map(({icon:Icon,id,label,active}) =>
|
||
<button type="button" key={id} aria-label={label} aria-pressed={active} disabled={!selectedCount} onClick={() => onToggleTextMark(id)}><Icon size={18} /></button>)}
|
||
</div>
|
||
</section>
|
||
<section className="xmind-format-section">
|
||
<div className="xmind-setting-row"><h3><ChevronDown size={13} />结构</h3><button type="button" aria-label="选择结构" aria-expanded={structuresOpen} className="xmind-structure-trigger" onClick={() => setStructuresOpen(value => !value)}><LayoutPreview direction={activeLayout.preview} /><ChevronDown size={12} /></button></div>
|
||
<div className="xmind-current-structure">{activeLayout.label}</div>
|
||
{structuresOpen ? <div className="xmind-structure-grid">
|
||
{layouts.map(layout => <button type="button" key={layout.id} aria-label={layout.label} aria-pressed={activeLayoutId === layout.id}
|
||
onClick={() => { onLayoutChange(layout.id); setStructuresOpen(false); }}><LayoutPreview direction={layout.preview} /><span>{layout.label}</span></button>)}
|
||
</div> : null}
|
||
</section>
|
||
<section className="xmind-format-section">
|
||
<h3><ChevronDown size={13} />分支</h3>
|
||
<label className="xmind-setting-row"><span>线条</span><select aria-label="分支形状" value={branchShape} disabled={!selectedCount} onChange={e => onBranchShapeChange(e.target.value as "bight" | "polyline")}><option value="bight">曲线</option><option value="polyline">折线</option></select></label>
|
||
<label className="xmind-setting-row"><span>粗细</span><select aria-label="分支粗细" value={branchWidth} disabled={!selectedCount} onChange={e => onBranchWidthChange(Number(e.target.value))}>{[1,2,3,4,5,6,8].map(width => <option key={width} value={width}>{width} px</option>)}</select></label>
|
||
<label className="xmind-setting-row"><span>颜色</span>{colorControl("分支颜色", branchColor, onBranchColorChange, !selectedCount)}</label>
|
||
</section>
|
||
</> : <>
|
||
<section className="xmind-format-section">
|
||
<h3><ChevronDown size={13} />背景</h3>
|
||
<div className="xmind-background-presets">{["#ffffff","#f7f8fb","#f1f5f9","#fff8e7","#eef8f1","#f5f0ff"].map(color => <button type="button" key={color} aria-label={`画布背景 ${color}`} aria-pressed={color === canvasBackgroundColor} style={{backgroundColor:color}} onClick={() => onCanvasBackgroundColorChange(color)} />)}</div>
|
||
<label className="xmind-setting-row"><span>自定义颜色</span>{colorControl("画布背景颜色",canvasBackgroundColor,onCanvasBackgroundColorChange)}</label>
|
||
</section>
|
||
<section className="xmind-format-section"><h3><ChevronDown size={13} />彩虹色</h3><div className="xmind-theme-grid">{themes.map(theme => <button type="button" key={theme.id} aria-label={theme.label} aria-pressed={theme.id === activeThemeId} onClick={() => onThemeChange(theme.id)}><span>{theme.colors.map(color => <i key={color} style={{backgroundColor:color}} />)}</span>{theme.label}</button>)}</div></section>
|
||
<section className="xmind-format-section">
|
||
<div className="xmind-setting-row"><h3><ChevronDown size={13} />网格</h3><button type="button" role="switch" aria-label="显示画布网格" aria-checked={canvasGridEnabled} className="xmind-switch" onClick={() => onCanvasGridEnabledChange(!canvasGridEnabled)}><i /></button></div>
|
||
<label className="xmind-setting-row"><span>网格颜色</span>{colorControl("网格颜色",canvasGridColor,onCanvasGridColorChange,!canvasGridEnabled)}</label>
|
||
<label className="xmind-setting-row"><span>网格间距</span><select aria-label="网格间距" value={canvasGridSize} disabled={!canvasGridEnabled} onChange={e => onCanvasGridSizeChange(Number(e.target.value))}>{[8,16,24,32,40,48,64].map(size => <option key={size} value={size}>{size} px</option>)}</select></label>
|
||
<div aria-label="画布样式预览" className="xmind-canvas-preview" style={{backgroundColor:canvasBackgroundColor,backgroundImage:canvasGridEnabled ? `radial-gradient(${canvasGridColor} 1px, transparent 1px)` : "none",backgroundSize:`${canvasGridSize}px ${canvasGridSize}px`}} />
|
||
</section>
|
||
</>}
|
||
</div>
|
||
</aside>;
|
||
}
|
||
|
||
export function XmindSheetBar({
|
||
outlineOpen, onToggleOutline, activeSheetId, formatPanelOpen, onAddSheet,
|
||
onDeleteSheet, onDuplicateSheet, onFit, onRenameSheet, onSwitchSheet,
|
||
onZoomIn, onZoomOut, onZoomTo, sheets, zoomPercent, topicCount, selectedCount,
|
||
}: {
|
||
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;
|
||
onZoomTo: (value: number) => void;
|
||
sheets: FlowBoardSheetTab[];
|
||
zoomPercent: number;
|
||
topicCount: number;
|
||
selectedCount: number;
|
||
}) {
|
||
const [renamingSheetId, setRenamingSheetId] = useState<string | null>(null);
|
||
const [renameDraft, setRenameDraft] = useState("");
|
||
const [contextId, setContextId] = useState<string | null>(null);
|
||
const activeSheet = sheets.find(sheet => sheet.id === activeSheetId);
|
||
const rename = (id: string) => { const sheet = sheets.find(item => item.id === id); if (sheet) { setRenamingSheetId(id); setRenameDraft(sheet.title); } };
|
||
const commitRename = () => { if (renamingSheetId && renameDraft.trim()) onRenameSheet(renamingSheetId,renameDraft.trim()); setRenamingSheetId(null); };
|
||
return <footer className="xmind-sheetbar" data-flow-board-shell-control="true">
|
||
<div className="xmind-sheet-tabs" role="tablist" aria-label="工作表">
|
||
{sheets.map(sheet => <div className="xmind-sheet-tab-host" key={sheet.id}>
|
||
{renamingSheetId === sheet.id ? <input aria-label="重命名画布" autoFocus value={renameDraft} maxLength={80} onChange={e => setRenameDraft(e.target.value)} onBlur={commitRename} onKeyDown={e => { if (e.key === "Enter") { e.preventDefault(); commitRename(); } if (e.key === "Escape") { e.preventDefault(); setRenamingSheetId(null); } }} /> :
|
||
<button type="button" aria-label={sheet.title} aria-pressed={sheet.id === activeSheetId} onClick={() => onSwitchSheet(sheet.id)} onDoubleClick={() => rename(sheet.id)} onContextMenu={e => { e.preventDefault(); setContextId(contextId === sheet.id ? null : sheet.id); }}>{sheet.title}</button>}
|
||
{contextId === sheet.id ? <div className="xmind-sheet-context" data-flow-board-shell-control="true">
|
||
<button type="button" onClick={() => { rename(sheet.id); setContextId(null); }}>重命名</button>
|
||
<button type="button" onClick={() => { onDuplicateSheet(sheet.id); setContextId(null); }}>复制</button>
|
||
<button type="button" disabled={sheets.length <= 1} onClick={() => { onDeleteSheet(sheet.id); setContextId(null); }}>删除</button>
|
||
<button type="button" onClick={() => setContextId(null)}>关闭菜单</button>
|
||
</div> : null}
|
||
</div>)}
|
||
</div>
|
||
<span className="xmind-footer-divider" />
|
||
<button type="button" aria-label="新增画布" title="新增画布" className="xmind-footer-icon" onClick={onAddSheet}><Plus size={21} strokeWidth={1.5} /></button>
|
||
<XmindCommandMenu label="画布操作" className="xmind-sheet-actions" commands={[
|
||
{label:"重命名当前画布",icon:Pencil,disabled:!activeSheet,run:() => activeSheet && rename(activeSheet.id)},
|
||
{label:"复制当前画布",icon:Copy,disabled:!activeSheet,run:() => activeSheet && onDuplicateSheet(activeSheet.id)},
|
||
{label:"删除当前画布",icon:Trash2,disabled:sheets.length <= 1,run:() => activeSheet && onDeleteSheet(activeSheet.id)},
|
||
]}><MoreHorizontal size={17} /></XmindCommandMenu>
|
||
<div className="xmind-sheet-status"><span className="xmind-topic-count">主题: {selectedCount} / {topicCount}</span><span className="xmind-footer-divider" />
|
||
<XmindCommandMenu label="缩放" align="right" className="xmind-zoom-menu" commands={[
|
||
{label:"放大",icon:Plus,run:onZoomIn},{label:"缩小",icon:Minus,run:onZoomOut},
|
||
...[50,75,100,125,150,200].map(value => ({label:`${value}%`,checked:value === zoomPercent,separator:value === 50,run:() => onZoomTo(value)})),
|
||
{label:"适应画布",icon:Maximize,separator:true,run:onFit},
|
||
]}><span>{zoomPercent}%</span></XmindCommandMenu>
|
||
<span className="xmind-footer-divider" /><button type="button" aria-label={outlineOpen ? "切换到思维导图" : "切换到大纲"} aria-pressed={outlineOpen} className="xmind-outline-toggle" onClick={onToggleOutline}>{outlineOpen ? "思维导图" : "大纲"}</button>
|
||
</div>
|
||
</footer>;
|
||
}
|