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