311 lines
10 KiB
TypeScript
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>
|
|
);
|
|
}
|