feat(flow-board): add rich notes, Gantt tasks and PNG export

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.
This commit is contained in:
134 2026-10-06 17:38:14 +08:00
parent e6a68b345b
commit 23ced0b760
28 changed files with 4375 additions and 67 deletions

View File

@ -106,6 +106,15 @@ import { insertMindAnnotation, withMindAnnotations } from "./mind-annotation-boa
import { isMindAnnotation, validateTopicRange } from "@/lib/flow-board/mind-annotations";
import { XmindAnnotationPanel } from "./xmind-annotation-panel";
import { XmindTopicPanel, type TopicDetailSection } from "./xmind-topic-panel";
import { XmindTaskEditor } from "./xmind-task-editor";
import { XmindGantt } from "./xmind-gantt";
import { XmindProjectCalendar } from "./xmind-project-calendar";
import { XmindImageExport } from "./xmind-image-export";
import type { ImageExportOptions } from "@/lib/flow-board/image-export";
import { calendarRoot, getProjectCalendar, patchProjectCalendar, validateWorkingCalendar, type ProjectCalendarConfig } from "@/lib/flow-board/project-calendar";
import { XmindTopicBadges } from "./xmind-topic-badges";
import { XmindNotesPopover } from "./xmind-notes-popover";
import { getTopicTask, patchTopicTask, scheduleTaskChanges, type TopicTask } from "@/lib/flow-board/topic-tasks";
import { XmindOutline } from "./xmind-outline";
import { withSpecialMindLayouts } from "./mind-special-layout-board";
import { SPECIAL_MIND_LAYOUTS, isSpecialMindLayout } from "@/lib/flow-board/mind-special-layout";
@ -777,6 +786,8 @@ export function DrawnixWorkspace({
const [isFileListLoading, setIsFileListLoading] = useState(false);
const [isSavingNow, setIsSavingNow] = useState(false);
const [isExporting, setIsExporting] = useState(false);
const [isImageExportOpen, setIsImageExportOpen] = useState(false);
const [isExportingImage, setIsExportingImage] = useState(false);
const [isOutlineOpen, setIsOutlineOpen] = useState(false);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [isZenMode, setIsZenMode] = useState(false);
@ -803,6 +814,10 @@ export function DrawnixWorkspace({
}, []);
const [selectedTopicId, setSelectedTopicId] = useState<string | null>(null);
const [isTopicPanelOpen, setIsTopicPanelOpen] = useState(false);
const [isNotesPopoverOpen, setIsNotesPopoverOpen] = useState(false);
const [isGanttOpen, setIsGanttOpen] = useState(false);
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
const [taskTopicId, setTaskTopicId] = useState<string | null>(null);
const [topicDetailSection, setTopicDetailSection] = useState<TopicDetailSection>("notes");
const [annotationId, setAnnotationId] = useState<string | null>(null);
const mindMapFileInputRef = useRef<HTMLInputElement | null>(null);
@ -825,8 +840,8 @@ export function DrawnixWorkspace({
useEffect(() => {
const onKey = (event: KeyboardEvent) => {
if (isPresenting) return;
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f") {
if (isPresenting || (event.target as HTMLElement)?.closest(".xmind-gantt, .xmind-task-backdrop, .xmind-project-calendar-backdrop, .xmind-image-export")) return;
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f" && !isFlowBoardEditablePasteTarget(event.target as HTMLElement)) {
event.preventDefault(); event.stopPropagation(); setIsSearchOpen(true);
}
if (event.key === "Escape" && (isZenMode || isSearchOpen)) {
@ -883,8 +898,10 @@ export function DrawnixWorkspace({
return;
}
const focusedShellControl = document.activeElement?.closest('[data-flow-board-shell-control="true"]');
const preserveFallback = options.preserveFallback || Boolean(focusedShellControl);
const annotation = getSelectedElements(board).find(isMindAnnotation);
if (!options.preserveFallback || annotation) {
if (!preserveFallback || annotation) {
setAnnotationId(annotation?.id || null);
}
if (annotation) {
@ -903,7 +920,7 @@ export function DrawnixWorkspace({
setMarkerSelectionState(
getFlowBoardMarkerSelectionState(board, selectedElements)
);
if (!options.preserveFallback) {
if (!preserveFallback) {
setSelectionStyleState(
getFlowBoardSelectionStyleState(board, selectedElements)
);
@ -911,7 +928,7 @@ export function DrawnixWorkspace({
return;
}
if (!options.preserveFallback) {
if (!preserveFallback) {
setSelectedTopicId(null);
selectedMindElementsRef.current = [];
setMarkerSelectionState({ count: 0, names: [], mixedNames: [] });
@ -1284,6 +1301,45 @@ export function DrawnixWorkspace({
}
}, [selectedTopicId]);
const handleSaveTopicTask = useCallback((id: string, task: TopicTask | null) => {
const board = boardRef.current;
if (!board) return;
const entries = getTopicEntries(board.children);
try {
const changes = task ? scheduleTaskChanges(board.children, id, task) : new Map<string, TopicTask | null>([[id, null]]);
if (!task) for (const entry of entries) {
const existing = getTopicTask(entry.element);
if (existing?.dependencies.some(dep => dep.id === id)) changes.set(entry.element.id, {...existing, dependencies: existing.dependencies.filter(dep => dep.id !== id)});
}
PlaitHistoryBoard.withNewBatch(board, () => {
for (const [topicId, value] of changes) {
const entry = getTopicEntries(board.children).find(e => e.element.id === topicId);
if (entry) Transforms.setNode(board, {xmind: patchTopicTask(entry.element, value)}, entry.path);
}
});
} catch (error) { toast.error(error instanceof Error ? error.message : "无法保存任务"); }
}, []);
const handleSaveProjectCalendar = useCallback((config: ProjectCalendarConfig) => {
const board = boardRef.current;
const root = board && calendarRoot(board.children);
if (!board || !root) { toast.error("请先创建一个中心主题。"); return false; }
try {
const problem = config.settings && validateWorkingCalendar(config.settings);
if (problem) throw new Error(problem);
const first = getTopicEntries(board.children).find(entry => getTopicTask(entry.element));
const changes = first ? scheduleTaskChanges(board.children, first.element.id, getTopicTask(first.element)!, config) : new Map<string, TopicTask>();
PlaitHistoryBoard.withNewBatch(board, () => {
Transforms.setNode(board, {xmind: patchProjectCalendar(root, config)}, PlaitBoard.findPath(board, root));
for (const [id, task] of changes) {
const entry = getTopicEntries(board.children).find(item => item.element.id === id);
if (entry) Transforms.setNode(board, {xmind: patchTopicTask(entry.element, task)}, entry.path);
}
});
return true;
} catch (error) { toast.error(error instanceof Error ? error.message : "无法保存工作日设置"); return false; }
}, []);
const handleJumpToTopic = useCallback((id: string) => {
const board = boardRef.current;
if (!board) return;
@ -2185,6 +2241,29 @@ export function DrawnixWorkspace({
}
}, [isExporting]);
const handleExportImage = useCallback(async (options: Pick<ImageExportOptions, "scale" | "transparent">) => {
const board = boardRef.current;
if (!board || isExportingImage) return;
setIsExportingImage(true);
try {
const { exportBoardImage } = await import("@/lib/flow-board/image-export");
const { blob } = await exportBoardImage(board, { ...options, backgroundColor: snapshotRef.current.canvas.backgroundColor || "#ffffff" });
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
const sheet = snapshotRef.current.sheets?.find(item => item.id === snapshotRef.current.activeSheetId);
anchor.download = `${`${currentTitleRef.current || "思维导图"}-${sheet?.title || "工作表"}`.replace(/[\\/:*?"<>|]/g, "_")}.png`;
anchor.click();
setTimeout(() => URL.revokeObjectURL(url), 30_000);
setIsImageExportOpen(false);
toast.success("PNG 图片已导出");
} catch (error) {
toast.error(error instanceof Error ? error.message : "图片导出失败,请重试");
} finally {
setIsExportingImage(false);
}
}, [isExportingImage]);
const handleImportMindMapFile = useCallback(
async (file: File) => {
if (!isSupportedMindMapImportFile(file)) {
@ -2475,20 +2554,20 @@ export function DrawnixWorkspace({
style={{ "--xmind-panel-offset": !isZenMode && (isFormatPanelVisible || isFilePanelOpen || isMarkerPanelOpen) ? "calc(var(--xmind-panel-width) + 16px)" : "0px" } as React.CSSProperties}
onChangeCapture={handleFlowBoardFileInputChangeCapture}
onKeyDownCapture={(event) => {
if (isPresenting) return;
if (isPresenting || (event.target as HTMLElement)?.closest(".xmind-gantt, .xmind-task-backdrop, .xmind-project-calendar-backdrop, .xmind-image-export")) return;
if (event.key === "Escape") {
if (isSearchOpen) { event.preventDefault(); setIsSearchOpen(false); }
if (isZenMode) { event.preventDefault(); setIsZenMode(false); }
}
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f") {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "f" && !isFlowBoardEditablePasteTarget(event.target as HTMLElement)) {
event.preventDefault(); event.stopPropagation(); setIsSearchOpen(true);
}
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z" && !isFlowBoardEditablePasteTarget(event.target as HTMLElement)) {
event.preventDefault(); event.stopPropagation();
if (event.shiftKey) handleRedo(); else handleUndo();
}
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === "n") {
event.preventDefault(); setIsTopicPanelOpen(true); setIsFormatPanelOpen(true); setTopicDetailSection("notes");
if ((event.metaKey || event.ctrlKey) && event.shiftKey && event.key.toLowerCase() === "n" && !isFlowBoardEditablePasteTarget(event.target as HTMLElement)) {
event.preventDefault(); setIsNotesPopoverOpen(true); setIsTopicPanelOpen(false); setIsFormatPanelOpen(true); setIsFilePanelOpen(false); setIsMarkerPanelOpen(false); setTopicDetailSection("notes");
}
if (event.key === "Escape" && relationshipSourceId) {
event.preventDefault();
@ -2502,6 +2581,18 @@ export function DrawnixWorkspace({
void flushPendingSave(true);
}
}}
onKeyDown={(event) => {
if (isFlowBoardEditablePasteTarget(event.target as HTMLElement) && (event.target as HTMLElement).closest('[data-flow-board-shell-control="true"]')) event.stopPropagation();
}}
onKeyUp={(event) => {
if (isFlowBoardEditablePasteTarget(event.target as HTMLElement) && (event.target as HTMLElement).closest('[data-flow-board-shell-control="true"]')) event.stopPropagation();
}}
onCopy={(event) => {
if (isFlowBoardEditablePasteTarget(event.target as HTMLElement) && (event.target as HTMLElement).closest('[data-flow-board-shell-control="true"]')) event.stopPropagation();
}}
onCut={(event) => {
if (isFlowBoardEditablePasteTarget(event.target as HTMLElement) && (event.target as HTMLElement).closest('[data-flow-board-shell-control="true"]')) event.stopPropagation();
}}
onPasteCapture={handleFlowBoardPasteCapture}
onPointerDownCapture={(event) => {
const target = event.target;
@ -2581,6 +2672,12 @@ export function DrawnixWorkspace({
onToggleZen={() => setIsZenMode(true)}
onToggleOutline={() => setIsOutlineOpen(value => !value)}
onPresentation={() => setIsPresenting(true)}
onGantt={() => setIsGanttOpen(true)}
onTask={() => {
const selected = getActiveMarkerElements();
if (selected.length !== 1) {toast.info("请选择一个主题"); return;}
setTaskTopicId(selected[0].id);
}}
activeTool={activeTool}
canEditMind={markerSelectionState.count > 0}
currentTitle={currentTitle}
@ -2592,7 +2689,8 @@ export function DrawnixWorkspace({
}}
onImport={() => mindMapFileInputRef.current?.click()}
onExport={() => void handleExportXmind()}
isExporting={isExporting}
onExportImage={() => setIsImageExportOpen(true)}
isExporting={isExporting || isExportingImage}
onInsertChild={isOutlineOpen ? () => { handleOutlineCommand(selectedTopicId, "child"); } : handleInsertChildTopic}
onInsertSibling={isOutlineOpen ? () => { handleOutlineCommand(selectedTopicId, "sibling"); } : handleInsertSiblingTopic}
onRelationship={handleInsertRelationship}
@ -2611,7 +2709,8 @@ export function DrawnixWorkspace({
if (selected.length !== 1) { toast.info("请选择一个主题"); return; }
setSelectedTopicId(selected[0].id);
setTopicDetailSection(section);
setIsTopicPanelOpen(true);
setIsNotesPopoverOpen(section === "notes");
setIsTopicPanelOpen(section !== "notes");
setIsFormatPanelOpen(true);
setIsFilePanelOpen(false);
setIsMarkerPanelOpen(false);
@ -2851,6 +2950,13 @@ export function DrawnixWorkspace({
/>
</div>
{!isOutlineOpen ? <XmindTopicBadges board={boardRef.current} elements={snapshot.children} viewport={snapshot.viewport} onTask={setTaskTopicId} onNotes={id => {
const board=boardRef.current;
const topic=board && getTopicEntries(board.children).find(e=>e.element.id===id)?.element;
const node=topic && MindElement.getNode(topic as MindElement);
if(board && node) { const rect=getRectangleByNode(node);const center:Point=[rect.x+rect.width/2,rect.y+rect.height/2];Transforms.setSelection(board,{anchor:center,focus:center}); }
setSelectedTopicId(id);setTopicDetailSection("notes");setIsNotesPopoverOpen(true);setIsTopicPanelOpen(false);setIsFormatPanelOpen(true);setIsFilePanelOpen(false);setIsMarkerPanelOpen(false);
}} /> : null}
{isOutlineOpen ? <div className="xmind-outline-shell">
<XmindOutline key={snapshot.activeSheetId} elements={snapshot.children} selectedId={selectedTopicId} zoomPercent={outlineZoomPercent}
onSelect={handleJumpToTopic} onRename={handleOutlineRename} onToggle={handleOutlineToggle}
@ -2876,6 +2982,11 @@ export function DrawnixWorkspace({
zoomPercent={isOutlineOpen ? outlineZoomPercent : zoomPercent}
/> : null}
{isSearchOpen && !isZenMode ? <XmindSearchPanel sheets={snapshot.sheets || []} activeSheetId={snapshot.activeSheetId || "sheet-1"} onClose={() => setIsSearchOpen(false)} onJump={handleSearchJump} onReplace={handleSearchReplace} /> : null}
{isNotesPopoverOpen ? <XmindNotesPopover board={boardRef.current} topic={selectedTopic} viewport={snapshot.viewport} onChange={notesDocument => handlePatchTopicDetails({notesDocument})} onDetails={() => {setIsNotesPopoverOpen(false);setIsTopicPanelOpen(true);setIsFormatPanelOpen(true);setIsFilePanelOpen(false);setIsMarkerPanelOpen(false);}} onClose={() => setIsNotesPopoverOpen(false)} /> : null}
{isImageExportOpen ? <XmindImageExport busy={isExportingImage} onExport={options => void handleExportImage(options)} onClose={() => setIsImageExportOpen(false)} /> : null}
{isCalendarOpen ? <XmindProjectCalendar key={snapshot.activeSheetId} config={getProjectCalendar(snapshot.children)} onSave={handleSaveProjectCalendar} onClose={() => setIsCalendarOpen(false)} /> : null}
{isGanttOpen ? <XmindGantt key={snapshot.activeSheetId} elements={snapshot.children} title={currentTitle} onClose={() => setIsGanttOpen(false)} onEdit={setTaskTopicId} onCalendar={() => setIsCalendarOpen(true)} onChange={handleSaveTopicTask} onUndo={handleUndo} onRedo={handleRedo} /> : null}
{taskTopicId && topicEntries.some(e => e.element.id === taskTopicId) ? <XmindTaskEditor key={taskTopicId} topic={topicEntries.find(e => e.element.id === taskTopicId)!.element} elements={snapshot.children} onSave={handleSaveTopicTask} onClose={() => setTaskTopicId(null)} onGantt={() => setIsGanttOpen(true)} onCalendar={() => setIsCalendarOpen(true)} /> : null}
{isPresenting ? <XmindPresentation elements={snapshot.children} title={currentTitle} onClose={() => setIsPresenting(false)} /> : null}
</main>
);

View File

@ -1,6 +1,8 @@
"use client";
import {
CalendarDays,
ChartGantt,
ArrowLeft,
ArrowUpRight,
Bold,
@ -153,11 +155,11 @@ function LayoutPreview({
export function XmindEditorTopBar({
activeTool, canEditMind, currentTitle, formatPanelOpen, markerPanelOpen, isSaving, onBack,
onImport, onExport, isExporting, onInsertChild, onInsertSibling,
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,
onSearch, onToggleZen, onToggleOutline, onPresentation, onTask, onGantt,
}: {
activeTool: FlowBoardToolId;
canEditMind: boolean;
@ -168,6 +170,7 @@ export function XmindEditorTopBar({
onBack: () => void;
onImport: () => void;
onExport: () => void;
onExportImage: () => void;
isExporting: boolean;
onInsertChild: () => void;
onInsertSibling: () => void;
@ -187,6 +190,8 @@ export function XmindEditorTopBar({
onUndo: () => void;
siteLogo: string | null;
siteName: string;
onTask: () => void;
onGantt: () => void;
onSearch: () => void;
onToggleZen: () => void;
onToggleOutline: () => void;
@ -210,8 +215,10 @@ export function XmindEditorTopBar({
{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")},
@ -234,7 +241,8 @@ export function XmindEditorTopBar({
{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: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")},
@ -245,7 +253,10 @@ export function XmindEditorTopBar({
</div>
</nav>
<div className="xmind-utility-tools">
<div className="xmind-toolbar-pill">{action("导出 XMind", Download, onExport, isExporting)}</div>
<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)}

View File

@ -0,0 +1,778 @@
"use client";
import {
CalendarDays,
ChevronDown,
ChevronRight,
Download,
Link2,
Minus,
Plus,
Printer,
Search,
UserRound,
X,
} from "lucide-react";
import type { PlaitElement } from "@plait/core";
import { useEffect, useMemo, useRef, useState } from "react";
import {
addDays,
dateIndex,
getTaskRows,
indexDate,
todayDate,
type TopicTask,
} from "@/lib/flow-board/topic-tasks";
import { getTopicEntries } from "@/lib/flow-board/topic-details";
import {
activeWorkingCalendar,
getProjectCalendar,
isWorkingDate,
preserveNonWorkingDue,
taskDueDate,
workingDaysBetween,
} from "@/lib/flow-board/project-calendar";
const ROW = 38,
HEADER = 64;
export function XmindGantt({
elements,
title,
onClose,
onEdit,
onCalendar,
onChange,
onUndo,
onRedo,
}: {
elements: PlaitElement[];
title: string;
onClose: () => void;
onEdit: (id: string) => void;
onCalendar: () => void;
onChange: (id: string, task: TopicTask) => void;
onUndo: () => void;
onRedo: () => void;
}) {
const panelRef = useRef<HTMLElement>(null);
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
panelRef.current?.querySelector<HTMLButtonElement>("button")?.focus();
return () => {
if (previous?.isConnected) previous.focus();
};
}, []);
const [scale, setScale] = useState(34),
[query, setQuery] = useState(""),
[assignee, setAssignee] = useState(""),
[status, setStatus] = useState("all");
const [collapsed, setCollapsed] = useState<Set<string>>(new Set());
const [selected, setSelected] = useState<string[]>([]);
const [drag, setDrag] = useState<{
id: string;
x: number;
kind: "move" | "start" | "due";
task: TopicTask;
days: number;
} | null>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const rowScrollRef = useRef<HTMLDivElement>(null);
const projectCalendar = getProjectCalendar(elements);
const calendar = activeWorkingCalendar(projectCalendar);
const rows = getTaskRows(elements);
const visible = rows.filter(
(row) =>
row.title.toLocaleLowerCase().includes(query.toLocaleLowerCase()) &&
(!assignee || row.task.assignees.includes(assignee)) &&
(status === "all" ||
(status === "done" && row.task.progress === 100) ||
(status === "open" && row.task.progress < 100)) &&
!rows.some(
(parent) =>
collapsed.has(parent.element.id) &&
parent.path.length < row.path.length &&
parent.path.every((n, i) => row.path[i] === n)
)
);
const candidates = getTopicEntries(elements).filter(
(e) => !rows.some((r) => r.element.id === e.element.id)
);
const [candidate, setCandidate] = useState("");
const [notice, setNotice] = useState("");
const today = dateIndex(todayDate());
const dated = rows.filter((r) => r.task.start && r.task.due);
const from =
Math.min(today, ...dated.map((r) => dateIndex(r.task.start))) - 3;
const last =
Math.max(today + 20, ...dated.map((r) => dateIndex(r.task.due))) + 4;
// Very long plans use a virtual day grid; no thousands of day DOM nodes.
const width = Math.max(800, (last - from + 1) * scale);
const [scrollX, setScrollX] = useState(0),
[viewportWidth, setViewportWidth] = useState(1000);
const tickStep = scale >= 24 ? 1 : scale >= 10 ? 7 : 30;
const monthBands = useMemo(() => {
const start = from + Math.max(0, Math.floor(scrollX / scale) - 31),
end = Math.min(
last,
from + Math.ceil((scrollX + viewportWidth) / scale) + 31
);
const bands: { date: string; offset: number; days: number }[] = [];
for (let day = start; day <= end; ) {
const date = indexDate(day),
next = dateIndex(`${date.slice(0, 7)}-01`);
const d = new Date(next * 86_400_000);
d.setUTCMonth(d.getUTCMonth() + 1);
const monthEnd = d.getTime() / 86_400_000;
const bandStart = Math.max(from, next),
bandEnd = Math.min(last + 1, monthEnd);
bands.push({
date: date.slice(0, 7),
offset: bandStart - from,
days: bandEnd - bandStart,
});
day = monthEnd;
}
return bands;
}, [from, last, scrollX, scale, viewportWidth]);
const ticks = useMemo(() => {
const result: number[] = [];
const begin = Math.max(
0,
Math.floor(scrollX / scale / tickStep) * tickStep - tickStep
);
const end = Math.min(
last - from,
Math.ceil((scrollX + viewportWidth) / scale) + tickStep
);
for (let i = begin; i <= end; i += tickStep) result.push(i);
return result;
}, [scrollX, scale, tickStep, viewportWidth, last, from]);
const moveTask = (task: TopicTask, days: number) => {
const start = addDays(task.start, days);
return {
...task,
start,
due: preserveNonWorkingDue(
addDays(task.due, days),
taskDueDate(start, task.duration, calendar),
calendar
),
};
};
const effective = (row: (typeof rows)[number]) => {
if (drag?.id !== row.element.id || !drag.days) return row.task;
const task = row.task;
if (drag.kind === "move") return moveTask(task, drag.days);
const start =
drag.kind === "start" ? addDays(task.start, drag.days) : task.start;
const due = drag.kind === "due" ? addDays(task.due, drag.days) : task.due;
return {
...task,
start,
due,
duration: Math.max(1, workingDaysBetween(start, due, calendar)),
};
};
const goToday = () =>
scrollRef.current?.scrollTo({
left: Math.max(0, (today - from) * scale - 200),
behavior: "smooth",
});
const downloadCsv = () => {
const quote = (v: unknown) => `"${String(v ?? "").replaceAll('"', '""')}"`;
const content =
"\ufeff" +
[
[
"任务名称",
"开始日期",
"截止日期",
"时长(天)",
"进度(%)",
"优先级",
"负责人",
"前置任务",
],
...visible.map((r) => [
r.title,
r.task.start,
r.task.due,
r.task.duration,
r.task.progress,
r.task.priority == null ? "" : `P${r.task.priority}`,
r.task.assignees.join(", "),
r.task.dependencies
.map(
(d) =>
`${rows.find((t) => t.element.id === d.id)?.title || d.id} ${d.type}`
)
.join(", "),
]),
]
.map((row) => row.map(quote).join(","))
.join("\r\n");
const url = URL.createObjectURL(
new Blob([content], { type: "text/csv;charset=utf-8" })
);
const a = document.createElement("a");
a.href = url;
a.download = `${title}-甘特图.csv`;
a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
};
const linkTasks = () => {
if (selected.length !== 2) return;
const target = rows.find((r) => r.element.id === selected[1]);
if (!target) return;
onChange(target.element.id, {
...target.task,
dependencies: [
...target.task.dependencies.filter((d) => d.id !== selected[0]),
{ id: selected[0], type: "FS", lag: 0 },
],
});
};
return (
<section
role="dialog"
aria-modal="true"
aria-label="甘特图"
className="xmind-gantt"
ref={panelRef}
data-flow-board-shell-control="true"
onKeyUp={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
if (e.key === "Tab") {
const controls = Array.from(
panelRef.current?.querySelectorAll<HTMLElement>(
'button:not(:disabled),input,select,[tabindex="0"]'
) || []
);
const first = controls[0],
last = controls.at(-1);
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
}
if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
if (
(e.metaKey || e.ctrlKey) &&
e.key.toLowerCase() === "z" &&
!(e.target instanceof HTMLInputElement) &&
!(e.target instanceof HTMLSelectElement)
) {
e.preventDefault();
e.stopPropagation();
if (e.shiftKey) onRedo();
else onUndo();
}
}}
>
<header className="xmind-gantt-toolbar">
<div className="xmind-gantt-title">
<button type="button" aria-label="关闭甘特图" onClick={onClose}>
<X size={18} />
</button>
<h2>甘特图</h2>
<span>{title}</span>
</div>
<div className="xmind-gantt-actions">
<button
type="button"
aria-label="连接选中任务"
title="按选择顺序建立完成 → 开始依赖"
disabled={selected.length !== 2}
onClick={linkTasks}
>
<Link2 size={18} />
</button>
<button
type="button"
aria-label="项目工作日"
title="项目工作日"
onClick={onCalendar}
>
<CalendarDays size={18} />
</button>
<button
type="button"
aria-label="导出甘特图 CSV"
onClick={downloadCsv}
>
<Download size={18} />
</button>
<button
type="button"
aria-label="打印甘特图"
onClick={() => window.print()}
>
<Printer size={18} />
</button>
<button type="button" aria-label="定位今天" onClick={goToday}>
<CalendarDays size={18} />
</button>
<div className="xmind-gantt-zoom">
<button
type="button"
aria-label="缩小甘特图"
onClick={() => setScale((s) => Math.max(2, s - 4))}
>
<Minus size={16} />
</button>
<input
type="range"
aria-label="甘特图缩放"
min={2}
max={64}
value={scale}
onChange={(e) => setScale(Number(e.target.value))}
/>
<button
type="button"
aria-label="放大甘特图"
onClick={() => setScale((s) => Math.min(64, s + 4))}
>
<Plus size={16} />
</button>
</div>
</div>
</header>
<div className="xmind-gantt-filters">
<label>
<Search size={14} />
<input
aria-label="搜索甘特图任务"
placeholder="搜索任务"
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</label>
<select
aria-label="按负责人筛选"
value={assignee}
onChange={(e) => setAssignee(e.target.value)}
>
<option value="">全部负责人</option>
{[...new Set(rows.flatMap((r) => r.task.assignees))].map((name) => (
<option key={name} value={name}>
{name}
</option>
))}
</select>
<select
aria-label="按任务进度筛选"
value={status}
onChange={(e) => setStatus(e.target.value)}
>
<option value="all">全部任务</option>
<option value="open">未完成</option>
<option value="done">已完成</option>
</select>
<select
aria-label="选择新增任务主题"
value={candidate}
onChange={(e) => setCandidate(e.target.value)}
>
<option value="">选择主题添加任务</option>
{candidates.map((e) => (
<option key={e.element.id} value={e.element.id}>
{e.title}
</option>
))}
</select>
<button
type="button"
disabled={!candidate}
onClick={() => {
onEdit(candidate);
setCandidate("");
}}
>
添加任务
</button>
<span>
{calendar ? `${calendar.name} · 跳过非工作日 · ` : ""}
{visible.length} 个任务
</span>
</div>
<div className="xmind-gantt-body">
<div className="xmind-gantt-table">
<div className="xmind-gantt-table-head">
<span>任务名称</span>
<span>开始日期</span>
<span>时长</span>
</div>
<div
className="xmind-gantt-row-scroll"
ref={rowScrollRef}
onScroll={(e) => {
const node = scrollRef.current;
if (node && node.scrollTop !== e.currentTarget.scrollTop)
node.scrollTop = e.currentTarget.scrollTop;
}}
data-gantt-row-scroll="true"
>
{visible.map((row) => (
<div
className={`xmind-gantt-row ${selected.includes(row.element.id) ? "is-selected" : ""}`}
key={row.element.id}
style={{ height: ROW }}
>
<div
className="xmind-gantt-row-name"
style={{ paddingLeft: 8 + Math.min(row.depth, 5) * 12 }}
>
<input
type="checkbox"
aria-label={`选择任务 ${row.title}`}
checked={selected.includes(row.element.id)}
onChange={(e) =>
setSelected((s) =>
e.target.checked
? [...s, row.element.id]
: s.filter((id) => id !== row.element.id)
)
}
/>
{row.summary ? (
<button
type="button"
aria-label={`折叠任务 ${row.title}`}
aria-expanded={!collapsed.has(row.element.id)}
onClick={() =>
setCollapsed((old) => {
const next = new Set(old);
if (next.has(row.element.id))
next.delete(row.element.id);
else next.add(row.element.id);
return next;
})
}
>
{collapsed.has(row.element.id) ? (
<ChevronRight size={12} />
) : (
<ChevronDown size={12} />
)}
</button>
) : null}
<button
type="button"
className="xmind-gantt-task-title"
title={row.title}
onClick={() => onEdit(row.element.id)}
>
{row.title}
</button>
</div>
<button
type="button"
className="xmind-gantt-date"
onClick={() => onEdit(row.element.id)}
>
{effective(row).start || "未排期"}
</button>
<span>{effective(row).duration} 天</span>
</div>
))}
{!visible.length ? (
<div className="xmind-gantt-empty">
<CalendarDays size={52} />
<strong>{rows.length ? "没有匹配的任务" : "没有任务"}</strong>
<p>
{rows.length
? "调整搜索或筛选条件。"
: "请选择一个主题以添加任务。"}
</p>
</div>
) : null}
</div>
</div>
<div
className="xmind-gantt-timeline"
ref={scrollRef}
onScroll={(e) => {
setScrollX(e.currentTarget.scrollLeft);
setViewportWidth(e.currentTarget.clientWidth);
const rowScroll = rowScrollRef.current;
if (rowScroll && rowScroll.scrollTop !== e.currentTarget.scrollTop)
rowScroll.scrollTop = e.currentTarget.scrollTop;
}}
>
<div
className="xmind-gantt-grid"
style={{
width,
height: Math.max(600, HEADER + visible.length * ROW),
backgroundSize: `${scale}px ${ROW}px`,
}}
>
{calendar
? Array.from(
{ length: Math.ceil(viewportWidth / scale) + 3 },
(_, index) =>
Math.max(0, Math.floor(scrollX / scale) - 1) + index
)
.filter(
(offset) =>
offset <= last - from &&
!isWorkingDate(indexDate(from + offset), calendar)
)
.map((offset) => (
<div
key={`off-${offset}`}
className="xmind-gantt-day-off"
data-gantt-day-off={indexDate(from + offset)}
style={{
left: offset * scale,
width: scale,
top: HEADER,
bottom: 0,
}}
/>
))
: null}
<div
className="xmind-gantt-calendar"
style={{ width, height: HEADER }}
>
{ticks.map((offset) => {
const date = indexDate(from + offset);
return (
<div
className={`xmind-gantt-day ${calendar && !isWorkingDate(date, calendar) ? "is-day-off" : ""}`}
key={offset}
style={{ left: offset * scale, width: tickStep * scale }}
>
<b>
{tickStep >= 30
? date.slice(5, 7) + "月"
: tickStep >= 7
? date.slice(5)
: String(Number(date.slice(8)))}
</b>
</div>
);
})}
{monthBands.map((band) => (
<div
key={band.date}
className="xmind-gantt-month"
style={{
left: band.offset * scale,
width: band.days * scale,
}}
>
{band.date.replace("-", "年")}月
</div>
))}
</div>
<div
className="xmind-gantt-today"
aria-label="今天"
style={{ left: (today - from) * scale, top: HEADER }}
/>
<svg
className="xmind-gantt-connections"
aria-label="任务依赖关系"
role="img"
width={width}
height={Math.max(600, HEADER + visible.length * ROW)}
>
<defs>
<marker
id="gantt-arrow"
viewBox="0 0 10 10"
refX="8"
refY="5"
markerWidth="5"
markerHeight="5"
orient="auto-start-reverse"
>
<path d="M 0 0 L 10 5 L 0 10 z" fill="#8d94a1" />
</marker>
</defs>
{visible.flatMap((row, i) =>
row.task.dependencies.map((d) => {
const fromIndex = visible.findIndex(
(r) => r.element.id === d.id
);
if (fromIndex < 0) return null;
const before = effective(visible[fromIndex]),
after = effective(row);
if (!before.start || !after.start) return null;
const x1 =
(dateIndex(
d.type[0] === "F" ? before.due : before.start
) -
from +
(d.type[0] === "F" ? 1 : 0)) *
scale,
x2 =
(dateIndex(d.type[1] === "F" ? after.due : after.start) -
from +
(d.type[1] === "F" ? 1 : 0)) *
scale,
y1 = HEADER + fromIndex * ROW + ROW / 2,
y2 = HEADER + i * ROW + ROW / 2;
return (
<path
key={`${row.element.id}-${d.id}`}
data-gantt-dependency={`${d.id}:${row.element.id}`}
d={`M${x1},${y1} H${x1 + 9} V${y2} H${x2}`}
fill="none"
stroke="#8d94a1"
strokeWidth={1.3}
markerEnd="url(#gantt-arrow)"
/>
);
})
)}
</svg>
{visible.map((row, i) =>
selected.includes(row.element.id) ? (
<div
key={`selection-${row.element.id}`}
className="xmind-gantt-selection"
style={{ top: HEADER + i * ROW, height: ROW, width }}
/>
) : null
)}
{visible.map((row, i) => {
const t = effective(row);
if (!t.start || !t.due) return null;
const left = (dateIndex(t.start) - from) * scale,
barWidth = Math.max(
6,
(dateIndex(t.due) - dateIndex(t.start) + 1) * scale
);
return (
<div
key={row.element.id}
className={`xmind-gantt-bar ${row.summary ? "is-summary" : ""} ${t.progress === 100 ? "is-complete" : ""}`}
data-gantt-task-id={row.element.id}
style={{ left, top: HEADER + i * ROW + 6, width: barWidth }}
role="button"
tabIndex={0}
aria-label={`任务时间条 ${row.title}`}
aria-pressed={selected.includes(row.element.id)}
title={`${row.title}\n${t.start} — ${t.due}\n进度 ${t.progress}%`}
onDoubleClick={() => onEdit(row.element.id)}
onKeyDown={(e) => {
if (e.key === "Enter") onEdit(row.element.id);
if (
!row.summary &&
["ArrowLeft", "ArrowRight"].includes(e.key)
) {
e.preventDefault();
const days = e.key === "ArrowLeft" ? -1 : 1;
onChange(row.element.id, moveTask(t, days));
}
}}
onPointerDown={(e) => {
if (row.summary || e.button !== 0) return;
e.preventDefault();
setSelected((ids) =>
e.metaKey || e.ctrlKey
? ids.includes(row.element.id)
? ids.filter((id) => id !== row.element.id)
: [...ids, row.element.id]
: [row.element.id]
);
e.currentTarget.setPointerCapture(e.pointerId);
const kind =
(e.target as HTMLElement).dataset.resize === "start"
? "start"
: (e.target as HTMLElement).dataset.resize === "due"
? "due"
: "move";
setDrag({
id: row.element.id,
x: e.clientX,
kind,
task: row.task,
days: 0,
});
}}
onPointerMove={(e) => {
if (drag?.id !== row.element.id) return;
let days = Math.round((e.clientX - drag.x) / scale);
if (drag.kind === "start")
days = Math.min(
dateIndex(drag.task.due) - dateIndex(drag.task.start),
days
);
if (drag.kind === "due")
days = Math.max(
dateIndex(drag.task.start) - dateIndex(drag.task.due),
days
);
setDrag({ ...drag, days });
}}
onPointerUp={() => {
if (drag?.id !== row.element.id) return;
if (drag.days) onChange(row.element.id, t);
setDrag(null);
}}
onPointerCancel={() => setDrag(null)}
>
<div
className="xmind-gantt-progress"
style={{ width: `${t.progress}%` }}
/>
{!row.summary ? (
<>
<span
data-resize="start"
className="xmind-gantt-resize is-start"
/>
<span
data-resize="due"
className="xmind-gantt-resize is-due"
/>
</>
) : null}
<span className="xmind-gantt-bar-label">
<span
className="xmind-gantt-avatar"
title={t.assignees.join("、") || "未分配负责人"}
>
<UserRound size={12} />
</span>
<span>{row.title}</span>
</span>
</div>
);
})}
</div>
</div>
</div>
<footer className="xmind-gantt-footer">
<span>
{drag
? `调整 ${drag.days > 0 ? "+" : ""}${drag.days} 天`
: "拖动调整日期 · 拉伸修改工期 · 双击编辑任务"}
</span>
<span>{notice}</span>
<button
type="button"
onClick={() => {
setQuery("");
setAssignee("");
setStatus("all");
setNotice("");
}}
>
清除筛选
</button>
</footer>
</section>
);
}

View File

@ -0,0 +1,94 @@
"use client";
import { Download, Loader2, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import type { ImageExportOptions } from "@/lib/flow-board/image-export";
export function XmindImageExport({
busy,
onExport,
onClose,
}: {
busy: boolean;
onExport: (
options: Pick<ImageExportOptions, "scale" | "transparent">
) => void;
onClose: () => void;
}) {
const dialog = useRef<HTMLDialogElement>(null);
const [scale, setScale] = useState<1 | 2 | 3>(2);
const [transparent, setTransparent] = useState(false);
useEffect(() => {
const node = dialog.current;
node?.showModal();
return () => node?.close();
}, []);
return (
<dialog
ref={dialog}
className="xmind-image-export"
aria-labelledby="image-export-title"
data-flow-board-shell-control="true"
onKeyDown={(event) => event.stopPropagation()}
onCancel={(event) => {
event.preventDefault();
if (!busy) onClose();
}}
>
<header>
<h2 id="image-export-title">导出图片</h2>
<button
type="button"
aria-label="关闭图片导出"
disabled={busy}
onClick={onClose}
>
<X size={18} />
</button>
</header>
<p>导出当前工作表的完整内容为 PNG 图片。</p>
<label className="xmind-image-export-field">
清晰度
<select
aria-label="图片清晰度"
value={scale}
disabled={busy}
onChange={(event) =>
setScale(Number(event.target.value) as 1 | 2 | 3)
}
>
<option value={1}>标准 · 1 倍</option>
<option value={2}>高清 · 2 倍</option>
<option value={3}>超清 · 3 倍</option>
</select>
</label>
<label className="xmind-image-export-check">
<input
type="checkbox"
checked={transparent}
disabled={busy}
onChange={(event) => setTransparent(event.target.checked)}
/>
透明背景
</label>
<footer>
<button type="button" disabled={busy} onClick={onClose}>
取消
</button>
<button
type="button"
className="xmind-primary-button"
disabled={busy}
onClick={() => onExport({ scale, transparent })}
>
{busy ? (
<Loader2 size={16} className="animate-spin" />
) : (
<Download size={16} />
)}
{busy ? "正在导出…" : "导出 PNG"}
</button>
</footer>
</dialog>
);
}

View File

@ -178,3 +178,103 @@
}
.xmind-native-icon { display:block; width:18px; height:18px; background:currentColor; mask-size:contain; mask-position:center; mask-repeat:no-repeat; }
/* Topic notes follow the native compact formatting toolbar. */
.xmind-notes-editor { border:1px solid #e2e5eb; border-radius:12px; background:#fafbfc; overflow:hidden; }
.xmind-notes-toolbar { display:flex; justify-content:center; align-items:center; gap:2px; padding:7px; border-bottom:1px solid #eceef1; }
.xmind-notes-toolbar button { display:grid; place-items:center; width:28px; height:28px; border-radius:6px; }
.xmind-notes-toolbar button:hover,.xmind-notes-toolbar button[aria-pressed=true] { background:#e9edf5; color:#3569e8; }
.xmind-note-content { min-height:180px; max-height:380px; overflow:auto; padding:12px; outline:none; font-size:13px; line-height:1.7; color:#30343b; counter-reset:note-number; }
.xmind-note-content p { margin:0 0 4px; position:relative; }
.xmind-note-content p[data-note-list] { padding-left:20px; }
.xmind-note-content p[data-note-list=bullet]::before { content:"•"; position:absolute; left:3px; }
.xmind-note-content p[data-note-list=number] { counter-increment:note-number; }
.xmind-note-content p[data-note-list=number]::before { content:counter(note-number) "."; position:absolute; left:0; }
.xmind-note-content a { color:#3569e8; text-decoration:underline; }
.xmind-note-link-form { padding:8px; display:flex; flex-wrap:wrap; gap:6px; }
.xmind-note-link-form input { min-width:0; flex:1; border:1px solid #dfe2e7; padding:4px; border-radius:5px; }
.xmind-note-link-form p { width:100%; color:#c0392b; font-size:11px; }
.xmind-task-backdrop { position:absolute; inset:0; background:rgb(20 27 40 / .16); z-index:85; display:grid; place-items:center; padding:20px; }
.xmind-task-editor { width:min(460px,100%); max-height:calc(100vh - 40px); overflow:auto; border:1px solid #e0e3e8; border-radius:16px; background:#fafbfc; color:#343b48; padding:18px; box-shadow:0 16px 64px rgb(20 27 40 / .18); font-size:12px; }
.xmind-task-editor header,.xmind-task-editor header h2 { display:flex; align-items:center; gap:8px; }
.xmind-task-editor header { justify-content:space-between; }.xmind-task-editor h2 { font-size:14px; font-weight:600; }
.xmind-task-name { margin:14px 0; font-size:15px; font-weight:600; overflow-wrap:anywhere; }
.xmind-task-fields { display:grid; grid-template-columns:1fr 1fr; gap:12px; }
.xmind-task-fields label { display:flex; flex-direction:column; gap:5px; }
.xmind-task-editor input,.xmind-task-editor select { width:100%; min-width:0; height:32px; padding:0 8px; border:1px solid #dfe2e7; background:white; border-radius:6px; outline:none; }
.xmind-task-editor input:focus,.xmind-task-editor select:focus { border-color:#799ce8; }
.xmind-task-editor h3 { font-weight:600; margin:16px 0 8px; }
.xmind-task-dependencies>div { display:flex; justify-content:space-between; padding:6px 0; }.xmind-task-add-dependency { display:grid; grid-template-columns:1fr 1fr 50px 34px; gap:5px; }
.xmind-task-editor footer { display:flex; align-items:center; justify-content:flex-end; gap:10px; margin-top:20px; }.xmind-task-editor footer button { border-radius:6px; padding:7px 11px; }.xmind-task-editor footer button:first-child { margin-right:auto; }
.xmind-primary-button { background:#3569e8; color:white; }.xmind-danger,.xmind-task-error { color:#c23c36; }.xmind-task-error { margin-top:10px; }
.xmind-gantt { position:absolute; inset:0; z-index:75; display:flex; flex-direction:column; background:#edf0f3; color:#30343b; font-size:12px; }
.xmind-gantt-toolbar { display:flex; align-items:center; justify-content:space-between; min-height:58px; padding:8px 16px; gap:10px; }.xmind-gantt-title,.xmind-gantt-actions { display:flex; align-items:center; gap:12px; }.xmind-gantt-title h2 { font-size:15px; font-weight:600; white-space:nowrap; }.xmind-gantt-title>span { max-width:220px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; color:#7d8592; }
.xmind-gantt-actions>button,.xmind-gantt-title>button { display:grid; place-items:center; width:32px; height:32px; background:#fafbfc; border-radius:12px; }.xmind-gantt button:disabled { opacity:.35; cursor:default; }.xmind-gantt-zoom { display:flex; align-items:center; gap:8px; padding:6px 10px; background:#fafbfc; border-radius:16px; }.xmind-gantt-zoom input { width:90px; }
.xmind-gantt-filters { display:flex; align-items:center; gap:8px; padding:0 16px 10px; flex-wrap:wrap; }.xmind-gantt-filters label { display:flex; align-items:center; gap:6px; background:#fafbfc; padding:0 8px; border:1px solid #e0e3e8; border-radius:6px; }.xmind-gantt-filters input { width:130px; height:28px; outline:none; background:transparent; }.xmind-gantt-filters select { max-width:210px; height:30px; border:1px solid #e0e3e8; background:#fafbfc; padding:0 8px; border-radius:6px; }.xmind-gantt-filters>span { margin-left:auto; color:#7d8592; }
.xmind-gantt-body { min-height:0; flex:1; display:grid; grid-template-columns:minmax(290px,34%) minmax(0,1fr); gap:12px; padding:0 12px; }.xmind-gantt-table,.xmind-gantt-timeline { border:1px solid #dfe3e9; border-radius:12px; background:#fafbfc; overflow:hidden; min-width:0; position:relative; }.xmind-gantt-table { display:flex; flex-direction:column; }.xmind-gantt-table-head,.xmind-gantt-row { display:grid; grid-template-columns:minmax(0,1fr) 104px 54px; align-items:center; }.xmind-gantt-table-head { height:64px; min-height:64px; font-weight:600; border-bottom:1px solid #e2e5eb; }.xmind-gantt-table-head>span { display:flex; align-items:center; height:100%; padding:0 12px; border-right:1px solid #e2e5eb; }.xmind-gantt-row-scroll { overflow:auto; scrollbar-width:none; flex:1; position:relative; }.xmind-gantt-row { border-bottom:1px solid #e2e5eb; }.xmind-gantt-row.is-selected { background:#edf3ff; }.xmind-gantt-row>span { padding:0 7px; }.xmind-gantt-row-name { display:flex; align-items:center; gap:5px; overflow:hidden; }.xmind-gantt-row-name input { flex-shrink:0; }.xmind-gantt-task-title { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; text-align:left; }.xmind-gantt-date { font-size:11px; text-align:left; padding:0 8px; }
.xmind-gantt-timeline { overflow:auto; }.xmind-gantt-grid { position:relative; background-image:linear-gradient(to right,#e7e9ee 1px,transparent 1px),linear-gradient(to bottom,#e7e9ee 1px,transparent 1px); background-position:0 64px; }.xmind-gantt-calendar { position:sticky; top:0; z-index:5; background:#fafbfc; border-bottom:1px solid #e2e5eb; }.xmind-gantt-day { position:absolute; top:0; height:64px; display:flex; flex-direction:column; justify-content:space-around; align-items:center; border-right:1px solid #e2e5eb; white-space:nowrap; overflow:hidden; }.xmind-gantt-day span { font-size:10px; color:#8b929d; }.xmind-gantt-day b { font-weight:500; }.xmind-gantt-today { position:absolute; bottom:0; width:2px; background:#fa603b; pointer-events:none; z-index:2; }.xmind-gantt-connections { position:absolute; inset:0; pointer-events:none; z-index:2; }.xmind-gantt-bar { position:absolute; height:26px; border-radius:16px; background:#fac28a; cursor:grab; touch-action:none; z-index:3; overflow:hidden; min-width:6px; border:1px solid #f5b978; }.xmind-gantt-bar:focus { outline:2px solid #3569e8; outline-offset:2px; }.xmind-gantt-progress { position:absolute; inset:0 auto 0 0; background:#f3a660; }.xmind-gantt-bar.is-complete { background:#b8dcca; border-color:#68a986; }.xmind-gantt-bar.is-complete .xmind-gantt-progress { background:#86b99b; }.xmind-gantt-bar.is-summary { height:10px; margin-top:4px; background:#667387; border-color:#667387; cursor:default; }.xmind-gantt-bar-label { position:relative; display:flex; align-items:center; gap:8px; padding:0 6px; height:24px; color:#72491f; font-size:11px; pointer-events:none; white-space:nowrap; }
.xmind-gantt-bar-label>span:last-child { flex:1; overflow:hidden; text-overflow:ellipsis; text-align:center; }
.xmind-gantt-avatar { width:18px; height:18px; border-radius:50%; background:#eceff1; color:#939ba2; display:grid; place-items:center; flex-shrink:0; }
.xmind-gantt-selection { position:absolute; left:0; background:rgb(125 136 148 / .15); pointer-events:none; z-index:1; }
.xmind-gantt-bar[aria-pressed=true] { outline:2px solid #539efa; outline-offset:1px; }.xmind-gantt-bar.is-summary .xmind-gantt-bar-label { display:none; }.xmind-gantt-resize { position:absolute; top:0; bottom:0; width:6px; cursor:ew-resize; z-index:4; }.xmind-gantt-resize.is-start { left:0; }.xmind-gantt-resize.is-due { right:0; }
.xmind-gantt-empty { min-height:300px; height:70%; display:flex; flex-direction:column; align-items:center; justify-content:center; color:#a0a6af; gap:10px; }.xmind-gantt-empty strong { font-size:15px; color:#424852; }.xmind-gantt-footer { display:flex; align-items:center; justify-content:space-between; gap:8px; min-height:32px; padding:0 16px; color:#7d8592; font-size:11px; }
@media(max-width:800px) { .xmind-gantt-title>span { display:none; }.xmind-gantt-body { grid-template-columns:260px minmax(0,1fr); gap:6px; padding:0 6px; }.xmind-gantt-table-head,.xmind-gantt-row { grid-template-columns:minmax(0,1fr) 84px 42px; }.xmind-gantt-actions { gap:5px; }.xmind-gantt-toolbar { padding:8px; }.xmind-gantt-zoom input { width:50px; } }
@media print { .xmind-workspace:has(.xmind-gantt)>:not(.xmind-gantt) { display:none!important; }.xmind-gantt { position:fixed; background:white; }.xmind-gantt-toolbar button,.xmind-gantt-actions,.xmind-gantt-filters,.xmind-gantt-footer { display:none; }.xmind-gantt-timeline,.xmind-gantt-row-scroll { overflow:visible; }.xmind-gantt-body { overflow:visible; }.xmind-gantt-table { overflow:visible; }.xmind-gantt-row-name input { display:none; } }
.xmind-gantt-grid { min-height:100%; }
.xmind-gantt-day { top:28px; height:36px; }
.xmind-gantt-month { position:absolute; top:0; height:28px; padding:6px 12px; text-align:center; color:#7d8592; border-right:1px solid #e2e5eb; border-bottom:1px solid #e2e5eb; white-space:nowrap; overflow:hidden; }
.xmind-topic-badges { position:absolute; inset:0; pointer-events:none; z-index:12; overflow:hidden; }
.xmind-topic-badge { position:absolute; display:flex; align-items:center; gap:2px; pointer-events:auto; }
.xmind-topic-badge button { display:grid; place-items:center; width:19px; height:19px; background:rgb(255 255 255 / .9); border-radius:4px; color:#8b929d; }
.xmind-topic-badge button:hover { background:#edf3ff; color:#3569e8; }
.xmind-note-content p:not([data-note-list=number]) { counter-reset:note-number; }
.xmind-gantt-day-off { position:absolute; background:rgb(111 124 139 / .09); pointer-events:none; z-index:0; }
.xmind-gantt-day.is-day-off { color:#929aa6; background:#eef0f3; }
.xmind-task-calendar-info { display:flex; justify-content:space-between; align-items:center; gap:10px; margin:12px 0; color:#7d8592; font-size:11px; }
.xmind-task-calendar-info button { padding:6px 10px; border:1px solid #dce1e8; border-radius:6px; background:#f7f8fa; color:#3b4452; }
.xmind-project-calendar-backdrop,.xmind-calendar-exception-backdrop { position:absolute; inset:0; z-index:100; display:flex; align-items:center; justify-content:center; background:rgb(29 36 46 / .22); padding:16px; }
.xmind-calendar-exception-backdrop { position:fixed; z-index:110; }
.xmind-project-calendar,.xmind-calendar-exception { display:flex; flex-direction:column; width:740px; max-width:100%; max-height:calc(100svh - 32px); color:#343b48; font-size:12px; background:#f7f8fa; border:1px solid #dce1e8; border-radius:14px; box-shadow:0 18px 60px rgb(0 0 0 / .17); overflow:hidden; }
.xmind-project-calendar>header,.xmind-calendar-exception>header { display:flex; align-items:flex-start; justify-content:space-between; gap:14px; padding:18px 20px; }
.xmind-project-calendar h2,.xmind-calendar-exception h2 { display:flex; align-items:center; gap:9px; font-size:16px; font-weight:600; }
.xmind-project-calendar>header p { margin-top:6px; font-size:11px; color:#8b929d; }
.xmind-project-calendar>header button,.xmind-calendar-exception>header button { padding:4px; border-radius:5px; }
.xmind-project-calendar-content { display:grid; grid-template-columns:290px minmax(0,1fr); gap:12px; padding:0 20px 18px; min-height:0; overflow:auto; }
.xmind-calendar-preview,.xmind-calendar-settings { padding:15px; border:1px solid #e0e4ea; border-radius:10px; background:#fff; }
.xmind-project-calendar label,.xmind-calendar-exception label { display:flex; flex-direction:column; gap:6px; font-size:11px; }
.xmind-project-calendar input:not([type=checkbox]),.xmind-project-calendar select,.xmind-calendar-exception input,.xmind-calendar-exception select { height:31px; padding:0 9px; border:1px solid #dce1e8; background:#fff; border-radius:6px; font-size:12px; outline:none; min-width:0; width:100%; }
.xmind-project-calendar input:focus,.xmind-project-calendar select:focus,.xmind-calendar-exception input:focus,.xmind-calendar-exception select:focus { border-color:#6592ef; box-shadow:0 0 0 2px rgb(53 105 232 / .1); }
.xmind-project-calendar h3 { font-size:12px; font-weight:600; }
.xmind-calendar-import { display:flex; align-items:flex-end; gap:8px; margin-bottom:18px; }.xmind-calendar-import label { flex:1; min-width:0; }.xmind-calendar-import>button { color:#3569e8; font-size:11px; margin-bottom:8px; }
.xmind-calendar-month-header { display:flex; justify-content:space-between; align-items:center; margin-bottom:9px; }.xmind-calendar-month-header button { padding:5px; }.xmind-calendar-month-header strong { font-weight:500; }
.xmind-calendar-month-grid { display:grid; grid-template-columns:repeat(7,minmax(0,1fr)); gap:3px; }.xmind-calendar-month-grid>button,.xmind-calendar-weekday { height:29px; display:grid; place-items:center; border-radius:5px; font-size:11px; }
.xmind-calendar-weekday { color:#8b929d; }.xmind-calendar-month-grid .is-day-off { background:repeating-linear-gradient(135deg,#f1f3f6,#f1f3f6 3px,#e9ecf0 3px,#e9ecf0 4px); color:#89929e; }.xmind-calendar-month-grid .is-other-month { opacity:.4; }.xmind-calendar-month-grid>button[aria-pressed=true] { background:#edf3ff; box-shadow:inset 0 0 0 1px #6592ef; color:#3569e8; }
.xmind-calendar-hint { color:#929aa6; font-size:10px; line-height:1.6; margin-top:9px; }
.xmind-project-calendar .xmind-calendar-skip { flex-direction:row; align-items:center; gap:7px; margin:18px 0; font-size:12px; }.xmind-calendar-working-week { display:flex; justify-content:space-between; gap:5px; padding:12px 0 18px; border-bottom:1px solid #e9edf2; }.xmind-calendar-working-week label { align-items:center; }
.xmind-calendar-rule-heading { display:flex; align-items:center; justify-content:space-between; margin:16px 0 9px; }.xmind-calendar-rule-heading button { padding:5px; background:#f2f4f7; border-radius:5px; }.xmind-calendar-rules { max-height:225px; overflow:auto; }.xmind-calendar-rules>div { display:flex; justify-content:space-between; align-items:center; gap:7px; padding:9px 0; border-bottom:1px solid #eef0f4; }.xmind-calendar-rules>div>button:first-child { flex:1; text-align:left; min-width:0; }.xmind-calendar-rules strong,.xmind-calendar-rules span { display:block; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }.xmind-calendar-rules strong { font-weight:500; }.xmind-calendar-rules span { font-size:10px; color:#8b929d; margin-top:5px; }.xmind-calendar-rules>div>button:last-child { padding:6px; color:#8b929d; }.xmind-calendar-rules>p { color:#929aa6; font-size:11px; padding:10px 0; }
.xmind-project-calendar>footer,.xmind-calendar-exception>footer { padding:13px 20px; display:flex; justify-content:space-between; gap:10px; border-top:1px solid #e2e6ec; background:#fff; }.xmind-project-calendar>footer>div { display:flex; gap:8px; }.xmind-project-calendar>footer button,.xmind-calendar-exception>footer button,.xmind-calendar-personal>button { padding:8px 12px; border:1px solid #dce1e8; border-radius:6px; font-size:11px; }.xmind-project-calendar>footer .xmind-primary-button,.xmind-calendar-exception>footer .xmind-primary-button { border-color:#3569e8; }
.xmind-calendar-personal { border-top:1px solid #e6e9ee; margin-top:14px; padding-top:12px; }.xmind-calendar-personal p { color:#929aa6; font-size:10px; margin:7px 0; }.xmind-calendar-personal>div { display:flex; justify-content:space-between; align-items:center; margin:9px 0; }.xmind-calendar-personal>label { margin:12px 0 8px; }
.xmind-calendar-exception { width:420px; }.xmind-calendar-exception-fields { display:flex; flex-direction:column; gap:12px; padding:0 20px 20px; overflow:auto; }.xmind-calendar-range { display:grid; grid-template-columns:1fr 1fr; gap:12px; }.xmind-calendar-exception>footer { justify-content:flex-end; }
@media(max-width:700px) { .xmind-project-calendar-content { grid-template-columns:1fr; }.xmind-calendar-preview { max-width:360px; width:100%; justify-self:center; }.xmind-project-calendar>footer { flex-wrap:wrap; }.xmind-project-calendar>footer>div:last-child { margin-left:auto; } }
.xmind-notes-popover { position:absolute; z-index:40; width:min(360px,calc(100vw - 32px)); padding:8px; background:#fafbfc; border:1px solid #e5e8ed; border-radius:18px; box-shadow:0 14px 44px rgb(0 0 0 / .14); color:#30343b; }
.xmind-notes-popover>header { display:flex; align-items:center; justify-content:space-between; padding:0 6px; height:24px; font-size:11px; color:#9198a3; }.xmind-notes-popover>header>span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }.xmind-notes-popover>header>div { display:flex; align-items:center; gap:8px; }
.xmind-notes-popover .xmind-notes-editor { border:0; border-radius:12px; }.xmind-notes-popover .xmind-notes-toolbar { border-bottom:0; padding:4px; }.xmind-notes-popover .xmind-note-content { min-height:220px; max-height:280px; padding:8px; }.xmind-note-empty { min-height:220px; padding:16px; font-size:12px; color:#8b929d; }
.xmind-image-export { width:400px; max-width:calc(100vw - 32px); border:1px solid #e5e5e8; border-radius:14px; padding:24px; color:#303034; background:white; box-shadow:0 18px 60px #0002; font:14px NeverMind,Arial,"PingFang SC",sans-serif; }
.xmind-image-export::backdrop { background:#0004; }
.xmind-image-export header,.xmind-image-export footer { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.xmind-image-export h2 { margin:0; font-size:18px; font-weight:600; }
.xmind-image-export p { color:#737378; margin:18px 0 24px; line-height:1.6; }
.xmind-image-export button { display:inline-flex; gap:7px; align-items:center; justify-content:center; min-height:34px; border:0; border-radius:7px; padding:7px 12px; background:#f2f2f4; color:inherit; cursor:pointer; }
.xmind-image-export button:disabled { opacity:.55; cursor:wait; }
.xmind-image-export button.xmind-primary-button { background:#2463eb; color:white; }
.xmind-image-export-field { display:flex; align-items:center; justify-content:space-between; gap:20px; }
.xmind-image-export select { width:220px; height:36px; background:white; border:1px solid #dcdce2; border-radius:7px; padding:0 10px; color:inherit; }
.xmind-image-export-check { display:flex; align-items:center; gap:8px; margin:20px 0 28px; }
.xmind-image-export-check input { accent-color:#2463eb; }
.xmind-image-export footer { justify-content:flex-end; }

View File

@ -0,0 +1,318 @@
"use client";
import {
Bold,
Italic,
Underline,
Strikethrough,
List,
ListOrdered,
Link2,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
createEditor,
Editor,
Element,
Transforms,
type Descendant,
} from "slate";
import { withHistory } from "slate-history";
import {
Editable,
Slate,
withReact,
ReactEditor,
type RenderElementProps,
type RenderLeafProps,
} from "slate-react";
import {
type NoteDocument,
type NoteParagraph,
safeNoteLink,
serializeNoteDocument,
} from "@/lib/flow-board/topic-notes";
export function XmindNotesEditor({
value,
onChange,
}: {
value: NoteDocument;
onChange: (value: NoteDocument) => void;
}) {
const [editor] = useState(() => withHistory(withReact(createEditor())));
const sourceRef = useRef(value);
sourceRef.current = value;
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const [revision, setRevision] = useState(0);
const [linkOpen, setLinkOpen] = useState(false),
[link, setLink] = useState("");
const [linkError, setLinkError] = useState("");
const savedSelection = useRef(editor.selection);
useEffect(() => {
if (
JSON.stringify(serializeNoteDocument(editor.children as NoteDocument)) ===
JSON.stringify(serializeNoteDocument(value))
)
return;
editor.children = value as Descendant[];
editor.selection = null;
editor.history = { undos: [], redos: [] };
editor.onChange();
}, [editor, value]);
const marks = Editor.marks(editor) as Record<string, unknown> | null;
// Toolbar presses can arrive before Slate's throttled selection listener.
const syncDOMSelection = () => {
const selection = window.getSelection();
if (
!selection?.anchorNode ||
!ReactEditor.hasDOMNode(editor, selection.anchorNode)
)
return;
const range = ReactEditor.toSlateRange(editor, selection, {
exactMatch: false,
suppressThrow: true,
});
if (range) Transforms.select(editor, range);
};
const toggleMark = (
mark: "bold" | "italic" | "underline" | "strikethrough"
) => {
syncDOMSelection();
const currentMarks = Editor.marks(editor) as Record<string, unknown> | null;
if (currentMarks?.[mark]) Editor.removeMark(editor, mark);
else Editor.addMark(editor, mark, true);
setRevision((v) => v + 1);
};
const toggleList = (list: "bullet" | "number") => {
syncDOMSelection();
const match = Array.from(
Editor.nodes(editor, { match: (n) => Element.isElement(n) })
)[0];
Transforms.setNodes(
editor,
{
list: (match?.[0] as NoteParagraph)?.list === list ? undefined : list,
} as Partial<Descendant>,
{ match: (n) => Element.isElement(n) }
);
};
const applyLink = () => {
const href = link.trim() ? safeNoteLink(link.trim()) : "";
if (href === null) {
setLinkError("请输入完整的网页或邮件链接。");
return;
}
if (savedSelection.current)
Transforms.select(editor, savedSelection.current);
if (href) Editor.addMark(editor, "href", href);
else Editor.removeMark(editor, "href");
setLinkOpen(false);
editor.onChange();
};
const leaf = ({ attributes, children, leaf }: RenderLeafProps) => {
const t = leaf as {
bold?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
href?: string;
};
const href = safeNoteLink(t.href || "");
return (
<span
{...attributes}
style={{
fontWeight: t.bold ? 700 : undefined,
fontStyle: t.italic ? "italic" : undefined,
textDecoration:
[
t.underline ? "underline" : "",
t.strikethrough ? "line-through" : "",
]
.filter(Boolean)
.join(" ") || undefined,
}}
>
{href ? (
<a
href={href}
title={href}
rel="noopener noreferrer"
onClick={(e) => {
if (!e.metaKey && !e.ctrlKey) e.preventDefault();
}}
target="_blank"
>
{children}
</a>
) : (
children
)}
</span>
);
};
const element = ({ attributes, children, element }: RenderElementProps) => {
const p = element as NoteParagraph;
return (
<p
{...attributes}
data-note-list={p.list}
style={{ marginLeft: Math.max(0, Math.min(120, p.indent || 0)) }}
>
{children}
</p>
);
};
return (
<div className="xmind-notes-editor" data-editor-revision={revision}>
<Slate
editor={editor}
initialValue={value as Descendant[]}
onChange={(next) => {
setRevision((v) => v + 1);
if (editor.operations.every((o) => o.type === "set_selection"))
return;
const doc = next as NoteDocument;
if (
JSON.stringify(serializeNoteDocument(doc)) !==
JSON.stringify(serializeNoteDocument(sourceRef.current))
)
onChangeRef.current(doc);
}}
>
<div
className="xmind-notes-toolbar"
role="toolbar"
aria-label="笔记格式"
>
{(
[
["bold", "加粗", Bold],
["italic", "斜体", Italic],
["underline", "下划线", Underline],
["strikethrough", "删除线", Strikethrough],
] as const
).map(([mark, label, Icon]) => (
<button
key={mark}
type="button"
aria-label={`笔记${label}`}
aria-pressed={Boolean(marks?.[mark])}
onMouseDown={(e) => {
e.preventDefault();
toggleMark(mark);
}}
>
<Icon size={17} />
</button>
))}
<button
type="button"
aria-label="笔记无序列表"
onMouseDown={(e) => {
e.preventDefault();
toggleList("bullet");
}}
>
<List size={17} />
</button>
<button
type="button"
aria-label="笔记有序列表"
onMouseDown={(e) => {
e.preventDefault();
toggleList("number");
}}
>
<ListOrdered size={17} />
</button>
<button
type="button"
aria-label="笔记链接"
onMouseDown={(e) => {
e.preventDefault();
syncDOMSelection();
savedSelection.current = editor.selection;
setLink(String(marks?.href || ""));
setLinkError("");
setLinkOpen(!linkOpen);
}}
>
<Link2 size={17} />
</button>
</div>
{linkOpen ? (
<div className="xmind-note-link-form">
<input
aria-label="笔记链接地址"
placeholder="https://…"
value={link}
onChange={(e) => setLink(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
applyLink();
}
}}
/>
<button type="button" onClick={applyLink}>
确定
</button>
<button
type="button"
aria-label="取消笔记链接"
onClick={() => setLinkOpen(false)}
>
<X size={14} />
</button>
{linkError ? <p role="alert">{linkError}</p> : null}
</div>
) : null}
<Editable
aria-label="主题备注"
className="xmind-note-content"
placeholder="为这个主题补充说明…"
renderLeaf={leaf}
renderElement={element}
onKeyDown={(e) => {
if (
(e.metaKey || e.ctrlKey) &&
["b", "i", "u"].includes(e.key.toLowerCase())
) {
e.preventDefault();
toggleMark(
(
{ b: "bold", i: "italic", u: "underline" } as Record<
string,
"bold" | "italic" | "underline"
>
)[e.key.toLowerCase()]
);
}
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "z")
e.stopPropagation();
if (e.key === "Tab") {
e.preventDefault();
const match = Array.from(
Editor.nodes(editor, { match: (n) => Element.isElement(n) })
)[0];
const indent = (match?.[0] as NoteParagraph)?.indent || 0;
Transforms.setNodes(
editor,
{
indent: Math.max(
0,
Math.min(120, indent + (e.shiftKey ? -20 : 20))
),
} as Partial<Descendant>,
{ match: (n) => Element.isElement(n) }
);
}
}}
/>
</Slate>
</div>
);
}

View File

@ -0,0 +1,111 @@
"use client";
import {
type PlaitBoard,
type PlaitElement,
toHostPointFromViewBoxPoint,
toScreenPointFromHostPoint,
} from "@plait/core";
import { getRectangleByNode, MindElement } from "@plait/mind";
import { MoreHorizontal, X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
getNoteDocument,
type NoteDocument,
} from "@/lib/flow-board/topic-notes";
import { getTopicTitle } from "@/lib/flow-board/topic-details";
import { XmindNotesEditor } from "./xmind-notes-editor";
export function XmindNotesPopover({
board,
topic,
viewport,
onChange,
onDetails,
onClose,
}: {
board: PlaitBoard | null;
topic: PlaitElement | null;
viewport: unknown;
onChange: (doc: NoteDocument) => void;
onDetails: () => void;
onClose: () => void;
}) {
const ref = useRef<HTMLElement>(null);
const [position, setPosition] = useState({ left: 400, top: 300 });
useEffect(() => {
if (!board || !topic) return;
const update = () => {
const node = MindElement.getNode(topic as MindElement);
if (!node) return;
const rect = getRectangleByNode(node),
point = toScreenPointFromHostPoint(
board,
toHostPointFromViewBoxPoint(board, [
rect.x + rect.width / 2,
rect.y + rect.height,
])
);
const host = ref.current?.parentElement?.getBoundingClientRect();
const width = Math.min(360, window.innerWidth - 32);
const right = window.innerWidth > 800 ? 240 : 0;
const left = Math.max(
12,
Math.min(window.innerWidth - right - width - 16, point[0] - width / 2)
);
const top = Math.max(
78,
Math.min(window.innerHeight - 340, point[1] + 12)
);
setPosition({ left: left - (host?.x || 0), top: top - (host?.y || 0) });
};
const frame = requestAnimationFrame(update);
window.addEventListener("resize", update);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", update);
};
}, [board, topic?.id, topic?.data, viewport]);
return (
<aside
ref={ref}
className="xmind-notes-popover"
aria-label="主题笔记"
style={position}
data-flow-board-shell-control="true"
onKeyDown={(e) => {
if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
onClose();
}
}}
>
<header>
<span title={topic ? getTopicTitle(topic) : "笔记"}>
{topic ? getTopicTitle(topic) : "笔记"}
</span>
<div>
<button
type="button"
aria-label="主题详情"
title="主题详情"
onClick={onDetails}
>
<MoreHorizontal size={15} />
</button>
<button type="button" aria-label="关闭笔记" onClick={onClose}>
<X size={15} />
</button>
</div>
</header>
{topic ? (
<XmindNotesEditor
key={topic.id}
value={getNoteDocument(topic)}
onChange={onChange}
/>
) : (
<p className="xmind-note-empty">选择一个主题以编辑笔记。</p>
)}
</aside>
);
}

View File

@ -0,0 +1,650 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
CalendarDays,
ChevronLeft,
ChevronRight,
Plus,
Trash2,
X,
} from "lucide-react";
import {
CALENDAR_FREQUENCIES,
defaultWorkingCalendar,
isWorkingDate,
readWorkingCalendar,
validateWorkingCalendar,
type CalendarRule,
type ProjectCalendarConfig,
type ProjectWorkingCalendar,
} from "@/lib/flow-board/project-calendar";
import {
addDays,
dateIndex,
DAY,
indexDate,
todayDate,
} from "@/lib/flow-board/calendar-dates";
const STORAGE_KEY = "flow-board.personal-calendars.v1";
const WEEKDAYS = ["一", "二", "三", "四", "五", "六", "日"];
const FREQUENCY_LABELS: Record<CalendarRule["frequency"], string> = {
NONE: "不重复",
DAILY: "每天",
WEEKLY: "每周",
MONTHLY_BY_DATE: "每月(按日期)",
MONTHLY_BY_WEEKDAY: "每月(按星期)",
YEARLY: "每年",
};
const copy = <T,>(value: T): T => JSON.parse(JSON.stringify(value));
function useCalendarDialogFocus(ref: React.RefObject<HTMLElement | null>) {
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
ref.current?.querySelector<HTMLElement>("input,button")?.focus();
return () => {
if (previous?.isConnected) previous.focus();
};
}, [ref]);
}
function trapCalendarTab(
event: React.KeyboardEvent,
panel: HTMLElement | null
) {
if (event.key !== "Tab") return;
const controls = Array.from(
panel?.querySelectorAll<HTMLElement>(
"button:not(:disabled),input:not(:disabled),select:not(:disabled)"
) || []
);
const first = controls[0],
last = controls.at(-1);
if (event.shiftKey && document.activeElement === first) {
event.preventDefault();
last?.focus();
}
if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first?.focus();
}
}
function CalendarExceptionEditor({
rule,
onSave,
onClose,
}: {
rule: CalendarRule;
onSave: (rule: CalendarRule) => void;
onClose: () => void;
}) {
const [draft, setDraft] = useState(() => copy(rule)),
[error, setError] = useState("");
const ref = useRef<HTMLElement>(null);
useCalendarDialogFocus(ref);
const patch = (changes: Partial<CalendarRule>) =>
setDraft((value) => ({ ...value, ...changes }));
const save = () => {
const problem = validateWorkingCalendar({
id: "validation",
name: "",
defaultWorkingDays: [1],
rules: [draft],
});
if (problem) {
setError(problem);
return;
}
const selected = draft.selectedDates[0];
if (
(draft.effectiveRange?.start && selected < draft.effectiveRange.start) ||
(draft.effectiveRange?.end && selected > draft.effectiveRange.end)
) {
setError("例外日期必须在生效范围内。");
return;
}
onSave({ ...draft, name: draft.name.trim() || "例外" });
onClose();
};
return (
<div
className="xmind-calendar-exception-backdrop"
data-flow-board-shell-control="true"
onKeyUp={(event) => event.stopPropagation()}
onKeyDown={(event) => {
event.stopPropagation();
trapCalendarTab(event, ref.current);
if (event.key === "Escape") onClose();
}}
>
<section
ref={ref}
className="xmind-calendar-exception"
role="dialog"
aria-modal="true"
aria-label="工作日例外"
>
<header>
<h2>工作日例外</h2>
<button type="button" aria-label="关闭工作日例外" onClick={onClose}>
<X size={17} />
</button>
</header>
<div className="xmind-calendar-exception-fields">
<label>
名称
<input
aria-label="例外名称"
value={draft.name}
onChange={(event) => patch({ name: event.target.value })}
/>
</label>
<label>
日期
<input
type="date"
aria-label="例外日期"
value={draft.selectedDates[0] || ""}
onChange={(event) =>
patch({ selectedDates: [event.target.value] })
}
/>
</label>
<label>
类型
<select
aria-label="例外类型"
value={draft.type}
onChange={(event) =>
patch({ type: event.target.value as CalendarRule["type"] })
}
>
<option value="NON_WORKING_DAY">休息日</option>
<option value="WORKING_DAY">工作日(补班)</option>
</select>
</label>
<label>
重复
<select
aria-label="例外重复规则"
value={draft.frequency}
onChange={(event) =>
patch({
frequency: event.target.value as CalendarRule["frequency"],
})
}
>
{CALENDAR_FREQUENCIES.map((frequency) => (
<option key={frequency} value={frequency}>
{FREQUENCY_LABELS[frequency]}
</option>
))}
</select>
</label>
{draft.frequency !== "NONE" ? (
<label>
每隔
<input
type="number"
min={1}
max={100}
aria-label="例外重复间隔"
value={draft.interval || 1}
onChange={(event) =>
patch({ interval: Number(event.target.value) })
}
/>
</label>
) : null}
<div className="xmind-calendar-range">
<label>
生效开始
<input
type="date"
aria-label="例外生效开始"
value={draft.effectiveRange?.start || ""}
onChange={(event) =>
patch({
effectiveRange: {
...draft.effectiveRange,
start: event.target.value,
},
})
}
/>
</label>
<label>
生效结束
<input
type="date"
aria-label="例外生效结束"
value={draft.effectiveRange?.end || ""}
onChange={(event) =>
patch({
effectiveRange: {
...draft.effectiveRange,
end: event.target.value,
},
})
}
/>
</label>
</div>
<p className="xmind-calendar-hint">
留空表示不限制生效日期。后添加的例外优先。
</p>
{error ? (
<p role="alert" className="xmind-task-error">
{error}
</p>
) : null}
</div>
<footer>
<button type="button" onClick={onClose}>
取消
</button>
<button type="button" className="xmind-primary-button" onClick={save}>
保存例外
</button>
</footer>
</section>
</div>
);
}
export function XmindProjectCalendar({
config,
onSave,
onClose,
}: {
config: ProjectCalendarConfig;
onSave: (config: ProjectCalendarConfig) => boolean;
onClose: () => void;
}) {
const ref = useRef<HTMLElement>(null);
useCalendarDialogFocus(ref);
const [draft, setDraft] = useState(() =>
copy(config.settings || defaultWorkingCalendar())
);
const [skip, setSkip] = useState(config.settings ? config.skip : true),
[error, setError] = useState("");
const [selectedDate, setSelectedDate] = useState(todayDate()),
[month, setMonth] = useState(todayDate().slice(0, 7));
const [exception, setException] = useState<CalendarRule | null>(null),
[manage, setManage] = useState(false);
const [templates, setTemplates] = useState<ProjectWorkingCalendar[]>(() => {
try {
const stored = JSON.parse(localStorage.getItem(STORAGE_KEY) || "[]");
return Array.isArray(stored)
? stored
.map(readWorkingCalendar)
.filter((item): item is ProjectWorkingCalendar => Boolean(item))
: [];
} catch {
return [];
}
});
const [templateName, setTemplateName] = useState("");
const [message, setMessage] = useState("");
const storeTemplates = (value: ProjectWorkingCalendar[]) => {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
setTemplates(value);
return true;
} catch {
setError("个人日历保存失败,请检查浏览器存储空间。");
return false;
}
};
const save = () => {
const problem = validateWorkingCalendar(draft);
if (problem) {
setError(problem);
return;
}
if (
onSave({
settings: { ...draft, name: draft.name.trim() || "项目日历" },
skip,
})
)
onClose();
};
const saveTemplate = () => {
const problem = validateWorkingCalendar(draft);
if (problem) {
setError(problem);
return;
}
if (!templateName.trim()) {
setError("请输入个人日历名称。");
return;
}
const value = {
...copy(draft),
id: crypto.randomUUID(),
name: templateName.trim(),
};
if (storeTemplates([...templates, value])) {
setTemplateName("");
setError("");
setMessage(`已保存个人日历:${value.name}`);
}
};
const moveMonth = (step: number) => {
const date = new Date(`${month}-01T00:00:00Z`);
date.setUTCMonth(date.getUTCMonth() + step);
setMonth(date.toISOString().slice(0, 7));
};
const first = dateIndex(`${month}-01`),
offset = (new Date(first * DAY).getUTCDay() + 6) % 7;
const days = Array.from({ length: 42 }, (_, index) =>
indexDate(first - offset + index)
);
const addException = () =>
setException({
id: crypto.randomUUID(),
name: "",
type: isWorkingDate(selectedDate, draft)
? "NON_WORKING_DAY"
: "WORKING_DAY",
frequency: "NONE",
selectedDates: [selectedDate],
effectiveRange: { start: selectedDate, end: selectedDate },
});
return (
<div
className="xmind-project-calendar-backdrop"
data-flow-board-shell-control="true"
onKeyUp={(event) => event.stopPropagation()}
onKeyDown={(event) => {
event.stopPropagation();
if (exception) return;
trapCalendarTab(event, ref.current);
if (event.key === "Escape") onClose();
}}
>
<section
ref={ref}
className="xmind-project-calendar"
role="dialog"
aria-modal="true"
aria-label="项目工作日"
>
<header>
<div>
<h2>
<CalendarDays size={19} />
项目工作日
</h2>
<p>只应用于当前工作表,不影响其他工作表。</p>
</div>
<button type="button" aria-label="关闭项目工作日" onClick={onClose}>
<X size={18} />
</button>
</header>
<div className="xmind-project-calendar-content">
<div className="xmind-calendar-preview">
<div className="xmind-calendar-import">
<label>
导入日历
<select
aria-label="导入个人日历"
value=""
onChange={(event) => {
const value =
event.target.value === "basic"
? { ...defaultWorkingCalendar(), name: "基本日历" }
: templates.find(
(item) => item.id === event.target.value
);
if (value) {
setDraft(copy(value));
setError("");
}
}}
>
<option value="">当前项目:{draft.name}</option>
<option value="basic">基本日历(周一至周五)</option>
{templates.map((item) => (
<option key={item.id} value={item.id}>
{item.name}
</option>
))}
</select>
</label>
<button
type="button"
onClick={() => setManage((value) => !value)}
>
管理
</button>
</div>
<div className="xmind-calendar-month-header">
<button
type="button"
aria-label="上个月"
onClick={() => moveMonth(-1)}
>
<ChevronLeft size={16} />
</button>
<strong>{month.replace("-", "年")}月</strong>
<button
type="button"
aria-label="下个月"
onClick={() => moveMonth(1)}
>
<ChevronRight size={16} />
</button>
</div>
<div className="xmind-calendar-month-grid" aria-label="工作日预览">
{WEEKDAYS.map((day) => (
<span className="xmind-calendar-weekday" key={day}>
{day}
</span>
))}
{days.map((date) => (
<button
type="button"
key={date}
data-calendar-date={date}
data-working={isWorkingDate(date, draft)}
aria-label={`${date} ${isWorkingDate(date, draft) ? "工作日" : "休息日"}`}
aria-pressed={selectedDate === date}
className={`${date.slice(0, 7) !== month ? "is-other-month" : ""} ${isWorkingDate(date, draft) ? "" : "is-day-off"}`}
onClick={() => setSelectedDate(date)}
>
{Number(date.slice(8))}
</button>
))}
</div>
<p className="xmind-calendar-hint">
阴影为休息日。选择日期后可添加例外。
</p>
{manage ? (
<div className="xmind-calendar-personal">
<h3>个人日历</h3>
<p>保存在当前浏览器,可用于其他工作表。</p>
{templates.length ? (
templates.map((item) => (
<div key={item.id}>
<span>{item.name}</span>
<button
type="button"
aria-label={`删除个人日历 ${item.name}`}
onClick={() =>
storeTemplates(
templates.filter((value) => value.id !== item.id)
)
}
>
<Trash2 size={13} />
</button>
</div>
))
) : (
<p>还没有个人日历。</p>
)}
<label>
名称
<input
aria-label="个人日历名称"
value={templateName}
onChange={(event) => setTemplateName(event.target.value)}
/>
</label>
<button type="button" onClick={saveTemplate}>
保存为个人日历
</button>
</div>
) : null}
</div>
<div className="xmind-calendar-settings">
<label>
日历名称
<input
aria-label="项目日历名称"
value={draft.name}
onChange={(event) =>
setDraft((value) => ({ ...value, name: event.target.value }))
}
/>
</label>
<label className="xmind-calendar-skip">
<input
type="checkbox"
aria-label="跳过非工作日"
checked={skip}
onChange={(event) => setSkip(event.target.checked)}
/>
跳过非工作日
</label>
<h3>每周工作日</h3>
<div className="xmind-calendar-working-week">
{WEEKDAYS.map((day, index) => (
<label key={day}>
<input
type="checkbox"
aria-label={`周${day}为工作日`}
checked={draft.defaultWorkingDays.includes(index + 1)}
onChange={(event) => {
const value = event.target.checked
? [...draft.defaultWorkingDays, index + 1].sort()
: draft.defaultWorkingDays.filter(
(value) => value !== index + 1
);
if (!value.length) {
setError("请至少保留一个每周工作日。");
return;
}
setDraft((settings) => ({
...settings,
defaultWorkingDays: value,
}));
setError("");
}}
/>
{day}
</label>
))}
</div>
<div className="xmind-calendar-rule-heading">
<h3>例外</h3>
<button
type="button"
aria-label="添加工作日例外"
onClick={addException}
>
<Plus size={16} />
</button>
</div>
<div className="xmind-calendar-rules">
{[...draft.rules].reverse().map((rule) => (
<div key={rule.id}>
<button
type="button"
aria-label={`编辑工作日例外 ${rule.name}`}
onClick={() => setException(copy(rule))}
>
<strong>{rule.name}</strong>
<span>
{rule.type === "WORKING_DAY" ? "补班" : "休息日"} ·{" "}
{rule.selectedDates.join("、")} ·{" "}
{FREQUENCY_LABELS[rule.frequency]}
{rule.interval && rule.interval > 1
? `(间隔 ${rule.interval})`
: ""}
</span>
</button>
<button
type="button"
aria-label={`删除工作日例外 ${rule.name}`}
onClick={() =>
setDraft((value) => ({
...value,
rules: value.rules.filter(
(item) => item.id !== rule.id
),
}))
}
>
<Trash2 size={14} />
</button>
</div>
))}
{!draft.rules.length ? (
<p>可添加节假日、调休或重复例外。</p>
) : null}
</div>
{error ? (
<p role="alert" className="xmind-task-error">
{error}
</p>
) : null}
{message ? <p role="status">{message}</p> : null}
</div>
</div>
<footer>
<div>
{config.settings ? (
<button
type="button"
className="xmind-danger"
onClick={() => {
if (onSave({ settings: null, skip: false })) onClose();
}}
>
移除项目日历
</button>
) : null}
<button type="button" onClick={() => setManage(true)}>
保存为日历
</button>
</div>
<div>
<button type="button" onClick={onClose}>
取消
</button>
<button
type="button"
className="xmind-primary-button"
onClick={save}
>
保存工作日
</button>
</div>
</footer>
</section>
{exception ? (
<CalendarExceptionEditor
rule={exception}
onSave={(rule) =>
setDraft((value) => ({
...value,
rules: value.rules.some((item) => item.id === rule.id)
? value.rules.map((item) => (item.id === rule.id ? rule : item))
: [...value.rules, rule],
}))
}
onClose={() => setException(null)}
/>
) : null}
</div>
);
}

View File

@ -0,0 +1,384 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { CalendarDays, X } from "lucide-react";
import type { PlaitElement } from "@plait/core";
import { getTopicEntries, getTopicTitle } from "@/lib/flow-board/topic-details";
import {
activeWorkingCalendar,
getProjectCalendar,
taskDueDate,
workingDaysBetween,
} from "@/lib/flow-board/project-calendar";
import {
DAY,
getTopicTask,
newTopicTask,
validateTopicTask,
type TopicTask,
type TaskDependency,
} from "@/lib/flow-board/topic-tasks";
export function XmindTaskEditor({
topic,
elements,
onSave,
onClose,
onGantt,
onCalendar,
}: {
topic: PlaitElement;
elements: PlaitElement[];
onSave: (id: string, task: TopicTask | null) => void;
onClose: () => void;
onGantt: () => void;
onCalendar: () => void;
}) {
const dialogRef = useRef<HTMLElement>(null);
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
dialogRef.current?.querySelector<HTMLInputElement>("input")?.focus();
return () => {
if (previous?.isConnected) previous.focus();
};
}, []);
const calendar = activeWorkingCalendar(getProjectCalendar(elements));
const existing = getTopicTask(topic);
const [draft, setDraft] = useState<TopicTask>(
() => existing || newTopicTask()
);
const [error, setError] = useState("");
const [assigneesDraft, setAssigneesDraft] = useState(
(existing?.assignees || []).join(", ")
);
const [dependency, setDependency] = useState("");
const [dependencyType, setDependencyType] =
useState<TaskDependency["type"]>("FS");
const [lag, setLag] = useState("0");
const source = JSON.stringify(existing);
useEffect(() => {
setDraft(getTopicTask(topic) || newTopicTask());
setAssigneesDraft((getTopicTask(topic)?.assignees || []).join(", "));
setError("");
}, [topic.id, source]);
const tasks = getTopicEntries(elements).filter(
(e) => e.element.id !== topic.id && getTopicTask(e.element)
);
const patch = (p: Partial<TopicTask>) => setDraft((t) => ({ ...t, ...p }));
const withAssignees = () => ({
...draft,
assignees: [
...new Set(
assigneesDraft
.split(/[,,]/)
.map((s) => s.trim())
.filter(Boolean)
),
],
});
const save = () => {
const task = withAssignees();
const problem = validateTopicTask(topic.id, task, elements);
if (problem) {
setError(problem);
return;
}
onSave(topic.id, task);
onClose();
};
return (
<div
className="xmind-task-backdrop"
data-flow-board-shell-control="true"
onKeyUp={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === "Escape") {
e.stopPropagation();
onClose();
}
if (e.key === "Tab") {
const controls = Array.from(
dialogRef.current?.querySelectorAll<HTMLElement>(
"button:not(:disabled),input,select"
) || []
);
const first = controls[0],
last = controls.at(-1);
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
}
if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
}
}}
>
<section
role="dialog"
aria-modal="true"
aria-label="任务信息"
className="xmind-task-editor"
ref={dialogRef}
>
<header>
<h2>
<CalendarDays size={17} />
任务信息
</h2>
<button type="button" aria-label="关闭任务信息" onClick={onClose}>
<X size={17} />
</button>
</header>
<p className="xmind-task-name">{getTopicTitle(topic)}</p>
<div className="xmind-task-fields">
<label>
开始日期
<input
type="date"
aria-label="任务开始日期"
value={draft.start}
onChange={(e) => {
const start = e.target.value;
patch({
start,
due:
start && draft.duration >= 1
? taskDueDate(start, draft.duration, calendar)
: "",
});
}}
/>
</label>
<label>
截止日期
<input
type="date"
aria-label="任务截止日期"
value={draft.due}
onChange={(e) => {
const due = e.target.value;
patch({
due,
...(draft.start && due && due >= draft.start
? {
duration: Math.max(
1,
workingDaysBetween(draft.start, due, calendar)
),
}
: {}),
});
}}
/>
</label>
<label>
{calendar ? "时长(工作日)" : "时长(天)"}
<input
type="number"
aria-label="任务工期"
min={1}
max={36500}
value={draft.duration}
onChange={(e) => {
const duration = Number(e.target.value);
patch({
duration,
...(draft.start && duration >= 1 && duration <= 36500
? { due: taskDueDate(draft.start, duration, calendar) }
: {}),
});
}}
/>
</label>
<label>
进度(%)
<input
type="number"
aria-label="任务进度"
min={0}
max={100}
value={draft.progress}
onChange={(e) => patch({ progress: Number(e.target.value) })}
/>
</label>
<label>
优先级
<select
aria-label="任务优先级"
value={draft.priority ?? ""}
onChange={(e) =>
patch({
priority:
e.target.value === "" ? null : Number(e.target.value),
})
}
>
<option value="">无</option>
{Array.from({ length: 10 }, (_, i) => (
<option value={i} key={i}>
P{i}
</option>
))}
</select>
</label>
<label>
负责人
<input
aria-label="任务负责人"
placeholder="多人用逗号分隔"
value={assigneesDraft}
onChange={(e) => setAssigneesDraft(e.target.value)}
/>
</label>
</div>
<h3>前置任务</h3>
<div className="xmind-task-dependencies">
{draft.dependencies.map((dep, i) => (
<div key={`${dep.id}-${i}`}>
<span>
{tasks.find((e) => e.element.id === dep.id)?.title ||
"已删除的任务"}{" "}
· {dep.type}
{dep.lag ? ` · ${dep.lag / DAY} 天` : ""}
</span>
<button
type="button"
aria-label={`移除前置任务 ${tasks.find((e) => e.element.id === dep.id)?.title || dep.id}`}
onClick={() =>
patch({
dependencies: draft.dependencies.filter((_, n) => n !== i),
})
}
>
<X size={13} />
</button>
</div>
))}
</div>
<div className="xmind-task-add-dependency">
<select
aria-label="前置任务"
value={dependency}
onChange={(e) => setDependency(e.target.value)}
>
<option value="">选择任务</option>
{tasks.map((e) => (
<option value={e.element.id} key={e.element.id}>
{e.title}
</option>
))}
</select>
<select
aria-label="依赖类型"
value={dependencyType}
onChange={(e) =>
setDependencyType(e.target.value as TaskDependency["type"])
}
>
<option value="FS">完成 → 开始</option>
<option value="SS">开始 → 开始</option>
<option value="FF">完成 → 完成</option>
<option value="SF">开始 → 完成</option>
</select>
<input
type="number"
aria-label="依赖间隔天数"
value={lag}
onChange={(e) => setLag(e.target.value)}
title="间隔天数(负数为提前)"
/>
<button
type="button"
disabled={!dependency}
onClick={() => {
const next = {
...draft,
dependencies: [
...draft.dependencies.filter((d) => d.id !== dependency),
{
id: dependency,
type: dependencyType,
lag: Number(lag) * DAY,
},
],
};
const problem = validateTopicTask(topic.id, next, elements);
if (problem) setError(problem);
else {
setDraft(next);
setDependency("");
setError("");
}
}}
>
添加
</button>
</div>
{error ? (
<p role="alert" className="xmind-task-error">
{error}
</p>
) : null}
<div className="xmind-task-calendar-info">
<span>
{calendar ? `已跳过非工作日 · ${calendar.name}` : "包含非工作日"}
</span>
<button
type="button"
onClick={() => {
const task = withAssignees(),
problem = validateTopicTask(topic.id, task, elements);
if (problem) {
setError(problem);
return;
}
onSave(topic.id, task);
onClose();
onCalendar();
}}
>
项目工作日
</button>
</div>
<footer>
{existing ? (
<button
type="button"
className="xmind-danger"
onClick={() => {
onSave(topic.id, null);
onClose();
}}
>
移除任务
</button>
) : (
<button type="button" onClick={onClose}>
取消
</button>
)}
<button
type="button"
onClick={() => {
const task = withAssignees();
const problem = validateTopicTask(topic.id, task, elements);
if (problem) {
setError(problem);
return;
}
onSave(topic.id, task);
onClose();
onGantt();
}}
>
查看甘特图
</button>
<button type="button" className="xmind-primary-button" onClick={save}>
保存任务
</button>
</footer>
</section>
</div>
);
}

View File

@ -0,0 +1,139 @@
"use client";
import {
type PlaitBoard,
type PlaitElement,
toHostPointFromViewBoxPoint,
toScreenPointFromHostPoint,
} from "@plait/core";
import { MindElement, getRectangleByNode } from "@plait/mind";
import { FileText } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { getTopicDetails, getTopicTitle } from "@/lib/flow-board/topic-details";
import { getTopicTask } from "@/lib/flow-board/topic-tasks";
export function XmindTopicBadges({
board,
elements,
viewport,
onNotes,
onTask,
}: {
board: PlaitBoard | null;
elements: PlaitElement[];
viewport: unknown;
onNotes: (id: string) => void;
onTask: (id: string) => void;
}) {
const hostRef = useRef<HTMLDivElement>(null);
const [badges, setBadges] = useState<
{
id: string;
title: string;
notes: boolean;
progress: number | null;
left: number;
top: number;
}[]
>([]);
useEffect(() => {
if (!board) return;
const update = () => {
const host = hostRef.current;
if (!host) return;
const origin = host.getBoundingClientRect();
const result: typeof badges = [];
const visit = (children: PlaitElement[]) => {
for (const element of children) {
const notes = Boolean(getTopicDetails(element).notes.trim()),
task = getTopicTask(element);
if (notes || task) {
const node = MindElement.getNode(element as MindElement);
if (node) {
const rect = getRectangleByNode(node);
const point = toScreenPointFromHostPoint(
board,
toHostPointFromViewBoxPoint(board, [
rect.x + rect.width,
rect.y + rect.height / 2,
])
);
result.push({
id: element.id,
title: getTopicTitle(element),
notes,
progress: task?.progress ?? null,
left: point[0] - origin.x + 5,
top: point[1] - origin.y - 9,
});
}
}
if (!element.isCollapsed && element.children) visit(element.children);
}
};
visit(elements);
setBadges(result);
};
const frame = requestAnimationFrame(update);
window.addEventListener("resize", update);
return () => {
cancelAnimationFrame(frame);
window.removeEventListener("resize", update);
};
}, [board, elements, viewport]);
return (
<div ref={hostRef} className="xmind-topic-badges" aria-label="主题内容提示">
{badges.map((b) => (
<div
key={b.id}
className="xmind-topic-badge"
style={{ left: b.left, top: b.top }}
data-flow-board-shell-control="true"
>
{b.progress !== null ? (
<button
type="button"
aria-label={`编辑任务 ${b.title}`}
title={`任务进度 ${b.progress}%`}
onClick={() => onTask(b.id)}
>
<svg
aria-hidden="true"
width={16}
height={16}
viewBox="0 0 16 16"
>
<circle
cx={8}
cy={8}
r={6}
fill="#f8fafc"
stroke="#afb7c4"
strokeWidth={1.4}
/>
<circle
cx={8}
cy={8}
r={6}
fill="none"
stroke="#6b8ee4"
strokeWidth={3}
strokeDasharray={`${(b.progress * 37.7) / 100} 37.7`}
transform="rotate(-90 8 8)"
/>
</svg>
</button>
) : null}
{b.notes ? (
<button
type="button"
aria-label={`查看笔记 ${b.title}`}
title="查看笔记"
onClick={() => onNotes(b.id)}
>
<FileText size={15} />
</button>
) : null}
</div>
))}
</div>
);
}

View File

@ -18,6 +18,9 @@ import {
type TopicEntry,
} from "@/lib/flow-board/topic-details";
import { getNoteDocument } from "@/lib/flow-board/topic-notes";
import { XmindNotesEditor } from "./xmind-notes-editor";
export type TopicDetailSection = "notes" | "links" | "labels";
export function XmindTopicPanel({
@ -41,7 +44,7 @@ export function XmindTopicPanel({
const [attachmentError, setAttachmentError] = useState("");
const [isReadingAttachment, setIsReadingAttachment] = useState(false);
const attachmentRef = useRef<HTMLInputElement>(null);
const noteRef = useRef<HTMLTextAreaElement>(null);
const linkRef = useRef<HTMLInputElement>(null);
const labelRef = useRef<HTMLInputElement>(null);
useEffect(() => {
@ -52,9 +55,8 @@ export function XmindTopicPanel({
setAttachmentError("");
}, [topic?.id]);
useEffect(() => {
({ notes: noteRef, links: linkRef, labels: labelRef })[
section
].current?.focus();
if (section === "links") linkRef.current?.focus();
if (section === "labels") labelRef.current?.focus();
}, [section]);
const safeHref = getSafeTopicHref(linkDraft);
const invalidHref = Boolean(linkDraft.trim()) && !safeHref;
@ -114,20 +116,10 @@ export function XmindTopicPanel({
<p className="truncate font-medium" title={getTopicTitle(topic)}>
{getTopicTitle(topic)}
</p>
<label className="block space-y-2">
<span className="flex items-center gap-2 font-medium">
<StickyNote size={14} />
备注
</span>
<textarea
aria-label="主题备注"
ref={noteRef}
className="min-h-40 w-full resize-y rounded border border-[#dfe2e7] p-2 text-sm leading-6 outline-none focus:border-blue-400"
placeholder="为这个主题补充说明…"
value={details.notes}
onChange={(event) => onPatch({ notes: event.target.value })}
/>
</label>
<section className="space-y-2">
<h3 className="flex items-center gap-2 font-medium"><StickyNote size={14} />笔记</h3>
<XmindNotesEditor key={topic.id} value={getNoteDocument(topic)} onChange={(notesDocument) => onPatch({notesDocument})} />
</section>
<section className="space-y-2">
<h3 className="flex items-center gap-2 font-medium">
<Tag size={14} />

View File

@ -44,3 +44,14 @@
- 标记在主题上以客户端图标呈现,支持 XMind 导入导出。未识别的 ID 继续作为元数据保留,不能宣称所有第三方自定义标记都可显示。贴纸、插画、自定义标记库及图例交互仍未实现;原先禁用的占位页签已移除。
- 3 项标记浏览器测试覆盖面板尺寸、实际图标加载、增删替换、清空、批量撤销/重做、搜索和更多标记;2 项数据测试覆盖全部目录资源存在、197 个 ID 文件往返及旧 Emoji 兼容。TypeScript 与本轮文件的 Biome 检查通过。
- 连续操作发现成功提示会盖住顶部插入菜单;将白板内的提示移至底部,并取消清空标记后的遮挡提示。图片粘贴用例等待画布字体加载后再操作,避免加载中的坐标变化影响双击。
## 2026-10-06 笔记与甘特图
已加入富文本笔记、主题笔记/任务提示、任务信息窗口和甘特图。甘特图参考客户端左右表格/时间网格、橙色胶囊时间条、任务名称与负责人提示、选中行高亮、圆角容器、缩放和今天线,按项目现有控件尺寸实现。日级任务可编辑日期/工期、进度、优先级、负责人及四种前置关系;拖动和拉伸支持联动排期及批次撤销。
文件格式按安装包核对:最新笔记使用 `realHTML.content`,任务使用 `org.xmind.ui.task` 扩展、0–1 进度和毫秒工期。笔记默认在主题旁的浮窗编辑,支持展开主题详情侧栏;甘特图高级工作日日历、小时级及里程碑、团队成员服务和原生全部导出方式尚未对等。
本机 XMind 已实际打开本轮导出文件,任务卡片和原生甘特图正确显示 2026 年 10 月 5–6 日(2 天)与 7–9 日(3 天)、负责人“小明”;原生甘特图的前置关系箭头正常显示;笔记浮窗显示“1. 交付说明”、加粗及蓝色链接。指定样例的原生读取已验证,不能据此认定全部复杂笔记和任务设置均无损。
本轮 9 项数据检查、15 项浏览器检查(分批运行)、TypeScript 和相关代码检查通过。浏览器覆盖笔记与甘特图,并回归标记、框选、图片粘贴、主题内容及文件往返;首次空白画布导出超时后的复测已通过。快捷键不再把笔记全选变成画布多选。

View File

@ -19,9 +19,9 @@
| 外框 | 基础 | 标题、全形状、嵌套、填充、范围调整 |
| 概要 | 基础 | 范围调整、多重概要、完整样式 |
| 图标/标记、贴纸 | 原生标记可用,贴纸/插画待实现 | 自定义标记、图例交互及贴纸/插画库 |
| 备注、外链、主题链接、标签 | 基础 | 已验证纯文本备注、标签和外链编辑、撤销、工作表切换、快照序列化;同画布链接可展开折叠分支定位。待补富文本备注、跨画布链接、画布内容标识、真实云端保存重载 |
| 备注、外链、主题链接、标签 | 基础 | 已增加富文本笔记(加粗、斜体、下划线、删除线、列表、缩进、链接),写入客户端最新 realHTML 和 plain 格式并保留旧段落格式;支持笔记提示、撤销和项目内往返。标签和外链编辑、工作表切换已验证;待补备注图片、跨画布链接和真实云端保存重载 |
| 图片、附件、音频、公式 | 部分支持 | 新旧 XMind 内嵌 PNG/JPEG/GIF/WebP 导入;附件新增/替换/下载/移除/撤销。待补 SVG、音频、公式、资源导出和真实样本覆盖 |
| 任务、待办、进度、日期、负责人、甘特图 | 待实现 | 任务模型、视图和筛选,不能用普通图标替代 |
| 任务、待办、进度、日期、负责人、甘特图 | 任务及甘特图基础已实现 | 任务开始/截止日期、日级工期、进度、优先级、多负责人、四种依赖;甘特图拖动/拉伸、联动排期、批次撤销、汇总任务、折叠、筛选、缩放、今天定位、CSV 和浏览器打印;原生任务扩展导入导出。待办模型、工作日日历、小时级/里程碑、原生全部导出与团队成员服务待补 |
| 查找替换、过滤、聚焦、导航 | 查找替换基础 | 已验证折叠后代、当前/全部画布范围、跨工作表定位、替换批量撤销/重做和跨富文本叶替换;过滤、分支聚焦待补 |
| 大纲视图 | 基础 | 已验证画布双向改名、新增/删除、缩进/提升、拖动重排、展开折叠、缩放和撤销;待补混合富文本、批量选区、分支聚焦、大纲专用导出和原生视觉对照 |
| 浮动工具栏、主题添加按钮 | 待新版对照 | 26.05 安装包有新增编辑体验文案,需按原生行为补齐 |
@ -32,7 +32,7 @@
| XMind 新版 JSON / 旧版 XML 导入 | 基础 | 资源、全部样式、结构和扩展内容;真实样本库 |
| MD、OPML、FreeMind 导入 | 基础 | 更多格式与大文件性能 |
| XMind 导出与往返兼容 | 基础,指定原生往返样例已核验 | 本机 26.04 实际打开网页导出文件、修改字号并保存回传,指定主题的字号/纯文本备注/附件字节保留;全结构、复杂样式与混合富文本的原生无损往返仍待核验 |
| PNG/SVG/PDF、Markdown/OPML、Office 导出 | 待逐项核验 | 全图/选区/单页/多页、透明背景、纸张和清晰度 |
| PNG/SVG/PDF、Markdown/OPML、Office 导出 | PNG 已支持;其余待核验 | 当前工作表全图 PNG,1/2/3 倍清晰度、画布背景或透明背景;选区、多页和其他格式待补 |
| 打印、密码、缓存、文件恢复 | 待实现 | 不得以浏览器存储代替完整文件保护 |
| 模板、主题库、手绘风格 | 待实现/部分底层能力 | 内置模板、自定义、导入与复用 |
| 协作、评论、分享、权限 | 待实现 | 项目已有登录不等于实时协作完成 |
@ -97,3 +97,15 @@
- 结构面板增加两枚独立 SVG 图标;表格主题使用整格几何参与命中和画布适配。修复 8 位 XMind 色值、原生圆角主题边框残留和新增长文本裁切。
- 27 项数据测试、15 项浏览器测试和 TypeScript 检查通过;全量浏览器回归再次覆盖安装包的 9 个基础样例。另用安装包中的 `mood_tracker.xmind`、`coordinating_upcoming_release_1.0.xmind`、`tree_table.xmind`、`film_production.xmind` 完成主题完整性导入导出验证。
- 当前矩阵支持 XMind 的列扩展和标签映射;列增删/改名的专用交互、复杂嵌套子树、原生应用打开导出文件和逐像素多主题对照仍待实现。
## 2026-10-06 笔记与甘特图补齐
- 标记功能已先提交到本地 Git:`e6a68b34`。
- 富文本笔记使用现有 Slate 编辑能力,支持选中文字或继续输入时的格式、列表、缩进及安全链接;画布笔记提示可直接打开编辑。读取现代 `realHTML`、旧 `html.content.paragraphs` 与纯文本笔记,导出同时写入 `realHTML`/`plain` 和旧格式。格式依据已安装客户端 `6996.js` 的 Converter 核对。
- 任务写入原生 `org.xmind.ui.task` 扩展;日期使用本地日历日边界,进度存为 0–1,工期和依赖间隔存为毫秒。导入/导出重映射依赖主题 ID,保留其他原生扩展。
- 甘特图与画布共用主题数据及历史。可添加/编辑/移除任务、按前置关系排期、拒绝循环或父子依赖、拖动/拉伸时间条、按任务名称/负责人/进度筛选,支持汇总任务范围/进度、缩放、定位今天、CSV 导出和浏览器打印。移除任务时清理其他任务的引用,一次撤销恢复全部受影响任务。
- 本轮 9 项笔记/任务/导出数据检查、15 项浏览器检查(分批运行)、TypeScript 和相关文件 Biome 检查通过。浏览器覆盖富文本选区/列表/链接/撤销、任务日期/依赖/拖动/拉伸、校验/CSV/工作表隔离,以及原有图片粘贴、标记、框选、主题内容和导出。首次整批运行的空白画布导出用例超时,关闭追踪录制后单独复测通过;检查日志保留在本地验证目录。正式云账号保存重载没有在本轮验证。
- 已在安装的 XMind 26.04 中打开网页导出文件:笔记浮窗显示有序列表、加粗和蓝色链接,原生甘特图显示两条时间条、日期/工期/负责人和前置关系箭头。网页时间条改为客户端的橙色胶囊外观,显示任务名与负责人提示,选中行和时间条同步高亮。
- 修复笔记快捷键被画布拦截及快速选区格式不同步的问题;笔记、侧栏输入和任务窗口的键盘/剪贴板事件各自处理,编辑时保留当前主题。
- 当前是可用的富文本笔记和日级甘特图能力,未宣称与客户端全部功能或像素完全一致。高级工作日日历、小时/里程碑、原生全部导出格式、团队成员服务及笔记图片仍需继续补齐。

View File

@ -0,0 +1,24 @@
export const DAY = 86_400_000;
export function dateIndex(date: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) return NaN;
const timestamp = Date.parse(`${date}T00:00:00Z`);
return Number.isFinite(timestamp) &&
new Date(timestamp).toISOString().slice(0, 10) === date
? timestamp / DAY
: NaN;
}
export const indexDate = (index: number) =>
new Date(index * DAY).toISOString().slice(0, 10);
export const addDays = (date: string, days: number) =>
indexDate(dateIndex(date) + days);
export function timestampDate(value: unknown) {
if (typeof value !== "number" || !Number.isFinite(value)) return "";
const date = new Date(value);
if (!Number.isFinite(date.getTime())) return "";
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`;
}
export const todayDate = () => timestampDate(Date.now());
export const dateTimestamp = (date: string, end = false) =>
Number.isFinite(dateIndex(date))
? new Date(`${date}T${end ? "23:59:59.999" : "00:00:00"}`).getTime()
: null;

View File

@ -0,0 +1,137 @@
import { PlaitBoard } from "@plait/core";
export type ImageExportOptions = {
scale: 1 | 2 | 3;
transparent: boolean;
backgroundColor: string;
};
const textStyles = [
"display",
"box-sizing",
"margin",
"padding",
"width",
"height",
"font-family",
"font-size",
"font-weight",
"font-style",
"line-height",
"color",
"background-color",
"text-align",
"text-decoration",
"white-space",
"word-break",
"overflow-wrap",
"border-radius",
"vertical-align",
];
async function embedResource(url: string): Promise<string> {
const response = await fetch(url, { signal: AbortSignal.timeout(15_000) });
if (!response.ok) throw new Error("图片资源加载失败,请稍后重试");
const blob = await response.blob();
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(String(reader.result));
reader.onerror = () => reject(new Error("图片资源读取失败"));
reader.readAsDataURL(blob);
});
}
/** Render the complete content layer in board coordinates, independent of pan/zoom. */
export async function exportBoardImage(
board: PlaitBoard,
options: ImageExportOptions
) {
if (!board.children.length) throw new Error("当前工作表没有可导出的内容");
await document.fonts.ready;
const host = PlaitBoard.getElementHost(board);
const bounds = host.getBBox();
const padding = 32;
const width = Math.ceil(bounds.width + padding * 2);
const height = Math.ceil(bounds.height + padding * 2);
const pixelWidth = width * options.scale;
const pixelHeight = height * options.scale;
if (!bounds.width || !bounds.height)
throw new Error("画布尚未准备好,请稍后重试");
if (
pixelWidth > 16_384 ||
pixelHeight > 16_384 ||
pixelWidth * pixelHeight > 64_000_000
) {
throw new Error("图片尺寸过大,请降低清晰度或拆分工作表后导出");
}
const clone = host.cloneNode(true) as SVGGElement;
clone.removeAttribute("transform");
const sources = host.querySelectorAll("foreignObject *");
const copies = clone.querySelectorAll("foreignObject *");
sources.forEach((source, index) => {
const copy = copies[index] as HTMLElement;
const computed = getComputedStyle(source);
for (const property of textStyles)
copy.style.setProperty(property, computed.getPropertyValue(property));
copy.removeAttribute("contenteditable");
});
const resources = new Map<string, Promise<string>>();
await Promise.all(
Array.from(clone.querySelectorAll("img, image")).map(async (node) => {
const attribute = node.tagName.toLowerCase() === "img" ? "src" : "href";
const url =
node.getAttribute(attribute) ||
node.getAttributeNS("http://www.w3.org/1999/xlink", "href");
if (!url || url.startsWith("data:")) return;
if (!resources.has(url)) resources.set(url, embedResource(url));
node.setAttribute(attribute, await resources.get(url)!);
node.removeAttributeNS("http://www.w3.org/1999/xlink", "href");
})
);
const namespace = "http://www.w3.org/2000/svg";
const svg = document.createElementNS(namespace, "svg");
svg.setAttribute("width", String(width));
svg.setAttribute("height", String(height));
svg.setAttribute(
"viewBox",
`${bounds.x - padding} ${bounds.y - padding} ${width} ${height}`
);
for (const defs of PlaitBoard.getHost(board).querySelectorAll(
":scope > defs"
))
svg.append(defs.cloneNode(true));
const style = document.createElementNS(namespace, "style");
const weights = [400, 500, 700, 800];
const names = ["Regular", "Medium", "Bold", "Extrabold"];
style.textContent = (
await Promise.all(
names.map(async (name, index) => {
const data = await embedResource(`/fonts/xmind/NeverMind-${name}.ttf`);
return `@font-face{font-family:NeverMind;src:url('${data}') format('truetype');font-weight:${weights[index]};}`;
})
)
).join("\n");
svg.append(style, clone);
const image = new Image();
const svgData = new XMLSerializer().serializeToString(svg);
// A self-contained data URI keeps foreignObject content safe to draw on canvas.
image.src = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svgData)}`;
await image.decode();
const canvas = document.createElement("canvas");
canvas.width = pixelWidth;
canvas.height = pixelHeight;
const context = canvas.getContext("2d");
if (!context) throw new Error("浏览器无法生成图片");
if (!options.transparent) {
context.fillStyle = options.backgroundColor;
context.fillRect(0, 0, pixelWidth, pixelHeight);
}
context.drawImage(image, 0, 0, pixelWidth, pixelHeight);
const blob = await new Promise<Blob>((resolve, reject) =>
canvas.toBlob(
(value) => (value ? resolve(value) : reject(new Error("图片生成失败"))),
"image/png"
)
);
return { blob, width: pixelWidth, height: pixelHeight };
}

View File

@ -0,0 +1,288 @@
import type { PlaitElement } from "@plait/core";
import { getTopicEntries } from "./topic-details";
import { addDays, dateIndex, DAY, indexDate } from "./calendar-dates";
export const CALENDAR_PROVIDER = "org.xmind.ui.working-day-settings";
export const CALENDAR_FREQUENCIES = [
"NONE",
"DAILY",
"WEEKLY",
"MONTHLY_BY_DATE",
"MONTHLY_BY_WEEKDAY",
"YEARLY",
] as const;
export type CalendarRule = {
id: string;
name: string;
selectedDates: string[];
type: "WORKING_DAY" | "NON_WORKING_DAY";
frequency: (typeof CALENDAR_FREQUENCIES)[number];
interval?: number;
effectiveRange?: { start?: string; end?: string };
};
export type ProjectWorkingCalendar = {
id: string;
name: string;
defaultWorkingDays: number[];
rules: CalendarRule[];
effectiveRange?: { start?: string; end?: string };
};
export type ProjectCalendarConfig = {
settings: ProjectWorkingCalendar | null;
skip: boolean;
};
const record = (value: unknown): Record<string, unknown> =>
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
export function defaultWorkingCalendar(): ProjectWorkingCalendar {
return {
id: crypto.randomUUID(),
name: "基本日历",
defaultWorkingDays: [1, 2, 3, 4, 5],
rules: [],
};
}
export function calendarRoot(elements: PlaitElement[]) {
return (
getTopicEntries(elements).find((entry) => entry.depth === 0)?.element ||
null
);
}
export function readWorkingCalendar(
value: unknown
): ProjectWorkingCalendar | null {
const source = record(value);
if (!Array.isArray(source.defaultWorkingDays) || !Array.isArray(source.rules))
return null;
return {
...source,
id: String(source.id || "project-calendar"),
name: String(source.name || "项目日历"),
defaultWorkingDays: source.defaultWorkingDays.filter(
(day): day is number => Number.isInteger(day) && day >= 1 && day <= 7
),
rules: source.rules.map((rule, index) => {
const item = record(rule);
return {
...item,
id: String(item.id || `calendar-rule-${index}`),
name: String(item.name || "例外"),
selectedDates: Array.isArray(item.selectedDates)
? item.selectedDates.filter(
(date): date is string =>
typeof date === "string" && Number.isFinite(dateIndex(date))
)
: [],
type: item.type === "WORKING_DAY" ? "WORKING_DAY" : "NON_WORKING_DAY",
frequency: CALENDAR_FREQUENCIES.includes(
item.frequency as CalendarRule["frequency"]
)
? (item.frequency as CalendarRule["frequency"])
: "NONE",
} as CalendarRule;
}),
} as ProjectWorkingCalendar;
}
export function getProjectCalendar(
elements: PlaitElement[]
): ProjectCalendarConfig {
const sheet = record(record(calendarRoot(elements)?.xmind).sheet);
const extension = Array.isArray(sheet.extensions)
? sheet.extensions
.map(record)
.find((item) => item.provider === CALENDAR_PROVIDER)
: null;
const settings = readWorkingCalendar(extension?.content);
return {
settings,
skip: Boolean(settings && sheet.nonWorkingDaysSkipping === "skip"),
};
}
export function patchProjectCalendar(
root: PlaitElement,
config: ProjectCalendarConfig
) {
const metadata = { ...record(root.xmind) },
sheet = { ...record(metadata.sheet) };
const extensions = Array.isArray(sheet.extensions)
? sheet.extensions.map(record)
: [];
const existing = extensions.find(
(item) => item.provider === CALENDAR_PROVIDER
);
sheet.extensions = [
...extensions.filter((item) => item.provider !== CALENDAR_PROVIDER),
...(config.settings
? [{ ...existing, provider: CALENDAR_PROVIDER, content: config.settings }]
: []),
];
sheet.nonWorkingDaysSkipping =
config.skip && config.settings ? "skip" : "include";
metadata.sheet = sheet;
return metadata;
}
export const activeWorkingCalendar = (config: ProjectCalendarConfig) =>
config.skip ? config.settings : null;
function inRange(date: string, range?: CalendarRule["effectiveRange"]) {
return (
(!range?.start || date >= range.start) && (!range?.end || date <= range.end)
);
}
function ruleMatches(date: string, rule: CalendarRule) {
if (!inRange(date, rule.effectiveRange)) return false;
const index = dateIndex(date),
target = new Date(index * DAY),
interval = rule.interval || 1;
return rule.selectedDates.some((selected) => {
const seedIndex = dateIndex(selected),
seed = new Date(seedIndex * DAY);
const difference = Math.abs(index - seedIndex),
months = Math.abs(
(target.getUTCFullYear() - seed.getUTCFullYear()) * 12 +
target.getUTCMonth() -
seed.getUTCMonth()
);
switch (rule.frequency) {
case "NONE":
return selected === date;
case "DAILY":
return difference % interval === 0;
case "WEEKLY":
return (
target.getUTCDay() === seed.getUTCDay() &&
Math.floor(difference / 7) % interval === 0
);
case "MONTHLY_BY_DATE":
return (
target.getUTCDate() === seed.getUTCDate() && months % interval === 0
);
case "MONTHLY_BY_WEEKDAY":
return (
target.getUTCDay() === seed.getUTCDay() &&
Math.ceil(target.getUTCDate() / 7) ===
Math.ceil(seed.getUTCDate() / 7) &&
months % interval === 0
);
case "YEARLY":
return (
target.getUTCMonth() === seed.getUTCMonth() &&
target.getUTCDate() === seed.getUTCDate() &&
Math.abs(target.getUTCFullYear() - seed.getUTCFullYear()) %
interval ===
0
);
}
});
}
export function isWorkingDate(
date: string,
settings: ProjectWorkingCalendar | null
) {
if (!settings || !inRange(date, settings.effectiveRange)) return true;
const weekday = new Date(dateIndex(date) * DAY).getUTCDay() || 7;
let result = settings.defaultWorkingDays.includes(weekday);
for (const rule of settings.rules)
if (ruleMatches(date, rule)) result = rule.type === "WORKING_DAY";
return result;
}
export function nextWorkingDate(
date: string,
settings: ProjectWorkingCalendar | null,
direction: 1 | -1 = 1
) {
for (
let day = dateIndex(date), attempt = 0;
attempt <= 36500;
day += direction, attempt++
) {
const candidate = indexDate(day);
if (isWorkingDate(candidate, settings)) return candidate;
}
throw new Error("日历中连续 100 年没有可用工作日,请调整例外规则。");
}
export function addWorkingDays(
date: string,
days: number,
settings: ProjectWorkingCalendar | null
) {
if (!settings) return addDays(date, days);
const direction = days < 0 ? -1 : 1;
let result = date;
for (let left = Math.abs(days); left > 0; left--)
result = nextWorkingDate(addDays(result, direction), settings, direction);
return result;
}
export function taskDueDate(
start: string,
duration: number,
settings: ProjectWorkingCalendar | null
) {
return addWorkingDays(
nextWorkingDate(start, settings),
duration - 1,
settings
);
}
export function taskStartDate(
due: string,
duration: number,
settings: ProjectWorkingCalendar | null
) {
return addWorkingDays(
nextWorkingDate(due, settings, -1),
1 - duration,
settings
);
}
export function workingDaysBetween(
start: string,
due: string,
settings: ProjectWorkingCalendar | null
) {
if (!settings) return dateIndex(due) - dateIndex(start) + 1;
let duration = 0;
for (let day = dateIndex(start), last = dateIndex(due); day <= last; day++)
if (isWorkingDate(indexDate(day), settings)) duration++;
return duration;
}
export function preserveNonWorkingDue(
due: string,
calculated: string,
settings: ProjectWorkingCalendar | null
) {
return settings &&
due &&
!isWorkingDate(due, settings) &&
nextWorkingDate(due, settings, -1) === calculated
? due
: calculated;
}
export function validateWorkingCalendar(
calendar: ProjectWorkingCalendar
): string | null {
if (!calendar.defaultWorkingDays.length) return "请至少保留一个每周工作日。";
const validateRange = (range?: CalendarRule["effectiveRange"]) =>
!range ||
((!range.start || Number.isFinite(dateIndex(range.start))) &&
(!range.end || Number.isFinite(dateIndex(range.end))) &&
(!range.start || !range.end || range.start <= range.end));
if (!validateRange(calendar.effectiveRange)) return "日历生效范围无效。";
for (const rule of calendar.rules) {
if (
!rule.selectedDates.length ||
rule.selectedDates.some((date) => !Number.isFinite(dateIndex(date)))
)
return "请选择有效的例外日期。";
if (!validateRange(rule.effectiveRange))
return "例外结束日期不能早于开始日期。";
if (
rule.interval !== undefined &&
(!Number.isInteger(rule.interval) ||
rule.interval < 1 ||
rule.interval > 100)
)
return "重复间隔必须是 1 至 100。";
}
return null;
}

View File

@ -1,8 +1,10 @@
import type { PlaitElement } from "@plait/core";
import { getNoteDocument, noteDocumentText, serializeNoteDocument, type NoteDocument } from "./topic-notes";
export type TopicAttachment = { name: string; url: string; mimeType: string };
export type TopicDetails = {
notes: string;
notesDocument?: NoteDocument;
labels: string[];
href: string;
topicLinkId: string;
@ -64,19 +66,7 @@ export function getTopicDetails(element: PlaitElement | null): TopicDetails {
: typeof data.labels === "string"
? [data.labels]
: [];
const htmlParagraphs = record(
record(record(data.notes).html).content
).paragraphs;
const htmlText = Array.isArray(htmlParagraphs)
? htmlParagraphs
.map((paragraph) => {
const spans = record(paragraph).spans;
return Array.isArray(spans)
? spans.map((span) => String(record(span).text || "")).join("")
: "";
})
.join("\n")
: "";
const htmlText = typeof notes === "string" ? "" : noteDocumentText(getNoteDocument(element));
const attachment = record(data.attachment);
const safeAttachment =
typeof attachment.url === "string" &&
@ -108,7 +98,8 @@ export function patchTopicDetails(
) {
const data = { ...record(element.xmind) };
for (const [key, value] of Object.entries(patch)) {
if (key === "notes")
if (key === "notesDocument") data.notes = serializeNoteDocument(value as NoteDocument);
else if (key === "notes")
data.notes = value ? { plain: { content: value } } : undefined;
else data[key] = value;
}

View File

@ -0,0 +1,290 @@
import type { PlaitElement } from "@plait/core";
export type NoteText = {
text: string;
bold?: boolean;
italic?: boolean;
underline?: boolean;
strikethrough?: boolean;
href?: string;
properties?: Record<string, unknown>;
};
export type NoteParagraph = {
type: "paragraph";
list?: "bullet" | "number";
indent?: number;
properties?: Record<string, unknown>;
children: NoteText[];
};
export type NoteDocument = NoteParagraph[];
const record = (v: unknown): Record<string, unknown> =>
v && typeof v === "object" && !Array.isArray(v)
? (v as Record<string, unknown>)
: {};
export const safeNoteLink = (value: string) => {
try {
const url = new URL(value);
return ["http:", "https:", "mailto:", "tel:"].includes(url.protocol)
? url.href
: null;
} catch {
return null;
}
};
function parseNoteHTML(html: string): NoteDocument | null {
if (typeof DOMParser === "undefined") return null;
const dom = new DOMParser().parseFromString(html, "text/html");
const ignored = new Set([
"SCRIPT",
"STYLE",
"IFRAME",
"OBJECT",
"SVG",
"FORM",
"INPUT",
]);
const inline = (
node: Node,
inherited: Partial<NoteText> = {}
): NoteText[] => {
if (node.nodeType === 3)
return [{ ...inherited, text: node.textContent || "" }];
if (!(node instanceof Element) || ignored.has(node.tagName)) return [];
if (node.tagName === "BR") return [{ ...inherited, text: "\n" }];
const style = (node as HTMLElement).style;
const marks: Partial<NoteText> = { ...inherited };
if (
["B", "STRONG"].includes(node.tagName) ||
style.fontWeight === "bold" ||
Number(style.fontWeight) >= 600
)
marks.bold = true;
if (["I", "EM"].includes(node.tagName) || style.fontStyle === "italic")
marks.italic = true;
if (node.tagName === "U" || style.textDecoration.includes("underline"))
marks.underline = true;
if (
["S", "DEL", "STRIKE"].includes(node.tagName) ||
style.textDecoration.includes("line-through")
)
marks.strikethrough = true;
const href = safeNoteLink(node.getAttribute("href") || "");
if (href) marks.href = href;
return Array.from(node.childNodes).flatMap((child) => inline(child, marks));
};
const result: NoteDocument = [];
const visit = (node: Node, list?: "bullet" | "number") => {
if (node.nodeType === 3) {
if (node.textContent?.trim())
result.push({
type: "paragraph",
children: [{ text: node.textContent }],
});
return;
}
if (!(node instanceof Element) || ignored.has(node.tagName)) return;
if (["OL", "UL"].includes(node.tagName)) {
Array.from(node.children).forEach((child) =>
visit(child, node.tagName === "OL" ? "number" : "bullet")
);
return;
}
if (
[
"P",
"LI",
"DIV",
"H1",
"H2",
"H3",
"H4",
"H5",
"H6",
"BLOCKQUOTE",
"PRE",
].includes(node.tagName)
) {
const blocks = Array.from(node.children).some((child) =>
["P", "LI", "DIV", "OL", "UL"].includes(child.tagName)
);
if (blocks && node.tagName !== "LI") {
Array.from(node.childNodes).forEach((child) => visit(child, list));
return;
}
const leaves = Array.from(node.childNodes)
.filter(
(child) =>
!(child instanceof Element && ["OL", "UL"].includes(child.tagName))
)
.flatMap((child) => inline(child));
result.push({
type: "paragraph",
...(list ? { list } : {}),
indent: Number.parseFloat((node as HTMLElement).style.marginLeft) || 0,
children: leaves.length ? leaves : [{ text: "" }],
});
Array.from(node.children)
.filter((child) => ["OL", "UL"].includes(child.tagName))
.forEach((child) => visit(child));
return;
}
const leaves = inline(node);
if (leaves.length) result.push({ type: "paragraph", children: leaves });
};
Array.from(dom.body.childNodes).forEach((node) => visit(node));
return result.length ? result : null;
}
const escapeHTML = (value: string) =>
value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
function noteDocumentHTML(doc: NoteDocument) {
let html = "",
list: "bullet" | "number" | undefined;
for (const paragraph of doc) {
if (list !== paragraph.list) {
if (list) html += list === "number" ? "</ol>" : "</ul>";
list = paragraph.list;
if (list) html += list === "number" ? "<ol>" : "<ul>";
}
const tag = list ? "li" : "p";
let content = paragraph.children
.map((t) => {
let text = escapeHTML(t.text).replaceAll("\n", "<br>");
if (t.bold) text = `<strong>${text}</strong>`;
if (t.italic) text = `<em>${text}</em>`;
if (t.underline) text = `<u>${text}</u>`;
if (t.strikethrough) text = `<s>${text}</s>`;
const href = safeNoteLink(t.href || "");
return href ? `<a href="${escapeHTML(href)}">${text}</a>` : text;
})
.join("");
if (!content) content = "<br>";
html += `<${tag}${paragraph.indent ? ` style="margin-left:${Math.max(0, Math.min(120, paragraph.indent))}px"` : ""}>${content}</${tag}>`;
}
if (list) html += list === "number" ? "</ol>" : "</ul>";
return html;
}
export function getNoteDocument(element: PlaitElement | null): NoteDocument {
const notes = record(record(element?.xmind).notes);
const realHTML = record(notes.realHTML).content;
if (typeof realHTML === "string") {
const parsed = parseNoteHTML(realHTML);
if (parsed) return parsed;
}
const paragraphs = record(record(notes.html).content).paragraphs;
if (Array.isArray(paragraphs) && paragraphs.length) {
const readSpans = (
spans: unknown,
inherited: Record<string, unknown> = {},
href = ""
): NoteText[] =>
Array.isArray(spans)
? spans.flatMap((span) => {
const item = record(span),
props = {
...inherited,
...record(record(item.style).properties),
};
const link = typeof item.href === "string" ? item.href : href;
if (Array.isArray(item.spans))
return readSpans(item.spans, props, link);
if (typeof item.text !== "string") return [];
return [
{
text: item.text,
properties: props,
bold: ["bold", "700", "600", "800", "900"].includes(
String(props["fo:font-weight"] || props["font-weight"] || "")
),
italic:
(props["fo:font-style"] || props["font-style"]) === "italic",
underline: String(
props["fo:text-decoration"] || props["text-decoration"] || ""
).includes("underline"),
strikethrough: String(
props["fo:text-decoration"] || props["text-decoration"] || ""
).includes("line-through"),
...(safeNoteLink(link) ? { href: safeNoteLink(link)! } : {}),
},
];
})
: [];
return paragraphs.map((p) => {
const item = record(p),
props = record(record(item.style).properties);
const bullet = String(props["fo:text-bullet"] || "");
const children = readSpans(item.spans);
return {
type: "paragraph",
properties: props,
...(bullet === "number" || bullet === "decimal"
? { list: "number" as const }
: bullet && bullet !== "none"
? { list: "bullet" as const }
: {}),
indent: Number(props["fo:margin-left"]) || 0,
children: children.length ? children : [{ text: "" }],
};
});
}
const plain =
typeof record(element?.xmind).notes === "string"
? String(record(element?.xmind).notes)
: String(record(notes.plain).content || "");
return plain
.split("\n")
.map((text) => ({ type: "paragraph", children: [{ text }] }));
}
export const noteDocumentText = (doc: NoteDocument) =>
doc.map((p) => p.children.map((t) => t.text).join("")).join("\n");
export function serializeNoteDocument(doc: NoteDocument) {
const plain = noteDocumentText(doc);
if (!plain.trim()) return undefined;
return {
plain: { content: plain },
realHTML: { content: noteDocumentHTML(doc) },
html: {
content: {
paragraphs: doc.map((p) => ({
style: {
type: "paragraph",
properties: {
...p.properties,
"fo:text-bullet":
p.list === "number"
? "number"
: p.list === "bullet"
? "bullet"
: "none",
"fo:margin-left": String(p.indent || 0),
},
},
spans: p.children.map((t) => ({
text: t.text,
...(safeNoteLink(t.href || "")
? { href: safeNoteLink(t.href!) }
: {}),
style: {
type: "text",
properties: {
...t.properties,
"fo:font-weight": t.bold ? "bold" : "normal",
"fo:font-style": t.italic ? "italic" : "normal",
"fo:text-decoration":
[
t.underline ? "underline" : "",
t.strikethrough ? "line-through" : "",
]
.filter(Boolean)
.join(" ") || "none",
},
},
})),
})),
},
},
};
}

View File

@ -0,0 +1,268 @@
import type { PlaitElement } from "@plait/core";
import { getTopicEntries } from "./topic-details";
export const TASK_PROVIDER = "org.xmind.ui.task";
import {
DAY,
dateIndex,
indexDate,
addDays,
dateTimestamp,
timestampDate,
todayDate,
} from "./calendar-dates";
export {
DAY,
dateIndex,
indexDate,
addDays,
dateTimestamp,
timestampDate,
todayDate,
} from "./calendar-dates";
import {
activeWorkingCalendar,
getProjectCalendar,
taskDueDate,
taskStartDate,
preserveNonWorkingDue,
workingDaysBetween,
type ProjectCalendarConfig,
} from "./project-calendar";
export type TaskDependency = {
id: string;
type: "FS" | "SS" | "FF" | "SF";
lag: number;
};
export type TopicTask = {
start: string;
due: string;
duration: number;
progress: number;
priority: number | null;
assignees: string[];
dependencies: TaskDependency[];
creator?: string;
};
const record = (v: unknown): Record<string, unknown> =>
v && typeof v === "object" && !Array.isArray(v)
? (v as Record<string, unknown>)
: {};
export function getTaskContent(element: PlaitElement) {
const extensions = record(element.xmind).extensions;
if (!Array.isArray(extensions)) return null;
const ext = extensions.map(record).find((e) => e.provider === TASK_PROVIDER);
const content = record(ext?.content);
return typeof content.progress === "number" ? content : null;
}
export function getTopicTask(element: PlaitElement): TopicTask | null {
const c = getTaskContent(element);
if (!c) return null;
return {
start: timestampDate(c.start),
due: timestampDate(c.due),
duration: Math.max(1, Math.round(Number(c.duration || DAY) / DAY)),
progress: Math.max(0, Math.min(100, Math.round(Number(c.progress) * 100))),
priority: typeof c.priority === "number" ? c.priority : null,
assignees: Array.isArray(c.assignees)
? c.assignees.filter((s): s is string => typeof s === "string")
: [],
dependencies: Array.isArray(c.dependencies)
? c.dependencies.flatMap((d) => {
const item = record(d);
return typeof item.id === "string" &&
["FS", "SS", "FF", "SF"].includes(String(item.type))
? [
{
id: item.id,
type: item.type as TaskDependency["type"],
lag: Number(item.lag) || 0,
},
]
: [];
})
: [],
...(typeof c.creator === "string" ? { creator: c.creator } : {}),
};
}
export function newTopicTask(): TopicTask {
return {
start: todayDate(),
due: todayDate(),
duration: 1,
progress: 0,
priority: null,
assignees: [],
dependencies: [],
};
}
export function patchTopicTask(element: PlaitElement, task: TopicTask | null) {
const metadata = { ...record(element.xmind) };
const extensions = Array.isArray(metadata.extensions)
? metadata.extensions.map(record)
: [];
const existing = extensions.find((e) => e.provider === TASK_PROVIDER);
const others = extensions.filter((e) => e.provider !== TASK_PROVIDER);
if (task)
others.push({
...existing,
provider: TASK_PROVIDER,
content: {
...record(existing?.content),
...task,
start: dateTimestamp(task.start),
due: dateTimestamp(task.due, true),
duration: task.duration * DAY,
progress: task.progress / 100,
},
});
metadata.extensions = others;
return metadata;
}
export function validateTopicTask(
id: string,
task: TopicTask,
elements: PlaitElement[]
): string | null {
if (
(task.start && !Number.isFinite(dateIndex(task.start))) ||
(task.due && !Number.isFinite(dateIndex(task.due)))
)
return "请输入有效日期。";
if (task.start && task.due && task.due < task.start)
return "截止日期不能早于开始日期。";
if (
!Number.isInteger(task.duration) ||
task.duration < 1 ||
task.duration > 36500
)
return "工期必须是 1 至 36500 天。";
if (
!Number.isFinite(task.progress) ||
task.progress < 0 ||
task.progress > 100
)
return "进度必须在 0% 至 100% 之间。";
const entries = getTopicEntries(elements),
byId = new Map(entries.map((e) => [e.element.id, e]));
const me = byId.get(id);
for (const dep of task.dependencies) {
const target = byId.get(dep.id);
if (!target || !getTopicTask(target.element)) return "前置任务已不存在。";
if (!Number.isFinite(dep.lag)) return "间隔天数无效。";
if (
dep.id === id ||
(me &&
(target.path.every((n, i) => me.path[i] === n) ||
me.path.every((n, i) => target.path[i] === n)))
)
return "任务不能依赖自身、父任务或子任务。";
const visit = (current: string, seen = new Set<string>()): boolean => {
if (current === id) return true;
if (seen.has(current)) return false;
seen.add(current);
const item = byId.get(current);
return item
? (getTopicTask(item.element)?.dependencies || []).some((d) =>
visit(d.id, seen)
)
: false;
};
if (visit(dep.id)) return "前置任务形成循环,请调整依赖关系。";
}
return null;
}
export function getTaskRows(elements: PlaitElement[]) {
const calendar = activeWorkingCalendar(getProjectCalendar(elements));
return getTopicEntries(elements).flatMap((entry) => {
const task = getTopicTask(entry.element);
if (!task) return [];
const descendants = getTopicEntries(entry.element.children || [])
.map((e) => getTopicTask(e.element))
.filter((t): t is TopicTask => Boolean(t?.start && t.due));
if (!descendants.length) return [{ ...entry, task, summary: false }];
const start = descendants.map((t) => t.start).sort()[0],
due = descendants
.map((t) => t.due)
.sort()
.at(-1)!;
const total = descendants.reduce((s, t) => s + t.duration, 0);
return [
{
...entry,
summary: true,
task: {
...task,
start,
due,
duration: Math.max(1, workingDaysBetween(start, due, calendar)),
progress: Math.round(
descendants.reduce((s, t) => s + t.progress * t.duration, 0) / total
),
},
},
];
});
}
/** Propagate finish/start constraints in topological order; the caller commits one undo batch. */
export function scheduleTaskChanges(
elements: PlaitElement[],
id: string,
task: TopicTask,
config: ProjectCalendarConfig = getProjectCalendar(elements)
): Map<string, TopicTask> {
const error = validateTopicTask(id, task, elements);
if (error) throw new Error(error);
const calendar = activeWorkingCalendar(config);
const tasks = new Map(
getTopicEntries(elements).flatMap((e) => {
const t = getTopicTask(e.element);
return t ? [[e.element.id, t] as const] : [];
})
);
tasks.set(id, task);
const changed = new Map<string, TopicTask>([[id, task]]),
visiting = new Set<string>(),
done = new Set<string>();
const visit = (key: string) => {
if (done.has(key)) return;
if (visiting.has(key)) return;
visiting.add(key);
const t = tasks.get(key)!;
for (const d of t.dependencies) if (tasks.has(d.id)) visit(d.id);
if (t.start && t.due) {
let earliest = dateIndex(t.start);
for (const d of t.dependencies) {
const before = tasks.get(d.id);
if (!before?.start || !before.due) continue;
const lag = d.lag / DAY;
const anchor = d.type[0] === "F" ? before.due : before.start;
const endpoint = addDays(
anchor,
d.type[0] === "F" ? Math.ceil(lag) : Math.floor(lag)
);
const constraint =
d.type === "FS"
? addDays(endpoint, 1)
: d.type === "SS"
? endpoint
: taskStartDate(endpoint, t.duration, calendar);
earliest = Math.max(earliest, dateIndex(constraint));
}
const start = indexDate(earliest);
const due = preserveNonWorkingDue(
t.due,
taskDueDate(start, t.duration, calendar),
calendar
);
if (start !== t.start || (calendar && due !== t.due)) {
const next = { ...t, start, due };
tasks.set(key, next);
changed.set(key, next);
}
}
visiting.delete(key);
done.add(key);
};
tasks.forEach((_, key) => visit(key));
return changed;
}

View File

@ -143,6 +143,17 @@ export async function exportXmind(
],
},
];
// Merge native task extensions with structure extensions; remap every dependency to exported topic IDs.
const nativeExtensions = array(metadata.extensions).filter(ext => !["org.xmind.ui.spreadsheet", ...Object.values(layouts)].includes(String(ext.provider))).map(ext => {
if (ext.provider !== "org.xmind.ui.task") return ext;
const content = record(ext.content);
return {...ext, content: {...content, ...(Array.isArray(content.dependencies) ? {dependencies: array(content.dependencies).flatMap(dep => {
const target = ids.get(String(dep.id));
if (!target) {warnings.add("指向缺失主题的任务依赖未导出"); return [];}
return [{...dep, id: target}];
})} : {})}};
});
if (nativeExtensions.length) topic.extensions = [...array(topic.extensions), ...nativeExtensions];
const leaves = array(record(data.topic).children);
const leaf = leaves[0] || {};
if (leaves.length > 1) warnings.add("主题内混合文字格式已按首段样式导出");
@ -324,6 +335,7 @@ export async function exportXmind(
class: "sheet",
title: source.title,
rootTopic: root,
...record(record(topics.find(entry => !parents.has(entry.element.id))?.element.xmind).sheet),
relationships,
});
}

View File

@ -339,7 +339,7 @@ function convertSheets(sheets: Data[], fallbackTitle: string): ImportedMindMap {
};
element.data.topic.children = [leaf];
const metadata: Data = {};
for (const key of ["notes", "href", "labels", "markers", "image"])
for (const key of ["notes", "href", "labels", "markers", "image", "extensions"])
if (topic[key] !== undefined) metadata[key] = topic[key];
if (Object.keys(metadata).length) element.xmind = metadata;
@ -443,8 +443,22 @@ function convertSheets(sheets: Data[], fallbackTitle: string): ImportedMindMap {
return element;
};
const root = build(record(sheet.rootTopic), 0, true);
const sheetMetadata: Data = {};
for (const key of ["extensions", "nonWorkingDaysSkipping", "topicTasksDisplay", "topicTasksCreatorVisibility"]) if (sheet[key] !== undefined) sheetMetadata[key] = sheet[key];
if (Object.keys(sheetMetadata).length) root.xmind = {...record(root.xmind), sheet: sheetMetadata};
for (const { element } of getTopicEntries([root, ...floating])) {
const metadata = record(element.xmind);
if (Array.isArray(metadata.extensions)) metadata.extensions = metadata.extensions.map(value => {
const ext = record(value);
if (ext.provider !== "org.xmind.ui.task") return ext;
const content = record(ext.content);
return {...ext, content: {...content, ...(Array.isArray(content.dependencies) ? {dependencies: content.dependencies.map(value => {
const dep = record(value), mapped = topicIds.get(text(dep.id));
if (!mapped) warnings.add("部分任务依赖指向缺失主题,已保留引用");
return {...dep, id: mapped || dep.id};
})} : {})}};
});
if (Object.keys(metadata).length) element.xmind = metadata;
if (
typeof metadata.href === "string" &&
metadata.href.startsWith("xmind:#")

View File

@ -10,7 +10,7 @@ export async function clickXmindSheet(page: Page, label: string) {
export async function clickXmindAction(page: Page, label: string) {
const menus: Record<string,string> = {
备注:"插入",链接:"插入",标签:"插入",主题:"插入",文本:"插入",图片:"插入",
任务:"插入",甘特图:"文件与编辑",笔记:"插入",备注:"插入",链接:"插入",标签:"插入",主题:"插入",文本:"插入",图片:"插入",
撤销:"文件与编辑",重做:"文件与编辑",保存:"文件与编辑",导入:"文件与编辑",云文件:"文件与编辑",
"查找与替换":"文件与编辑",
放大:"缩放",缩小:"缩放",适应画布:"缩放",
@ -20,7 +20,7 @@ export async function clickXmindAction(page: Page, label: string) {
if (menu) {
const trigger = page.getByRole("button",{name:menu,exact:true});
if (await trigger.getAttribute("aria-expanded") !== "true") await trigger.click();
await page.getByRole("menuitem",{name:label,exact:false}).click();
await page.getByRole("menuitem",{name:label === "备注" ? "笔记" : label,exact:false}).click();
} else await page.getByRole("button",{name:label,exact:true}).click();
}

View File

@ -0,0 +1,119 @@
import { expect, test, type Page } from "@playwright/test";
import JSZip from "jszip";
import sharp from "sharp";
import { clickXmindAction, clickXmindSheet } from "./editor-actions";
async function openMap(page: Page) {
await page.goto("/dev/flow-board-preview", { waitUntil: "commit" });
const zip = new JSZip();
const image = await sharp({ create: { width: 40, height: 40, channels: 4, background: "#fa1515" } }).png().toBuffer();
zip.file("resources/red.png", image);
zip.file("content.json", JSON.stringify([
{ title: "完整画布", rootTopic: { id: "root", title: "图片导出验证", structureClass: "org.xmind.ui.logic.right", children: { attached: Array.from({ length: 20 }, (_, index) => ({
id: `topic-${index}`, title: `任务 ${index + 1}`,
...(index === 0 ? { image: { src: "xap:resources/red.png", width: 40 }, markers: [{ markerId: "priority-1" }] } : {}),
})) } } },
{ title: "第二页", rootTopic: { id: "other", title: "第二页内容" } },
]));
await page.locator("input[accept*=xmind]").setInputFiles({ name: "图片验证.xmind", mimeType: "application/octet-stream", buffer: await zip.generateAsync({ type: "nodebuffer" }) });
await expect(page.locator(".drawnix").getByText("图片导出验证", { exact: true })).toBeVisible();
await expect.poll(() => page.locator(".drawnix .xmind-topic-marker").evaluate((node: HTMLImageElement) => node.complete && node.naturalWidth > 0)).toBe(true);
}
async function exportPng(page: Page, scale: number, transparent: boolean) {
await page.getByRole("button", { name: "更多导出选项", exact: true }).click();
await page.getByRole("menuitem", { name: "导出图片", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "导出图片", exact: true });
await dialog.getByLabel("图片清晰度").selectOption(String(scale));
await dialog.getByLabel("透明背景").setChecked(transparent);
const pending = page.waitForEvent("download");
await dialog.getByRole("button", { name: "导出 PNG", exact: true }).click();
const download = await pending;
const stream = await download.createReadStream();
const chunks: Buffer[] = [];
for await (const chunk of stream!) chunks.push(chunk);
await expect(dialog).not.toBeVisible();
return { bytes: Buffer.concat(chunks), download };
}
test("PNG exports full content with text, markers, images and transparent high resolution independently of zoom", async ({ page }) => {
const errors: string[] = [];
page.on("pageerror", error => errors.push(error.message));
await openMap(page);
const standard = await exportPng(page, 1, false);
expect(standard.download.suggestedFilename()).toBe("图片验证-完整画布.png");
const { data, info } = await sharp(standard.bytes).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
expect(info.height).toBeGreaterThan(900); // Includes branches outside the viewport.
expect(Array.from(data.subarray(0, 4))).toEqual([255, 255, 255, 255]);
let red = 0, dark = 0;
for (let index = 0; index < data.length; index += 4) {
if (data[index] > 210 && data[index + 1] < 60 && data[index + 2] < 60) red++;
if (data[index] < 80 && data[index + 1] < 80 && data[index + 2] < 80) dark++;
}
expect(red).toBeGreaterThan(500);
expect(dark).toBeGreaterThan(300); // Topic text is rasterized, not just node shapes.
await standard.download.saveAs(test.info().outputPath("complete.png"));
const high = await exportPng(page, 2, true);
const highInfo = await sharp(high.bytes).metadata();
expect(highInfo.width).toBe(info.width * 2);
expect(highInfo.height).toBe(info.height * 2);
const corner = await sharp(high.bytes).extract({ left: 0, top: 0, width: 1, height: 1 }).ensureAlpha().raw().toBuffer();
expect(corner[3]).toBe(0);
await high.download.saveAs(test.info().outputPath("transparent.png"));
await clickXmindAction(page, "缩小");
await clickXmindAction(page, "缩小");
const zoomed = await exportPng(page, 2, true);
expect(zoomed.bytes.equals(high.bytes)).toBe(true);
await clickXmindSheet(page, "第二页");
await expect(page.locator(".drawnix").getByText("第二页内容", { exact: true })).toBeVisible();
const second = await exportPng(page, 3, false);
expect(second.download.suggestedFilename()).toBe("图片验证-第二页.png");
expect((await sharp(second.bytes).metadata()).height).toBeLessThan(info.height);
expect(errors).toEqual([]);
});
test("image export is keyboard dismissible and failed resources show a recoverable error", async ({ page }) => {
await openMap(page);
await page.getByRole("button", { name: "文件与编辑", exact: true }).click();
await page.getByRole("menuitem", { name: "导出图片", exact: true }).click();
const dialog = page.getByRole("dialog", { name: "导出图片", exact: true });
await expect(dialog).toBeVisible();
await page.keyboard.press("Escape");
await expect(dialog).not.toBeVisible();
await page.route("**/fonts/xmind/NeverMind-Bold.ttf", route => route.fulfill({ status: 503, body: "unavailable" }));
await page.getByRole("button", { name: "更多导出选项", exact: true }).click();
await page.getByRole("menuitem", { name: "导出图片", exact: true }).click();
await dialog.getByRole("button", { name: "导出 PNG", exact: true }).click();
await expect(page.getByText("图片资源加载失败,请稍后重试", { exact: true })).toBeVisible();
await expect(dialog.getByRole("button", { name: "导出 PNG", exact: true })).toBeEnabled();
await dialog.getByRole("button", { name: "取消", exact: true }).click();
await expect(dialog).not.toBeVisible();
});
test("PNG includes relationships, boundaries and summaries without selection outlines", async ({ page }) => {
await page.goto("/dev/flow-board-preview", { waitUntil: "commit" });
const zip = new JSZip();
zip.file("content.json", JSON.stringify([{ title: "结构", rootTopic: {
id: "r", title: "结构验证", structureClass: "org.xmind.ui.logic.right",
children: { attached: [{ id: "a", title: "设计" }, { id: "b", title: "交付" }], summary: [{ id: "s", title: "阶段概要" }] },
summaries: [{ range: "(0,1)", topicId: "s" }],
boundaries: [{ range: "(0,1)", style: { properties: { "line-color": "#00ff00" } } }],
}, relationships: [{ end1Id: "a", end2Id: "b", title: "验收联系", style: { properties: { "line-color": "#ff00ff" } } }] }]));
await page.locator("input[accept*=xmind]").setInputFiles({ name: "结构验证.xmind", mimeType: "application/octet-stream", buffer: await zip.generateAsync({ type: "nodebuffer" }) });
const canvas = page.locator(".drawnix");
await expect(canvas.getByText("阶段概要", { exact: true })).toBeVisible();
await expect(canvas.getByText("验收联系", { exact: true })).toBeVisible();
const plain = await exportPng(page, 2, false);
const data = await sharp(plain.bytes).ensureAlpha().raw().toBuffer();
let green = 0, purple = 0;
for (let index = 0; index < data.length; index += 4) {
if (data[index] < 60 && data[index + 1] > 200 && data[index + 2] < 60) green++;
if (data[index] > 200 && data[index + 1] < 60 && data[index + 2] > 200) purple++;
}
expect(green).toBeGreaterThan(40);
expect(purple).toBeGreaterThan(40);
await canvas.getByText("设计", { exact: true }).click();
const selected = await exportPng(page, 2, false);
expect(selected.bytes.equals(plain.bytes)).toBe(true);
await selected.download.saveAs(test.info().outputPath("annotations.png"));
});

View File

@ -113,8 +113,12 @@ test("image paste in title and notes is not intercepted by the canvas", async ({
const bytes = Uint8Array.from(atob(base64), char => char.charCodeAt(0));
clipboardData.items.add(new File([bytes], "image.png", { type: "image/png" }));
const event = new ClipboardEvent("paste", { clipboardData, bubbles: true, cancelable: true });
// Inspect before the rich-text editor's own bubbling handler.
// The canvas capture handler must leave clipboard ownership to the field.
let interceptedByCanvas = false;
element.addEventListener("paste", (paste) => { interceptedByCanvas = paste.defaultPrevented; }, {capture:true,once:true});
element.dispatchEvent(event);
return event.defaultPrevented;
return interceptedByCanvas;
}, pixel);
expect(prevented).toBe(false);
}

View File

@ -0,0 +1,279 @@
import { expect, test, type Page } from "@playwright/test";
import JSZip from "jszip";
import { clickXmindAction, clickXmindSheet } from "./editor-actions";
const DAY = 86400000;
async function loadPlan(page: Page) {
await page.goto("/dev/flow-board-preview", { waitUntil: "commit" });
const zip = new JSZip();
const task = (start: string, duration: number, progress = 0) => [
{
provider: "org.xmind.ui.task",
content: {
start: new Date(`${start}T00:00:00`).getTime(),
due: new Date(`${start}T23:59:59.999`).getTime() + (duration - 1) * DAY,
duration: duration * DAY,
progress,
assignees: ["小明"],
},
},
];
zip.file(
"content.json",
JSON.stringify([
{
id: "sheet",
title: "项目计划",
rootTopic: {
id: "root",
title: "项目",
structureClass: "org.xmind.ui.logic.right",
children: {
attached: [
{
id: "a",
title: "设计",
notes: { plain: { content: "第一行\n第二行" } },
extensions: task("2026-10-05", 2),
},
{
id: "b",
title: "开发",
extensions: [
{
provider: "org.xmind.ui.task",
content: {
...task("2026-10-07", 3)[0].content,
dependencies: [{ id: "a", type: "FS", lag: 0 }],
},
},
],
},
{ id: "c", title: "验收" },
],
},
},
},
{ title: "其他工作表", rootTopic: { id: "other", title: "其他" } },
])
);
await page
.locator("input[accept*=xmind]")
.setInputFiles({
name: "项目计划.xmind",
mimeType: "application/octet-stream",
buffer: await zip.generateAsync({ type: "nodebuffer" }),
});
await expect(
page.locator(".drawnix").getByText("设计", { exact: true })
).toBeVisible();
await page.evaluate(() => document.fonts.ready);
}
async function exported(page: Page) {
const promise = page.waitForEvent("download");
await clickXmindAction(page, "导出 XMind");
const d = await promise,
stream = await d.createReadStream(),
chunks: Buffer[] = [];
for await (const c of stream!) chunks.push(c);
const buffer = Buffer.concat(chunks);
await d.saveAs(test.info().outputPath("edited-plan.xmind"));
const zip = await JSZip.loadAsync(buffer);
return {
buffer,
sheets: JSON.parse(await zip.file("content.json")!.async("string")),
};
}
test("rich notes format selection, lists and safe links, undo, export and reimport", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await loadPlan(page);
await page
.getByRole("button", { name: "查看笔记 设计", exact: true })
.click();
const note = page.getByLabel("主题备注");
await expect(note).toContainText("第一行");
await note.fill("交付说明");
await note.press("ControlOrMeta+a");
await page.getByRole("button", { name: "笔记加粗", exact: true }).click();
await expect(note.locator('span[style*="font-weight: 700"]')).toContainText(
"交付说明"
);
await page.getByRole("button", { name: "笔记有序列表", exact: true }).click();
await expect(note.locator('[data-note-list="number"]')).toHaveCount(1);
await page.getByRole("button", { name: "笔记链接", exact: true }).click();
await page.getByLabel("笔记链接地址").fill("https://example.com/spec");
await page.getByRole("button", { name: "确定", exact: true }).click();
await expect(note.locator("a")).toHaveAttribute(
"href",
"https://example.com/spec"
);
await clickXmindAction(page, "撤销");
await expect(note.locator("a")).toHaveCount(0);
await clickXmindAction(page, "重做");
await expect(note.locator("a")).toHaveCount(1);
await page.screenshot({ path: test.info().outputPath("rich-notes.png") });
await page.getByRole("button", { name: "关闭笔记", exact: true }).click();
const out = await exported(page);
const native = out.sheets[0].rootTopic.children.attached[0].notes;
expect(native.plain.content).toBe("交付说明");
expect(native.realHTML.content).toContain("<ol>");
expect(native.realHTML.content).toContain("<strong>");
expect(
native.html.content.paragraphs[0].spans[0].style.properties[
"fo:font-weight"
]
).toBe("bold");
await page
.locator("input[accept*=xmind]")
.setInputFiles({
name: "富文本往返.xmind",
mimeType: "application/octet-stream",
buffer: out.buffer,
});
await page.locator(".drawnix").getByText("设计", { exact: true }).click();
await clickXmindAction(page, "备注");
await expect(page.getByLabel("主题备注").locator("a")).toHaveAttribute(
"href",
"https://example.com/spec"
);
expect(errors).toEqual([]);
});
test("task dates, dependencies, Gantt move/resize, undo, filters and native file roundtrip", async ({
page,
}) => {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await loadPlan(page);
await page.locator(".drawnix").getByText("验收", { exact: true }).click();
await clickXmindAction(page, "任务");
const editor = page.getByRole("dialog", { name: "任务信息", exact: true });
await page.getByLabel("任务开始日期").fill("2026-10-10");
await page.getByLabel("任务工期").fill("2");
await expect(page.getByLabel("任务截止日期")).toHaveValue("2026-10-11");
await page.getByLabel("任务进度", { exact: true }).fill("50");
await page.getByLabel("任务负责人").fill("小明");
await page
.getByLabel("前置任务", { exact: true })
.selectOption({ label: "开发" });
await editor.getByRole("button", { name: "添加", exact: true }).click();
await editor.getByRole("button", { name: "保存任务", exact: true }).click();
await expect(editor).toHaveCount(0);
await clickXmindAction(page, "甘特图");
const gantt = page.getByRole("dialog", { name: "甘特图", exact: true });
await expect(gantt.locator(".xmind-gantt-bar")).toHaveCount(3);
await expect(gantt.locator("[data-gantt-dependency]")).toHaveCount(2);
const row = (name: string) =>
gantt
.locator(".xmind-gantt-row")
.filter({ has: page.getByRole("button", { name, exact: true }) });
await expect(row("验收")).toContainText("2026-10-10");
const design = gantt.getByRole("button", {
name: "任务时间条 设计",
exact: true,
});
await design.focus();
await design.press("ArrowRight");
await expect(row("设计")).toContainText("2026-10-06");
await expect(row("开发")).toContainText("2026-10-08");
await design.press("ControlOrMeta+z");
await expect(row("设计")).toContainText("2026-10-05");
await expect(row("开发")).toContainText("2026-10-07");
const box = await design.boundingBox();
expect(box).not.toBeNull();
await page.mouse.move(box!.x + box!.width / 2, box!.y + box!.height / 2);
await page.mouse.down();
await page.mouse.move(
box!.x + box!.width / 2 + 68,
box!.y + box!.height / 2,
{ steps: 5 }
);
await page.mouse.up();
await expect(row("设计")).toContainText("2026-10-07");
await expect(row("开发")).toContainText("2026-10-09");
await expect(row("验收")).toContainText("2026-10-12");
const resized = await design.boundingBox();
await page.mouse.move(resized!.x + resized!.width - 2, resized!.y + 10);
await page.mouse.down();
await page.mouse.move(resized!.x + resized!.width + 32, resized!.y + 10, {
steps: 4,
});
await page.mouse.up();
await expect(row("设计")).toContainText("3 天");
await page.getByLabel("搜索甘特图任务").fill("验收");
await expect(gantt.locator(".xmind-gantt-row")).toHaveCount(1);
await page.getByRole("button", { name: "清除筛选", exact: true }).click();
await expect(gantt.locator(".xmind-gantt-row")).toHaveCount(3);
await page.screenshot({ path: test.info().outputPath("gantt-tasks.png") });
await page.getByRole("button", { name: "关闭甘特图", exact: true }).click();
const out = await exported(page);
const topics = out.sheets[0].rootTopic.children.attached;
expect(topics[1].extensions[0].content.dependencies[0].id).toBe(topics[0].id);
await page
.locator("input[accept*=xmind]")
.setInputFiles({
name: "任务往返.xmind",
mimeType: "application/octet-stream",
buffer: out.buffer,
});
await clickXmindAction(page, "甘特图");
await expect(gantt.locator(".xmind-gantt-bar")).toHaveCount(3);
await expect(gantt.locator("[data-gantt-dependency]")).toHaveCount(2);
expect(errors).toEqual([]);
});
test("task validation, circular dependencies, removal, CSV export and sheet isolation", async ({
page,
}) => {
await loadPlan(page);
await page
.getByRole("button", { name: "编辑任务 设计", exact: true })
.click();
const editor = page.getByRole("dialog", { name: "任务信息", exact: true });
await page.getByLabel("任务截止日期").fill("2026-10-01");
await editor.getByRole("button", { name: "保存任务", exact: true }).click();
await expect(editor.getByRole("alert")).toContainText("不能早于");
await page.getByLabel("任务截止日期").fill("2026-10-06");
await page
.getByLabel("前置任务", { exact: true })
.selectOption({ label: "开发" });
await editor.getByRole("button", { name: "添加", exact: true }).click();
await expect(editor.getByRole("alert")).toContainText("循环");
await editor
.getByRole("button", { name: "关闭任务信息", exact: true })
.click();
await clickXmindAction(page, "甘特图");
const gantt = page.getByRole("dialog", { name: "甘特图", exact: true });
const promise = page.waitForEvent("download");
await page
.getByRole("button", { name: "导出甘特图 CSV", exact: true })
.click();
const download = await promise;
expect(download.suggestedFilename()).toContain("甘特图.csv");
const stream = await download.createReadStream();
const chunks: Buffer[] = [];
for await (const c of stream!) chunks.push(c);
expect(Buffer.concat(chunks).toString()).toContain(
'"设计","2026-10-05","2026-10-06","2"'
);
await gantt.getByRole("button", { name: "设计", exact: true }).click();
await editor.getByRole("button", { name: "移除任务", exact: true }).click();
await expect(gantt.locator(".xmind-gantt-bar")).toHaveCount(1);
await expect(gantt.locator("[data-gantt-dependency]")).toHaveCount(0);
await gantt
.getByRole("button", { name: "任务时间条 开发", exact: true })
.press("ControlOrMeta+z");
await expect(gantt.locator(".xmind-gantt-bar")).toHaveCount(2);
await expect(gantt.locator("[data-gantt-dependency]")).toHaveCount(1);
await page.getByRole("button", { name: "关闭甘特图", exact: true }).click();
await clickXmindSheet(page, "其他工作表");
await clickXmindAction(page, "甘特图");
await expect(gantt.getByText("没有任务", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "关闭甘特图", exact: true }).click();
await clickXmindSheet(page, "项目计划");
await clickXmindAction(page, "甘特图");
await expect(gantt.locator(".xmind-gantt-bar")).toHaveCount(2);
});

View File

@ -5,6 +5,10 @@ import JSZip from "jszip";
const pixel =
"iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAYAAABzenr0AAAAO0lEQVR4AezXwQkAMAgDwND9d27rBD4FOdB3wv1yktzJrwI/f+4UIECAAAECBAgQIECAAAEC+wW63f0AAAD//+3aM8YAAAAGSURBVAMABkQgIT13JVIAAAAASUVORK5CYII=";
async function expectNote(page: import("@playwright/test").Page, text: string) {
await expect.poll(() => page.getByLabel("主题备注").locator("p").allTextContents().then(lines => lines.join("\n"))).toBe(text);
}
test("topic content imports, edits, undoes, survives sheet changes and follows internal links", async ({
page,
}) => {
@ -63,9 +67,8 @@ test("topic content imports, edits, undoes, survives sheet changes and follows i
const canvas = page.locator(".drawnix");
await canvas.getByText("产品说明", { exact: true }).click();
await clickXmindAction(page, "备注");
await expect(page.getByLabel("主题备注")).toHaveValue(
"第一行说明\n第二行说明"
);
await page.getByRole("button", {name:"主题详情",exact:true}).click();
await expectNote(page, "第一行说明\n第二行说明");
await expect(
page.getByRole("button", { name: "移除标签 产品", exact: true })
).toBeVisible();
@ -88,9 +91,7 @@ test("topic content imports, edits, undoes, survives sheet changes and follows i
await expect(
page.getByRole("button", { name: "移除标签 完成", exact: true })
).toHaveCount(0);
await expect(page.getByLabel("主题备注")).toHaveValue(
"更新后的多行备注\n保留换行"
);
await expectNote(page, "更新后的多行备注\n保留换行");
await clickXmindAction(page, "重做");
await expect(
page.getByRole("button", { name: "移除标签 完成", exact: true })
@ -103,19 +104,18 @@ test("topic content imports, edits, undoes, survives sheet changes and follows i
);
await page.getByRole("button", { name: "关闭主题内容", exact: true }).click();
await clickXmindAction(page, "备注");
await page.getByRole("button", {name:"主题详情",exact:true}).click();
await expect(page.getByLabel("主题外部链接")).toHaveValue(
"https://example.com/docs"
);
await page.getByRole("button", { name: "空白工作表", exact: true }).click();
await page.getByRole("button", { name: "产品工作表", exact: true }).click();
await canvas.getByText("产品说明", { exact: true }).click();
await expect(page.getByLabel("主题备注")).toHaveValue(
"更新后的多行备注\n保留换行"
);
await expectNote(page, "更新后的多行备注\n保留换行");
await canvas.getByText("链接入口", { exact: true }).click();
await page.getByRole("button", { name: "定位目标主题", exact: true }).click();
await expect(canvas.getByText("目标主题", { exact: true })).toBeVisible();
await expect(page.getByLabel("主题备注")).toHaveValue("目标的备注");
await expectNote(page, "目标的备注");
await canvas.getByText("附加文件", { exact: true }).click();
const downloadPromise = page.waitForEvent("download");
await page.getByRole("link", { name: "下载 spec.txt", exact: true }).click();
@ -176,7 +176,8 @@ test("legacy XML imports plain notes, separate labels and embedded pictures", as
const canvas = page.locator(".drawnix");
await canvas.getByText("旧版资料", { exact: true }).click();
await clickXmindAction(page, "备注");
await expect(page.getByLabel("主题备注")).toHaveValue("旧版备注");
await page.getByRole("button", {name:"主题详情",exact:true}).click();
await expectNote(page, "旧版备注");
await expect(
page.getByRole("button", { name: "移除标签 计划", exact: true })
).toBeVisible();

View File

@ -71,6 +71,7 @@ test("export downloads all sheets and preserves editable topic content after rei
const canvas = page.locator(".drawnix");
await canvas.getByText("设计方案", { exact: true }).click();
await clickXmindAction(page, "备注");
await page.getByRole("button",{name:"主题详情",exact:true}).click();
await page.getByLabel("主题备注").fill("编辑后的备注");
const downloading = page.waitForEvent("download");
await page.getByRole("button", { name: "导出 XMind", exact: true }).click();
@ -120,13 +121,14 @@ test("export downloads all sheets and preserves editable topic content after rei
).toHaveCount(1);
await canvas.getByText("设计方案", { exact: true }).click();
await clickXmindAction(page, "备注");
await expect(page.getByLabel("主题备注")).toHaveValue("编辑后的备注");
await page.getByRole("button",{name:"主题详情",exact:true}).click();
await expect.poll(() => page.getByLabel("主题备注").locator("p").allTextContents().then(lines => lines.join("\n"))).toBe("编辑后的备注");
await expect(
page.getByRole("button", { name: "移除标签 重点", exact: true })
).toBeVisible();
await canvas.getByText("定位设计", { exact: true }).click();
await page.getByRole("button", { name: "定位目标主题", exact: true }).click();
await expect(page.getByLabel("主题备注")).toHaveValue("编辑后的备注");
await expect.poll(() => page.getByLabel("主题备注").locator("p").allTextContents().then(lines => lines.join("\n"))).toBe("编辑后的备注");
await page.getByRole("button", { name: "上线", exact: true }).click();
await expect(canvas.getByText("功能验收", { exact: true })).toBeVisible();
expect(errors).toEqual([]);

View File

@ -0,0 +1,168 @@
import { expect, test } from "@playwright/test";
import type { PlaitElement } from "@plait/core";
import {
getNoteDocument,
serializeNoteDocument,
} from "@/lib/flow-board/topic-notes";
import {
addDays,
dateIndex,
DAY,
getTopicTask,
patchTopicTask,
scheduleTaskChanges,
validateTopicTask,
type TopicTask,
} from "@/lib/flow-board/topic-tasks";
import { exportXmind } from "@/lib/flow-board/xmind-export";
import { patchTopicDetails } from "@/lib/flow-board/topic-details";
import JSZip from "jszip";
const task = (start = "2026-10-05", duration = 2): TopicTask => ({
start,
due: addDays(start, duration - 1),
duration,
progress: 25,
priority: 2,
assignees: ["小明"],
dependencies: [],
});
const topic = (id: string, t: TopicTask): PlaitElement => ({
id,
type: "mindmap",
children: [],
data: { topic: { children: [{ text: id }] } },
xmind: patchTopicTask({ id }, t),
});
test("rich notes retain marks, nested links and lists through serialization, and reject executable links", async () => {
const source = {
id: "notes",
xmind: {
notes: {
plain: { content: "粗体链接\n步骤" },
html: {
content: {
paragraphs: [
{
spans: [
{
text: "粗体",
style: { properties: { "fo:font-weight": "bold" } },
},
{
href: "https://example.com",
spans: [
{
text: "链接",
style: {
properties: {
"fo:font-style": "italic",
"fo:text-decoration": "underline",
},
},
},
],
},
],
},
{
style: { properties: { "fo:text-bullet": "number" } },
spans: [{ text: "步骤", href: "javascript:alert(1)" }],
},
],
},
},
},
labels: ["保留"],
},
};
const document = getNoteDocument(source);
expect(document[0].children[0].bold).toBe(true);
expect(document[0].children[1]).toMatchObject({
href: "https://example.com/",
italic: true,
underline: true,
});
expect(document[1].list).toBe("number");
expect(document[1].children[0].href).toBeUndefined();
const metadata = patchTopicDetails(source, { notesDocument: document });
expect(metadata.labels).toEqual(["保留"]);
expect(
serializeNoteDocument(getNoteDocument({ id: "notes", xmind: metadata }))
).toEqual(metadata.notes);
const result = await exportXmind(
{
children: [
{
id: "notes",
type: "mindmap",
xmind: metadata,
data: { topic: { children: [{ text: "测试" }] } },
},
],
},
"笔记"
);
const zip = await JSZip.loadAsync(await result.blob.arrayBuffer());
expect(
JSON.parse(await zip.file("content.json")!.async("string"))[0].rootTopic
.notes
).toEqual(metadata.notes);
});
test("native task extensions export dates, fractional progress, millisecond durations and remapped dependencies", async () => {
const a = topic("a", task()),
b = topic("b", {
...task("2026-10-07", 3),
dependencies: [{ id: "a", type: "FS", lag: DAY }],
});
const root = {
id: "root",
type: "mindmap",
data: { topic: { children: [{ text: "计划" }] } },
children: [a, b],
};
expect(getTopicTask(a)).toEqual(task());
const result = await exportXmind({ children: [root] }, "计划");
const zip = await JSZip.loadAsync(await result.blob.arrayBuffer()),
content = JSON.parse(await zip.file("content.json")!.async("string"));
const [one, two] = content[0].rootTopic.children.attached;
expect(one.extensions[0].provider).toBe("org.xmind.ui.task");
expect(one.extensions[0].content).toMatchObject({
progress: 0.25,
duration: 2 * DAY,
assignees: ["小明"],
});
expect(two.extensions[0].content.dependencies).toEqual([
{ id: one.id, type: "FS", lag: DAY },
]);
});
test("task scheduling propagates dependencies, detects cycles and invalid dates, and rolls across leap days", () => {
const a = topic("a", task()),
b = topic("b", {
...task("2026-10-07"),
dependencies: [{ id: "a", type: "FS", lag: 0 }],
}),
c = topic("c", {
...task("2026-10-09"),
dependencies: [{ id: "b", type: "FS", lag: 0 }],
});
const changes = scheduleTaskChanges([a, b, c], "a", task("2026-10-08", 3));
expect(changes.get("b")?.start).toBe("2026-10-11");
expect(changes.get("c")?.start).toBe("2026-10-13");
expect(
validateTopicTask(
"a",
{ ...task(), dependencies: [{ id: "c", type: "FS", lag: 0 }] },
[a, b, c]
)
).toContain("循环");
expect(
validateTopicTask("a", { ...task(), due: "2026-10-01" }, [a, b, c])
).toContain("早于");
expect(Number.isNaN(dateIndex("2026-02-30"))).toBe(true);
expect(addDays("2028-02-28", 1)).toBe("2028-02-29");
const independent = patchTopicTask(a, null);
expect(getTopicTask({ ...a, xmind: independent })).toBeNull();
});