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.
385 lines
11 KiB
TypeScript
385 lines
11 KiB
TypeScript
"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>
|
||
);
|
||
}
|