184 lines
6.3 KiB
TypeScript
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() || "未命名主题" },
|
|
],
|
|
},
|
|
};
|
|
}
|