ai-chatbot/components/flow-board/xmind-outline.tsx

311 lines
10 KiB
TypeScript

"use client";
import { useEffect, useRef, useState } from "react";
import {
ArrowDown,
ArrowUp,
ChevronDown,
ChevronRight,
GripVertical,
IndentDecrease,
IndentIncrease,
Plus,
Trash2,
} from "lucide-react";
import type { PlaitElement } from "@plait/core";
import {
getTopicDetails,
getTopicEntries,
type TopicEntry,
} from "@/lib/flow-board/topic-details";
import type {
OutlineCommand,
OutlineDrop,
} from "@/lib/flow-board/mind-outline";
type Props = {
zoomPercent: number;
elements: PlaitElement[];
selectedId: string | null;
onSelect: (id: string) => void;
onRename: (id: string, title: string) => void;
onToggle: (id: string) => void;
onCommand: (
id: string | null,
command: OutlineCommand | OutlineDrop
) => string | null;
onUndo: () => void;
onRedo: () => void;
};
function OutlineRow({
entry,
selected,
onSelect,
onRename,
onToggle,
onCommand,
onFocusTopic,
}: {
entry: TopicEntry;
selected: boolean;
onSelect: Props["onSelect"];
onRename: Props["onRename"];
onToggle: Props["onToggle"];
onCommand: Props["onCommand"];
onFocusTopic: (id: string | null, direction?: number) => void;
}) {
const { element, title, depth } = entry;
const [draft, setDraft] = useState(title);
const [drop, setDrop] = useState<OutlineDrop["placement"] | null>(null);
const ref = useRef<HTMLInputElement>(null);
const details = getTopicDetails(element);
useEffect(() => setDraft(title), [title]);
const commit = () => {
if (draft !== title) onRename(element.id, draft);
};
const act = (command: OutlineCommand) => {
commit();
onFocusTopic(onCommand(element.id, command));
};
return (
<div
data-outline-id={element.id}
data-depth={depth}
className={`group flex min-h-11 items-center gap-1 border-b border-[#f1f2f5] px-3 ${selected ? "bg-[#f0f5ff]" : "hover:bg-[#fafbfc]"} ${drop === "child" ? "ring-1 ring-inset ring-blue-400" : drop === "before" ? "border-t-2 border-t-blue-400" : drop === "after" ? "border-b-2 border-b-blue-400" : ""}`}
style={{ paddingLeft: 12 + Math.min(depth, 20) * 24 }}
onDragOver={(event) => {
event.preventDefault();
const box = event.currentTarget.getBoundingClientRect();
const y = (event.clientY - box.top) / box.height;
setDrop(y < 0.25 ? "before" : y > 0.75 ? "after" : "child");
}}
onDragLeave={() => setDrop(null)}
onDrop={(event) => {
event.preventDefault();
const source = event.dataTransfer.getData("application/x-mind-topic");
if (source && drop)
onFocusTopic(
onCommand(source, { targetId: element.id, placement: drop })
);
setDrop(null);
}}
>
<span
draggable
aria-label={`拖动 ${title}`}
className="cursor-grab text-[#9ca3af] opacity-30 group-hover:opacity-100"
onDragStart={(event) => {
event.dataTransfer.setData("application/x-mind-topic", element.id);
event.dataTransfer.effectAllowed = "move";
}}
>
<GripVertical size={13} />
</span>
{element.children?.length ? (
<button
type="button"
className="p-1 text-[#64748b]"
aria-label={`${element.isCollapsed ? "展开" : "折叠"} ${title}`}
onClick={() => onToggle(element.id)}
>
{element.isCollapsed ? (
<ChevronRight size={14} />
) : (
<ChevronDown size={14} />
)}
</button>
) : (
<span className="w-[22px] text-center text-[#a2a9b3]">·</span>
)}
<input
ref={ref}
aria-label={`大纲主题 ${title}`}
data-outline-input={element.id}
className={`min-w-0 flex-1 bg-transparent py-2 text-sm outline-none ${depth === 0 ? "font-semibold text-[#253046]" : "text-[#404958]"}`}
value={draft}
onChange={(event) => setDraft(event.target.value)}
onFocus={() => onSelect(element.id)}
onBlur={commit}
onKeyDown={(event) => {
if (event.nativeEvent.isComposing) return;
if (event.key === "Enter") {
event.preventDefault();
act(event.shiftKey || depth === 0 ? "child" : "sibling");
} else if (event.key === "Tab") {
event.preventDefault();
act(event.shiftKey ? "outdent" : "indent");
} else if (event.key === "ArrowUp" || event.key === "ArrowDown") {
event.preventDefault();
commit();
if (event.altKey) act(event.key === "ArrowUp" ? "up" : "down");
else onFocusTopic(element.id, event.key === "ArrowUp" ? -1 : 1);
} else if (event.key === "Escape") {
event.preventDefault();
setDraft(title);
} else if (
(event.metaKey || event.ctrlKey) &&
event.key.toLowerCase() === "z" &&
draft !== title
) {
event.preventDefault();
event.stopPropagation();
setDraft(title);
} else if (event.key === "Backspace" && !draft) {
event.preventDefault();
onFocusTopic(onCommand(element.id, "delete"));
}
}}
/>
{Number.isInteger(element.start) ? (
<span className="text-[10px] text-[#8d79b2]">概要</span>
) : null}
{details.notes ? (
<span title={details.notes} className="text-[10px] text-[#8a94a5]">
备注
</span>
) : null}
{details.labels.map((label) => (
<span
key={label}
className="hidden rounded bg-[#e8effc] px-1.5 py-0.5 text-[10px] text-[#5879b4] lg:inline"
>
{label}
</span>
))}
<button
type="button"
aria-label={`为 ${title} 添加子主题`}
className="p-1 text-[#8a94a5] opacity-0 focus:opacity-100 group-hover:opacity-100"
onClick={() => act("child")}
>
<Plus size={14} />
</button>
</div>
);
}
export function XmindOutline(props: Props) {
const { elements, selectedId, onCommand } = props;
const container = useRef<HTMLElement>(null);
const [focusId, setFocusId] = useState<string | null>(null);
const entries = getTopicEntries(elements);
const collapsedPaths = entries
.filter(({ element }) => element.isCollapsed)
.map((entry) => entry.path);
const visible = entries.filter(
(entry) =>
!collapsedPaths.some(
(path) =>
path.length < entry.path.length &&
path.every((index, i) => index === entry.path[i])
)
);
useEffect(() => {
if (!focusId) return;
const input = Array.from(
container.current?.querySelectorAll<HTMLInputElement>(
"[data-outline-input]"
) || []
).find((node) => node.dataset.outlineInput === focusId);
if (input) {
input.focus();
input.select();
setFocusId(null);
}
}, [focusId, elements]);
const focusTopic = (id: string | null, direction?: number) => {
if (!id) return;
const target = direction
? visible[
visible.findIndex((entry) => entry.element.id === id) + direction
]?.element.id
: id;
if (target) setFocusId(target);
};
const action = (command: OutlineCommand) =>
focusTopic(onCommand(selectedId, command));
return (
<section
ref={container}
aria-label="大纲视图"
className="h-full overflow-auto bg-white"
data-flow-board-shell-control="true"
onKeyDown={(event) => {
event.stopPropagation();
if (
(event.metaKey || event.ctrlKey) &&
event.key.toLowerCase() === "z"
) {
event.preventDefault();
if (event.shiftKey) props.onRedo();
else props.onUndo();
}
}}
>
<div className="sticky top-0 z-10 flex flex-wrap items-center gap-1 border-b border-[#e5e8ee] bg-white px-5 py-3">
<h2 className="mr-2 text-sm font-semibold">大纲</h2>
<span className="mr-auto text-xs text-[#949ba7]">
{entries.length} 个主题
</span>
{(
[
{ command: "child", label: "大纲新增子主题", icon: Plus },
{ command: "indent", label: "大纲缩进", icon: IndentIncrease },
{ command: "outdent", label: "大纲提升层级", icon: IndentDecrease },
{ command: "up", label: "大纲上移", icon: ArrowUp },
{ command: "down", label: "大纲下移", icon: ArrowDown },
{ command: "delete", label: "大纲删除主题", icon: Trash2 },
] as const
).map(({ command, label, icon: Icon }) => (
<button
key={command}
type="button"
title={label}
aria-label={label}
className="rounded p-1.5 text-[#64748b] hover:bg-[#edf3ff] disabled:opacity-30"
disabled={
(!selectedId && entries.length > 0) ||
(!selectedId && command !== "child")
}
onClick={() => action(command)}
>
<Icon size={16} />
</button>
))}
</div>
<div
className="mx-auto max-w-4xl px-4 py-6"
style={{ zoom: props.zoomPercent / 100 }}
>
{!entries.length ? (
<button
type="button"
onClick={() => action("child")}
className="w-full rounded border border-dashed border-[#cbd5e1] p-8 text-sm text-[#64748b]"
>
添加中心主题
</button>
) : (
visible.map((entry) => (
<OutlineRow
key={entry.element.id}
entry={entry}
selected={entry.element.id === selectedId}
onSelect={props.onSelect}
onRename={props.onRename}
onToggle={props.onToggle}
onCommand={onCommand}
onFocusTopic={focusTopic}
/>
))
)}
<p className="mt-6 text-xs text-[#a1a8b4]">
Enter 新增同级 · Shift+Enter 新增子主题 · Tab 缩进 · Shift+Tab
提升层级 · Alt+↑↓ 调整顺序
</p>
</div>
</section>
);
}