Add native task metadata, working-day calendar settings, dependency scheduling and rich note editing. Support complete-sheet PNG export with resolution and transparency controls, preserve XMind extensions and update browser regressions.
471 lines
25 KiB
TypeScript
471 lines
25 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
CalendarDays,
|
||
ChartGantt,
|
||
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, markerPanelOpen, isSaving, onBack,
|
||
onImport, onExport, onExportImage, isExporting, onInsertChild, onInsertSibling,
|
||
onRelationship, onBoundary, relationshipPending, onOpenFiles, onRedo,
|
||
onSave, onSummary, onTopicDetails, onTitleChange, onTitleCommit,
|
||
onToggleFormat, onToggleMarkers, onToolChange, onUndo, siteName,
|
||
onSearch, onToggleZen, onToggleOutline, onPresentation, onTask, onGantt,
|
||
}: {
|
||
activeTool: FlowBoardToolId;
|
||
canEditMind: boolean;
|
||
currentTitle: string;
|
||
formatPanelOpen: boolean;
|
||
markerPanelOpen: boolean;
|
||
isSaving: boolean;
|
||
onBack: () => void;
|
||
onImport: () => void;
|
||
onExport: () => void;
|
||
onExportImage: () => 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;
|
||
onTask: () => void;
|
||
onGantt: () => void;
|
||
onSearch: () => void;
|
||
onToggleZen: () => void;
|
||
onToggleOutline: () => void;
|
||
onPresentation: () => void;
|
||
}) {
|
||
const nativeIcons: Record<string,string> = {"同级主题":"topic","子主题":"subtopic","联系":"relationship","概要":"summary","外框":"boundary","ZEN 模式":"zen","演示":"pitch","格式":"sidebar","标记":"marker","导出 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:ImageIcon,disabled:isExporting,run:onExportImage},
|
||
{label:"撤销",icon:Undo2,shortcut:"⌘Z",separator:true,run:onUndo},
|
||
{label:"重做",icon:Redo2,shortcut:"⇧⌘Z",run:onRedo},
|
||
{label:"甘特图",icon:ChartGantt,run:onGantt},
|
||
{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:CalendarDays,disabled:!canEditMind,run:onTask},
|
||
{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)}<XmindCommandMenu label="更多导出选项" align="right" commands={[
|
||
{label:"导出图片",icon:ImageIcon,disabled:isExporting,run:onExportImage},
|
||
{label:"导出 XMind",icon:Download,disabled:isExporting,run:onExport},
|
||
]}><span className="sr-only">导出</span></XmindCommandMenu></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, false, markerPanelOpen)}
|
||
{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>;
|
||
}
|