"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(null); const ref = useRef(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 (
{ 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); }} > { event.dataTransfer.setData("application/x-mind-topic", element.id); event.dataTransfer.effectAllowed = "move"; }} > {element.children?.length ? ( ) : ( · )} 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) ? ( 概要 ) : null} {details.notes ? ( 备注 ) : null} {details.labels.map((label) => ( {label} ))}
); } export function XmindOutline(props: Props) { const { elements, selectedId, onCommand } = props; const container = useRef(null); const [focusId, setFocusId] = useState(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( "[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 (
{ event.stopPropagation(); if ( (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z" ) { event.preventDefault(); if (event.shiftKey) props.onRedo(); else props.onUndo(); } }} >

大纲

{entries.length} 个主题 {( [ { 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 }) => ( ))}
{!entries.length ? ( ) : ( visible.map((entry) => ( )) )}

Enter 新增同级 · Shift+Enter 新增子主题 · Tab 缩进 · Shift+Tab 提升层级 · Alt+↑↓ 调整顺序

); }