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