ai-chatbot/components/flow-board/xmind-topic-panel.tsx
134 79253245f3 fix(flow-board): align Xmind editor UI and restore marquee feedback
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.
2026-10-04 13:32:54 +08:00

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