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:
parent
e6a68b345b
commit
23ced0b760
@ -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>
|
||||
);
|
||||
|
||||
@ -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)}
|
||||
|
||||
778
components/flow-board/xmind-gantt.tsx
Normal file
778
components/flow-board/xmind-gantt.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
components/flow-board/xmind-image-export.tsx
Normal file
94
components/flow-board/xmind-image-export.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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; }
|
||||
|
||||
318
components/flow-board/xmind-notes-editor.tsx
Normal file
318
components/flow-board/xmind-notes-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
111
components/flow-board/xmind-notes-popover.tsx
Normal file
111
components/flow-board/xmind-notes-popover.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
650
components/flow-board/xmind-project-calendar.tsx
Normal file
650
components/flow-board/xmind-project-calendar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
384
components/flow-board/xmind-task-editor.tsx
Normal file
384
components/flow-board/xmind-task-editor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
components/flow-board/xmind-topic-badges.tsx
Normal file
139
components/flow-board/xmind-topic-badges.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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} />
|
||||
|
||||
@ -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 和相关代码检查通过。浏览器覆盖笔记与甘特图,并回归标记、框选、图片粘贴、主题内容及文件往返;首次空白画布导出超时后的复测已通过。快捷键不再把笔记全选变成画布多选。
|
||||
|
||||
@ -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 中打开网页导出文件:笔记浮窗显示有序列表、加粗和蓝色链接,原生甘特图显示两条时间条、日期/工期/负责人和前置关系箭头。网页时间条改为客户端的橙色胶囊外观,显示任务名与负责人提示,选中行和时间条同步高亮。
|
||||
- 修复笔记快捷键被画布拦截及快速选区格式不同步的问题;笔记、侧栏输入和任务窗口的键盘/剪贴板事件各自处理,编辑时保留当前主题。
|
||||
- 当前是可用的富文本笔记和日级甘特图能力,未宣称与客户端全部功能或像素完全一致。高级工作日日历、小时/里程碑、原生全部导出格式、团队成员服务及笔记图片仍需继续补齐。
|
||||
|
||||
24
lib/flow-board/calendar-dates.ts
Normal file
24
lib/flow-board/calendar-dates.ts
Normal 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;
|
||||
137
lib/flow-board/image-export.ts
Normal file
137
lib/flow-board/image-export.ts
Normal 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 };
|
||||
}
|
||||
288
lib/flow-board/project-calendar.ts
Normal file
288
lib/flow-board/project-calendar.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
290
lib/flow-board/topic-notes.ts
Normal file
290
lib/flow-board/topic-notes.ts
Normal 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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
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",
|
||||
},
|
||||
},
|
||||
})),
|
||||
})),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
268
lib/flow-board/topic-tasks.ts
Normal file
268
lib/flow-board/topic-tasks.ts
Normal 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;
|
||||
}
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@ -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:#")
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
119
tests/flow-board/image-export.spec.ts
Normal file
119
tests/flow-board/image-export.spec.ts
Normal 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"));
|
||||
});
|
||||
@ -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);
|
||||
}
|
||||
|
||||
279
tests/flow-board/notes-gantt.spec.ts
Normal file
279
tests/flow-board/notes-gantt.spec.ts
Normal 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);
|
||||
});
|
||||
@ -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();
|
||||
|
||||
@ -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([]);
|
||||
|
||||
168
tests/routes/flow-board-notes-tasks.test.ts
Normal file
168
tests/routes/flow-board-notes-tasks.test.ts
Normal 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();
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user