Add native-style menus, search and presentation controls, typography resources, and Xmind import/export styling support. Bundle canvas styles for preview and production, show marquee borders and selection fills, and align editor density with the project UI. Include interaction regression coverage and client comparison notes.
330 lines
11 KiB
TypeScript
330 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";
|
||
|
||
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 noteRef = useRef<HTMLTextAreaElement>(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(() => {
|
||
({ notes: noteRef, links: linkRef, labels: labelRef })[
|
||
section
|
||
].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>
|
||
<label className="block space-y-2">
|
||
<span className="flex items-center gap-2 font-medium">
|
||
<StickyNote size={14} />
|
||
备注
|
||
</span>
|
||
<textarea
|
||
aria-label="主题备注"
|
||
ref={noteRef}
|
||
className="min-h-40 w-full resize-y rounded border border-[#dfe2e7] p-2 text-sm leading-6 outline-none focus:border-blue-400"
|
||
placeholder="为这个主题补充说明…"
|
||
value={details.notes}
|
||
onChange={(event) => onPatch({ notes: event.target.value })}
|
||
/>
|
||
</label>
|
||
<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>
|
||
);
|
||
}
|