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