From 23ced0b7602502f0ebedb96b2376d7b788daa2aa Mon Sep 17 00:00:00 2001 From: 134 Date: Tue, 6 Oct 2026 17:38:14 +0800 Subject: [PATCH] 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. --- components/flow-board/drawnix-workspace.tsx | 133 ++- components/flow-board/xmind-editor-chrome.tsx | 19 +- components/flow-board/xmind-gantt.tsx | 778 ++++++++++++++++++ components/flow-board/xmind-image-export.tsx | 94 +++ components/flow-board/xmind-native.css | 100 +++ components/flow-board/xmind-notes-editor.tsx | 318 +++++++ components/flow-board/xmind-notes-popover.tsx | 111 +++ .../flow-board/xmind-project-calendar.tsx | 650 +++++++++++++++ components/flow-board/xmind-task-editor.tsx | 384 +++++++++ components/flow-board/xmind-topic-badges.tsx | 139 ++++ components/flow-board/xmind-topic-panel.tsx | 28 +- docs/XMind-26.04-客户端界面对照.md | 11 + docs/XMind-复刻功能对照.md | 18 +- lib/flow-board/calendar-dates.ts | 24 + lib/flow-board/image-export.ts | 137 +++ lib/flow-board/project-calendar.ts | 288 +++++++ lib/flow-board/topic-details.ts | 19 +- lib/flow-board/topic-notes.ts | 290 +++++++ lib/flow-board/topic-tasks.ts | 268 ++++++ lib/flow-board/xmind-export.ts | 12 + lib/flow-board/xmind-import.ts | 16 +- tests/flow-board/editor-actions.ts | 4 +- tests/flow-board/image-export.spec.ts | 119 +++ tests/flow-board/image-paste.spec.ts | 6 +- tests/flow-board/notes-gantt.spec.ts | 279 +++++++ tests/flow-board/topic-details.spec.ts | 23 +- tests/flow-board/xmind-export.spec.ts | 6 +- tests/routes/flow-board-notes-tasks.test.ts | 168 ++++ 28 files changed, 4375 insertions(+), 67 deletions(-) create mode 100644 components/flow-board/xmind-gantt.tsx create mode 100644 components/flow-board/xmind-image-export.tsx create mode 100644 components/flow-board/xmind-notes-editor.tsx create mode 100644 components/flow-board/xmind-notes-popover.tsx create mode 100644 components/flow-board/xmind-project-calendar.tsx create mode 100644 components/flow-board/xmind-task-editor.tsx create mode 100644 components/flow-board/xmind-topic-badges.tsx create mode 100644 lib/flow-board/calendar-dates.ts create mode 100644 lib/flow-board/image-export.ts create mode 100644 lib/flow-board/project-calendar.ts create mode 100644 lib/flow-board/topic-notes.ts create mode 100644 lib/flow-board/topic-tasks.ts create mode 100644 tests/flow-board/image-export.spec.ts create mode 100644 tests/flow-board/notes-gantt.spec.ts create mode 100644 tests/routes/flow-board-notes-tasks.test.ts diff --git a/components/flow-board/drawnix-workspace.tsx b/components/flow-board/drawnix-workspace.tsx index b2b0d0d18..d03852ffc 100644 --- a/components/flow-board/drawnix-workspace.tsx +++ b/components/flow-board/drawnix-workspace.tsx @@ -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(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(null); const [topicDetailSection, setTopicDetailSection] = useState("notes"); const [annotationId, setAnnotationId] = useState(null); const mindMapFileInputRef = useRef(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([[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(); + 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) => { + 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({ /> + {!isOutlineOpen ? { + 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 ?
: null} {isSearchOpen && !isZenMode ? setIsSearchOpen(false)} onJump={handleSearchJump} onReplace={handleSearchReplace} /> : null} + {isNotesPopoverOpen ? handlePatchTopicDetails({notesDocument})} onDetails={() => {setIsNotesPopoverOpen(false);setIsTopicPanelOpen(true);setIsFormatPanelOpen(true);setIsFilePanelOpen(false);setIsMarkerPanelOpen(false);}} onClose={() => setIsNotesPopoverOpen(false)} /> : null} + {isImageExportOpen ? void handleExportImage(options)} onClose={() => setIsImageExportOpen(false)} /> : null} + {isCalendarOpen ? setIsCalendarOpen(false)} /> : null} + {isGanttOpen ? setIsGanttOpen(false)} onEdit={setTaskTopicId} onCalendar={() => setIsCalendarOpen(true)} onChange={handleSaveTopicTask} onUndo={handleUndo} onRedo={handleRedo} /> : null} + {taskTopicId && topicEntries.some(e => e.element.id === taskTopicId) ? e.element.id === taskTopicId)!.element} elements={snapshot.children} onSave={handleSaveTopicTask} onClose={() => setTaskTopicId(null)} onGantt={() => setIsGanttOpen(true)} onCalendar={() => setIsCalendarOpen(true)} /> : null} {isPresenting ? setIsPresenting(false)} /> : null} ); diff --git a/components/flow-board/xmind-editor-chrome.tsx b/components/flow-board/xmind-editor-chrome.tsx index 985acd108..6f262878e 100644 --- a/components/flow-board/xmind-editor-chrome.tsx +++ b/components/flow-board/xmind-editor-chrome.tsx @@ -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)} 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({
-
{action("导出 XMind", Download, onExport, isExporting)}
+
{action("导出 XMind", Download, onExport, isExporting)}导出
{action("ZEN 模式", Scan, onToggleZen)} {action("大纲视图", FileText, onToggleOutline)} diff --git a/components/flow-board/xmind-gantt.tsx b/components/flow-board/xmind-gantt.tsx new file mode 100644 index 000000000..591500a7f --- /dev/null +++ b/components/flow-board/xmind-gantt.tsx @@ -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(null); + useEffect(() => { + const previous = document.activeElement as HTMLElement | null; + panelRef.current?.querySelector("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>(new Set()); + const [selected, setSelected] = useState([]); + const [drag, setDrag] = useState<{ + id: string; + x: number; + kind: "move" | "start" | "due"; + task: TopicTask; + days: number; + } | null>(null); + const scrollRef = useRef(null); + const rowScrollRef = useRef(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 ( +
e.stopPropagation()} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Escape") { + e.stopPropagation(); + onClose(); + } + if (e.key === "Tab") { + const controls = Array.from( + panelRef.current?.querySelectorAll( + '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(); + } + }} + > +
+
+ +

甘特图

+ {title} +
+
+ + + + + +
+ + setScale(Number(e.target.value))} + /> + +
+
+
+
+ + + + + + + {calendar ? `${calendar.name} · 跳过非工作日 · ` : ""} + {visible.length} 个任务 + +
+
+
+
+ 任务名称 + 开始日期 + 时长 +
+
{ + const node = scrollRef.current; + if (node && node.scrollTop !== e.currentTarget.scrollTop) + node.scrollTop = e.currentTarget.scrollTop; + }} + data-gantt-row-scroll="true" + > + {visible.map((row) => ( +
+
+ + setSelected((s) => + e.target.checked + ? [...s, row.element.id] + : s.filter((id) => id !== row.element.id) + ) + } + /> + {row.summary ? ( + + ) : null} + +
+ + {effective(row).duration} 天 +
+ ))} + {!visible.length ? ( +
+ + {rows.length ? "没有匹配的任务" : "没有任务"} +

+ {rows.length + ? "调整搜索或筛选条件。" + : "请选择一个主题以添加任务。"} +

+
+ ) : null} +
+
+
{ + setScrollX(e.currentTarget.scrollLeft); + setViewportWidth(e.currentTarget.clientWidth); + const rowScroll = rowScrollRef.current; + if (rowScroll && rowScroll.scrollTop !== e.currentTarget.scrollTop) + rowScroll.scrollTop = e.currentTarget.scrollTop; + }} + > +
+ {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) => ( +
+ )) + : null} +
+ {ticks.map((offset) => { + const date = indexDate(from + offset); + return ( +
+ + {tickStep >= 30 + ? date.slice(5, 7) + "月" + : tickStep >= 7 + ? date.slice(5) + : String(Number(date.slice(8)))} + +
+ ); + })} + {monthBands.map((band) => ( +
+ {band.date.replace("-", "年")}月 +
+ ))} +
+
+ + + + + + + {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 ( + + ); + }) + )} + + {visible.map((row, i) => + selected.includes(row.element.id) ? ( +
+ ) : 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 ( +
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)} + > +
+ {!row.summary ? ( + <> + + + + ) : null} + + + + + {row.title} + +
+ ); + })} +
+
+
+
+ + {drag + ? `调整 ${drag.days > 0 ? "+" : ""}${drag.days} 天` + : "拖动调整日期 · 拉伸修改工期 · 双击编辑任务"} + + {notice} + +
+
+ ); +} diff --git a/components/flow-board/xmind-image-export.tsx b/components/flow-board/xmind-image-export.tsx new file mode 100644 index 000000000..3bde9a33e --- /dev/null +++ b/components/flow-board/xmind-image-export.tsx @@ -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 + ) => void; + onClose: () => void; +}) { + const dialog = useRef(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 ( + event.stopPropagation()} + onCancel={(event) => { + event.preventDefault(); + if (!busy) onClose(); + }} + > +
+

导出图片

+ +
+

导出当前工作表的完整内容为 PNG 图片。

+ + +
+ + +
+
+ ); +} diff --git a/components/flow-board/xmind-native.css b/components/flow-board/xmind-native.css index 0f5b9d99c..a2276338f 100644 --- a/components/flow-board/xmind-native.css +++ b/components/flow-board/xmind-native.css @@ -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; } diff --git a/components/flow-board/xmind-notes-editor.tsx b/components/flow-board/xmind-notes-editor.tsx new file mode 100644 index 000000000..360d2cd6e --- /dev/null +++ b/components/flow-board/xmind-notes-editor.tsx @@ -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 | 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 | 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, + { 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 ( + + {href ? ( + { + if (!e.metaKey && !e.ctrlKey) e.preventDefault(); + }} + target="_blank" + > + {children} + + ) : ( + children + )} + + ); + }; + const element = ({ attributes, children, element }: RenderElementProps) => { + const p = element as NoteParagraph; + return ( +

+ {children} +

+ ); + }; + return ( +
+ { + 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); + }} + > +
+ {( + [ + ["bold", "加粗", Bold], + ["italic", "斜体", Italic], + ["underline", "下划线", Underline], + ["strikethrough", "删除线", Strikethrough], + ] as const + ).map(([mark, label, Icon]) => ( + + ))} + + + +
+ {linkOpen ? ( +
+ setLink(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + applyLink(); + } + }} + /> + + + {linkError ?

{linkError}

: null} +
+ ) : null} + { + 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, + { match: (n) => Element.isElement(n) } + ); + } + }} + /> +
+
+ ); +} diff --git a/components/flow-board/xmind-notes-popover.tsx b/components/flow-board/xmind-notes-popover.tsx new file mode 100644 index 000000000..5e6f850b7 --- /dev/null +++ b/components/flow-board/xmind-notes-popover.tsx @@ -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(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 ( + + ); +} diff --git a/components/flow-board/xmind-project-calendar.tsx b/components/flow-board/xmind-project-calendar.tsx new file mode 100644 index 000000000..6b9537383 --- /dev/null +++ b/components/flow-board/xmind-project-calendar.tsx @@ -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 = { + NONE: "不重复", + DAILY: "每天", + WEEKLY: "每周", + MONTHLY_BY_DATE: "每月(按日期)", + MONTHLY_BY_WEEKDAY: "每月(按星期)", + YEARLY: "每年", +}; +const copy = (value: T): T => JSON.parse(JSON.stringify(value)); +function useCalendarDialogFocus(ref: React.RefObject) { + useEffect(() => { + const previous = document.activeElement as HTMLElement | null; + ref.current?.querySelector("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( + "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(null); + useCalendarDialogFocus(ref); + const patch = (changes: Partial) => + 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 ( +
event.stopPropagation()} + onKeyDown={(event) => { + event.stopPropagation(); + trapCalendarTab(event, ref.current); + if (event.key === "Escape") onClose(); + }} + > +
+
+

工作日例外

+ +
+
+ + + + + {draft.frequency !== "NONE" ? ( + + ) : null} +
+ + +
+

+ 留空表示不限制生效日期。后添加的例外优先。 +

+ {error ? ( +

+ {error} +

+ ) : null} +
+
+ + +
+
+
+ ); +} +export function XmindProjectCalendar({ + config, + onSave, + onClose, +}: { + config: ProjectCalendarConfig; + onSave: (config: ProjectCalendarConfig) => boolean; + onClose: () => void; +}) { + const ref = useRef(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(null), + [manage, setManage] = useState(false); + const [templates, setTemplates] = useState(() => { + 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 ( +
event.stopPropagation()} + onKeyDown={(event) => { + event.stopPropagation(); + if (exception) return; + trapCalendarTab(event, ref.current); + if (event.key === "Escape") onClose(); + }} + > +
+
+
+

+ + 项目工作日 +

+

只应用于当前工作表,不影响其他工作表。

+
+ +
+
+
+
+ + +
+
+ + {month.replace("-", "年")}月 + +
+
+ {WEEKDAYS.map((day) => ( + + {day} + + ))} + {days.map((date) => ( + + ))} +
+

+ 阴影为休息日。选择日期后可添加例外。 +

+ {manage ? ( +
+

个人日历

+

保存在当前浏览器,可用于其他工作表。

+ {templates.length ? ( + templates.map((item) => ( +
+ {item.name} + +
+ )) + ) : ( +

还没有个人日历。

+ )} + + +
+ ) : null} +
+
+ + +

每周工作日

+
+ {WEEKDAYS.map((day, index) => ( + + ))} +
+
+

例外

+ +
+
+ {[...draft.rules].reverse().map((rule) => ( +
+ + +
+ ))} + {!draft.rules.length ? ( +

可添加节假日、调休或重复例外。

+ ) : null} +
+ {error ? ( +

+ {error} +

+ ) : null} + {message ?

{message}

: null} +
+
+
+
+ {config.settings ? ( + + ) : null} + +
+
+ + +
+
+
+ {exception ? ( + + 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} +
+ ); +} diff --git a/components/flow-board/xmind-task-editor.tsx b/components/flow-board/xmind-task-editor.tsx new file mode 100644 index 000000000..eeb54e1f2 --- /dev/null +++ b/components/flow-board/xmind-task-editor.tsx @@ -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(null); + useEffect(() => { + const previous = document.activeElement as HTMLElement | null; + dialogRef.current?.querySelector("input")?.focus(); + return () => { + if (previous?.isConnected) previous.focus(); + }; + }, []); + const calendar = activeWorkingCalendar(getProjectCalendar(elements)); + const existing = getTopicTask(topic); + const [draft, setDraft] = useState( + () => existing || newTopicTask() + ); + const [error, setError] = useState(""); + const [assigneesDraft, setAssigneesDraft] = useState( + (existing?.assignees || []).join(", ") + ); + const [dependency, setDependency] = useState(""); + const [dependencyType, setDependencyType] = + useState("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) => 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 ( +
e.stopPropagation()} + onKeyDown={(e) => { + e.stopPropagation(); + if (e.key === "Escape") { + e.stopPropagation(); + onClose(); + } + if (e.key === "Tab") { + const controls = Array.from( + dialogRef.current?.querySelectorAll( + "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(); + } + } + }} + > +
+
+

+ + 任务信息 +

+ +
+

{getTopicTitle(topic)}

+
+ + + + + + +
+

前置任务

+
+ {draft.dependencies.map((dep, i) => ( +
+ + {tasks.find((e) => e.element.id === dep.id)?.title || + "已删除的任务"}{" "} + · {dep.type} + {dep.lag ? ` · ${dep.lag / DAY} 天` : ""} + + +
+ ))} +
+
+ + + setLag(e.target.value)} + title="间隔天数(负数为提前)" + /> + +
+ {error ? ( +

+ {error} +

+ ) : null} +
+ + {calendar ? `已跳过非工作日 · ${calendar.name}` : "包含非工作日"} + + +
+
+ {existing ? ( + + ) : ( + + )} + + +
+
+
+ ); +} diff --git a/components/flow-board/xmind-topic-badges.tsx b/components/flow-board/xmind-topic-badges.tsx new file mode 100644 index 000000000..9b671198d --- /dev/null +++ b/components/flow-board/xmind-topic-badges.tsx @@ -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(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 ( +
+ {badges.map((b) => ( +
+ {b.progress !== null ? ( + + ) : null} + {b.notes ? ( + + ) : null} +
+ ))} +
+ ); +} diff --git a/components/flow-board/xmind-topic-panel.tsx b/components/flow-board/xmind-topic-panel.tsx index 5ae6916c3..18c1d0646 100644 --- a/components/flow-board/xmind-topic-panel.tsx +++ b/components/flow-board/xmind-topic-panel.tsx @@ -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(null); - const noteRef = useRef(null); + const linkRef = useRef(null); const labelRef = useRef(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({

{getTopicTitle(topic)}

-