ai-chatbot/lib/flow-board/mind-outline.ts

184 lines
6.3 KiB
TypeScript

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<string, unknown>;
const topic = data.topic as {
children?: Record<string, unknown>[];
type?: string;
};
return {
...data,
topic: {
...topic,
children: [
{ ...(topic.children?.[0] || {}), text: title.trim() || "未命名主题" },
],
},
};
}