import type { PlaitElement } from "@plait/core"; import { getTopicEntries } from "./topic-details"; export type OutlineCommand = | "child" | "sibling" | "delete" | "indent" | "outdent" | "up" | "down"; export type OutlineDrop = { targetId: string; placement: "before" | "after" | "child"; }; const isSummary = (node: PlaitElement) => Number.isInteger(node.start) && Number.isInteger(node.end); const regular = (node: PlaitElement) => (node.children || []).filter((child) => !isSummary(child)); const newTopic = (root = false): PlaitElement => ({ id: crypto.randomUUID(), type: root ? "mindmap" : "mind_child", data: { topic: { type: "paragraph", children: [{ text: root ? "中心主题" : "新主题" }], }, }, children: [], ...(root ? { isRoot: true, layout: "standard", rightNodeCount: 0, points: [[0, 0]] } : {}), }); /** Work on the model, including collapsed nodes that have no renderer path. */ export function applyOutlineCommand( elements: PlaitElement[], topicId: string | null, command: OutlineCommand | OutlineDrop ): { children: PlaitElement[]; selectedId: string } { const children = structuredClone(elements); if (!topicId) { if (command !== "child" || getTopicEntries(children).length) throw new Error("请先选择一个主题"); const root = newTopic(true); children.push(root); return { children, selectedId: root.id }; } const entries = getTopicEntries(children); const entry = entries.find(({ element }) => element.id === topicId); if (!entry) throw new Error("主题已不存在,请重新选择"); const node = entry.element; const parentOf = (target: PlaitElement) => entries.find(({ element }) => element.children?.includes(target))?.element; const parent = parentOf(node); const ranges = entries .filter(({ element }) => isSummary(element)) .map(({ element }) => { const owner = parentOf(element)!; return { element, owner, ids: regular(owner) .slice(Number(element.start), Number(element.end) + 1) .map((child) => child.id), }; }); const remove = (target: PlaitElement) => { const owner = parentOf(target); const list = owner?.children || children; const index = list.indexOf(target); if ( owner?.layout === "standard" && !isSummary(target) && index < Number(owner.rightNodeCount) ) owner.rightNodeCount = Number(owner.rightNodeCount) - 1; list.splice(index, 1); }; const insert = (owner: PlaitElement, target: PlaitElement, index: number) => { owner.children ||= []; const count = regular(owner).length; const at = Math.min(index, count); owner.children.splice(at, 0, target); if (owner.layout === "standard" && at <= Number(owner.rightNodeCount || 0)) owner.rightNodeCount = Number(owner.rightNodeCount || 0) + 1; owner.isCollapsed = false; }; let selectedId = node.id; if (command === "child" || command === "sibling") { const next = newTopic(); if (command === "child") insert(node, next, regular(node).length); else { if (!parent || isSummary(node)) throw new Error("中心主题和概要请使用新增子主题"); insert(parent, next, parent.children!.indexOf(node) + 1); } selectedId = next.id; } else if (command === "delete") { const siblings = parent ? regular(parent) : children.filter((item) => ["mind", "mindmap"].includes(String(item.type)) ); const index = siblings.indexOf(node); selectedId = siblings[index - 1]?.id || siblings[index + 1]?.id || parent?.id || ""; remove(node); } else { if (!parent || isSummary(node)) throw new Error("中心主题和概要暂不支持此层级调整"); let target: PlaitElement | undefined; let placement: OutlineDrop["placement"] = "child"; const siblings = regular(parent); const index = siblings.indexOf(node); if (typeof command === "object") { target = entries.find( ({ element }) => element.id === command.targetId )?.element; placement = command.placement; } else if (command === "indent") target = siblings[index - 1]; else if (command === "outdent") { target = parent; placement = "after"; } else { target = siblings[index + (command === "up" ? -1 : 1)]; placement = command === "up" ? "before" : "after"; } if (!target) throw new Error("此位置无法继续移动主题"); if (isSummary(target)) throw new Error("请移动到普通主题附近"); if ( getTopicEntries([node]).some(({ element }) => element.id === target!.id) ) throw new Error("不能把主题移入自身或它的子主题"); const destination = placement === "child" ? target : parentOf(target); if (!destination) throw new Error("一级主题不能再提升层级"); remove(node); const at = placement === "child" ? regular(destination).length : destination.children!.indexOf(target) + (placement === "after" ? 1 : 0); insert(destination, node, at); } // Keep summaries attached to their surviving topic range, never unrelated gaps. for (const range of ranges) { if (!range.owner.children?.includes(range.element)) continue; const indices = regular(range.owner).flatMap((child, index) => range.ids.includes(child.id) ? [index] : [] ); if (!indices.length) { range.owner.children = range.owner.children.filter( (child) => child !== range.element ); continue; } const start = indices[0]; const end = indices[indices.length - 1]; const newlyInserted = command === "child" || command === "sibling"; if (!newlyInserted && end - start + 1 !== indices.length) throw new Error("移动会让概要范围断开,请先调整概要"); range.element.start = start; range.element.end = end; } return { children, selectedId }; } export function renameOutlineTopic(element: PlaitElement, title: string) { const data = element.data as Record; const topic = data.topic as { children?: Record[]; type?: string; }; return { ...data, topic: { ...topic, children: [ { ...(topic.children?.[0] || {}), text: title.trim() || "未命名主题" }, ], }, }; }