ai-chatbot/components/flow-board/xmind-topic-panel.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

322 lines
11 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import type { PlaitElement } from "@plait/core";
import {
ExternalLink,
FileDown,
Link2,
StickyNote,
Tag,
X,
} from "lucide-react";
import { useEffect, useRef, useState } from "react";
import {
getSafeTopicHref,
getTopicDetails,
getTopicTitle,
type TopicDetails,
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({
topic,
topics,
section,
onPatch,
onJump,
onClose,
}: {
topic: PlaitElement | null;
topics: TopicEntry[];
section: TopicDetailSection;
onPatch: (patch: Partial<TopicDetails>) => void;
onJump: (id: string) => void;
onClose: () => void;
}) {
const details = getTopicDetails(topic);
const [labelDraft, setLabelDraft] = useState("");
const [linkDraft, setLinkDraft] = useState(details.href);
const [attachmentError, setAttachmentError] = useState("");
const [isReadingAttachment, setIsReadingAttachment] = useState(false);
const attachmentRef = useRef<HTMLInputElement>(null);
const linkRef = useRef<HTMLInputElement>(null);
const labelRef = useRef<HTMLInputElement>(null);
useEffect(() => {
setLinkDraft(details.href);
}, [topic?.id, details.href]);
useEffect(() => {
setLabelDraft("");
setAttachmentError("");
}, [topic?.id]);
useEffect(() => {
if (section === "links") linkRef.current?.focus();
if (section === "labels") labelRef.current?.focus();
}, [section]);
const safeHref = getSafeTopicHref(linkDraft);
const invalidHref = Boolean(linkDraft.trim()) && !safeHref;
const addLabel = () => {
const value = labelDraft.trim();
if (value && !details.labels.includes(value))
onPatch({ labels: [...details.labels, value] });
setLabelDraft("");
};
const addAttachment = async (file: File) => {
setAttachmentError("");
if (file.size > 8 * 1024 * 1024) {
setAttachmentError("附件不能超过 8 MB。");
return;
}
setIsReadingAttachment(true);
try {
const url = await new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error("读取失败"));
reader.onload = () => resolve(String(reader.result));
// Always download attachments as files, independent of their source type.
reader.readAsDataURL(
new Blob([file], { type: "application/octet-stream" })
);
});
onPatch({
attachment: {
name: file.name,
url,
mimeType: "application/octet-stream",
},
});
} catch {
setAttachmentError("无法读取附件,请重新选择。");
} finally {
setIsReadingAttachment(false);
}
};
return (
<aside
className="xmind-aux-panel absolute right-0 top-[94px] bottom-9 z-30 w-full max-w-[304px] overflow-y-auto border-l border-[#dfe2e7] bg-white text-[#343b48]"
data-flow-board-shell-control="true"
>
<div className="flex h-12 items-center justify-between border-b border-[#eceef1] px-4">
<h2 className="text-sm font-semibold">主题内容</h2>
<button type="button" aria-label="关闭主题内容" onClick={onClose}>
<X size={16} />
</button>
</div>
{!topic ? (
<p className="p-4 text-xs text-[#8b919c]">
选择一个主题,编辑它的备注、链接和标签。
</p>
) : (
<div className="space-y-6 p-4 text-xs">
<p className="truncate font-medium" title={getTopicTitle(topic)}>
{getTopicTitle(topic)}
</p>
<section className="space-y-2">
<h3 className="flex items-center gap-2 font-medium"><StickyNote size={14} />笔记</h3>
<XmindNotesEditor key={topic.id} value={getNoteDocument(topic)} onChange={(notesDocument) => onPatch({notesDocument})} />
</section>
<section className="space-y-2">
<h3 className="flex items-center gap-2 font-medium">
<Tag size={14} />
标签
</h3>
<div className="flex flex-wrap gap-1.5">
{details.labels.map((label) => (
<span
key={label}
className="flex items-center gap-1 rounded bg-[#edf3ff] px-2 py-1 text-[#3569e8]"
>
{label}
<button
type="button"
aria-label={`移除标签 ${label}`}
onClick={() =>
onPatch({
labels: details.labels.filter(
(value) => value !== label
),
})
}
>
<X size={12} />
</button>
</span>
))}
</div>
<div className="flex gap-2">
<input
aria-label="新增主题标签"
ref={labelRef}
className="h-8 min-w-0 flex-1 rounded border border-[#dfe2e7] px-2"
placeholder="输入标签后回车"
value={labelDraft}
onChange={(event) => setLabelDraft(event.target.value)}
onKeyDown={(event) => {
if (event.key === "Enter" && !event.nativeEvent.isComposing) {
event.preventDefault();
addLabel();
}
}}
/>
<button
type="button"
className="rounded border border-[#dfe2e7] px-2"
onClick={addLabel}
>
添加
</button>
</div>
</section>
<section className="space-y-2">
<h3 className="flex items-center gap-2 font-medium">
<Link2 size={14} />
网页或邮件链接
</h3>
<input
aria-label="主题外部链接"
ref={linkRef}
aria-invalid={invalidHref}
className="h-8 w-full rounded border border-[#dfe2e7] px-2"
placeholder="https://…"
value={linkDraft}
onChange={(event) => setLinkDraft(event.target.value)}
onBlur={() => {
if (!invalidHref) onPatch({ href: linkDraft.trim() });
}}
onKeyDown={(event) => {
if (event.key === "Enter" && !invalidHref) {
event.preventDefault();
onPatch({ href: linkDraft.trim() });
}
}}
/>
{invalidHref ? (
<p role="alert" className="text-red-600">
请输入完整的 http、https、mailto 或 tel 链接。
</p>
) : safeHref ? (
<a
className="inline-flex items-center gap-1 text-[#3569e8]"
href={safeHref}
rel="noopener noreferrer"
target="_blank"
>
<ExternalLink size={12} />
打开链接
</a>
) : null}
{details.href ? (
<button
type="button"
className="ml-3 text-[#8b919c]"
onClick={() => {
setLinkDraft("");
onPatch({ href: "" });
}}
>
移除外部链接
</button>
) : null}
</section>
<section className="space-y-2">
<h3 className="flex items-center gap-2 font-medium">
<FileDown size={14} />
附件
</h3>
{details.attachment ? (
<>
<a
className="block break-all text-[#3569e8]"
href={details.attachment.url}
download={details.attachment.name}
>
下载 {details.attachment.name}
</a>
<button
type="button"
className="text-[#8b919c]"
onClick={() => onPatch({ attachment: null })}
>
移除附件
</button>
</>
) : null}
<input
ref={attachmentRef}
type="file"
className="hidden"
aria-label="上传主题附件"
onChange={(event) => {
const file = event.target.files?.[0];
event.target.value = "";
if (file) void addAttachment(file);
}}
/>
<button
type="button"
className="block text-[#3569e8] disabled:text-gray-400"
disabled={isReadingAttachment}
onClick={() => attachmentRef.current?.click()}
>
{isReadingAttachment
? "正在读取附件…"
: details.attachment
? "替换附件"
: "添加附件"}
</button>
{attachmentError ? (
<p role="alert" className="text-red-600">
{attachmentError}
</p>
) : null}
</section>
<label className="block space-y-2">
<span className="font-medium">链接到当前画布的主题</span>
<select
aria-label="主题链接目标"
className="h-8 w-full rounded border border-[#dfe2e7] px-2"
value={details.topicLinkId}
onChange={(event) => onPatch({ topicLinkId: event.target.value })}
>
<option value="">无主题链接</option>
{details.topicLinkId &&
!topics.some(
({ element }) => element.id === details.topicLinkId
) ? (
<option value={details.topicLinkId}>目标主题已不存在</option>
) : null}
{topics
.filter(({ element }) => element.id !== topic.id)
.map(({ element, title, depth }) => (
<option key={element.id} value={element.id}>
{" ".repeat(Math.min(depth, 5))}
{title}
</option>
))}
</select>
</label>
{details.topicLinkId ? (
<button
type="button"
disabled={
!topics.some(
({ element }) => element.id === details.topicLinkId
)
}
className="text-[#3569e8] disabled:text-gray-400"
onClick={() => onJump(details.topicLinkId)}
>
定位目标主题
</button>
) : null}
</div>
)}
</aside>
);
}