ai-chatbot/components/flow-board/xmind-topic-badges.tsx
134 23ced0b760 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.
2026-10-06 17:38:14 +08:00

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>
);
}