diff --git a/app/appearance/langs/ar_SA.json b/app/appearance/langs/ar_SA.json
index 945d265c9..b42333e6a 100644
--- a/app/appearance/langs/ar_SA.json
+++ b/app/appearance/langs/ar_SA.json
@@ -977,6 +977,9 @@
"heading4": "عنوان 4",
"heading5": "عنوان 5",
"heading6": "عنوان 6",
+ "outlineExpandLevel": "مستوى التوسيع",
+ "expandAll": "توسيع الكل",
+ "outlineKeepCurrentExpand": "الحفاظ على التوسيع الحالي",
"general": "عام",
"list1": "قائمة",
"element": "عناصر",
diff --git a/app/appearance/langs/de_DE.json b/app/appearance/langs/de_DE.json
index d85518419..f1ce306a6 100644
--- a/app/appearance/langs/de_DE.json
+++ b/app/appearance/langs/de_DE.json
@@ -977,6 +977,9 @@
"heading4": "Überschrift 4",
"heading5": "Überschrift 5",
"heading6": "Überschrift 6",
+ "outlineExpandLevel": "Expansionsebene",
+ "expandAll": "Alle erweitern",
+ "outlineKeepCurrentExpand": "Aktuelle Erweiterung beibehalten",
"general": "Allgemein",
"list1": "Liste",
"element": "Element",
diff --git a/app/appearance/langs/en_US.json b/app/appearance/langs/en_US.json
index d612f7ef9..65f4f9881 100644
--- a/app/appearance/langs/en_US.json
+++ b/app/appearance/langs/en_US.json
@@ -977,6 +977,9 @@
"heading4": "Heading 4",
"heading5": "Heading 5",
"heading6": "Heading 6",
+ "outlineExpandLevel": "Expand level",
+ "expandAll": "Expand all",
+ "outlineKeepCurrentExpand": "Keep current expand",
"general": "General",
"list1": "List",
"element": "Element",
diff --git a/app/appearance/langs/es_ES.json b/app/appearance/langs/es_ES.json
index ba8d7e2d8..8b29c6878 100644
--- a/app/appearance/langs/es_ES.json
+++ b/app/appearance/langs/es_ES.json
@@ -977,6 +977,9 @@
"heading4": "Encabezado 4",
"heading5": "Encabezado 5",
"heading6": "Encabezado 6",
+ "outlineExpandLevel": "Nivel de expansión",
+ "expandAll": "Expandir todo",
+ "outlineKeepCurrentExpand": "Mantener la expansión actual",
"general": "General",
"list1": "Lista",
"element": "elemento",
diff --git a/app/appearance/langs/fr_FR.json b/app/appearance/langs/fr_FR.json
index b5c666a4c..edf05b47d 100644
--- a/app/appearance/langs/fr_FR.json
+++ b/app/appearance/langs/fr_FR.json
@@ -977,6 +977,9 @@
"heading4": "Titre 4",
"heading5": "Titre 5",
"heading6": "Titre 6",
+ "outlineExpandLevel": "Niveau d'expansion",
+ "expandAll": "Tout développer",
+ "outlineKeepCurrentExpand": "Maintenir le titre actuel développé",
"general": "Général",
"list1": "Liste",
"element": "élément",
diff --git a/app/appearance/langs/he_IL.json b/app/appearance/langs/he_IL.json
index 5b0bd9656..491087d2d 100644
--- a/app/appearance/langs/he_IL.json
+++ b/app/appearance/langs/he_IL.json
@@ -977,6 +977,9 @@
"heading4": "כותרת 4",
"heading5": "כותרת 5",
"heading6": "כותרת 6",
+ "outlineExpandLevel": "רמת הרחבה",
+ "expandAll": "הרחב הכל",
+ "outlineKeepCurrentExpand": "שמור כותרת נוכחית מורחבת",
"general": "כללי",
"list1": "רשימה",
"element": "אלמנט",
diff --git a/app/appearance/langs/it_IT.json b/app/appearance/langs/it_IT.json
index 3daac422a..a0cb090b8 100644
--- a/app/appearance/langs/it_IT.json
+++ b/app/appearance/langs/it_IT.json
@@ -977,6 +977,9 @@
"heading4": "Titolo 4",
"heading5": "Titolo 5",
"heading6": "Titolo 6",
+ "outlineExpandLevel": "展開レベル",
+ "expandAll": "すべて展開",
+ "outlineKeepCurrentExpand": "現在の見出しを展開し続ける",
"general": "Generale",
"list1": "Lista",
"element": "elemento",
diff --git a/app/appearance/langs/ja_JP.json b/app/appearance/langs/ja_JP.json
index 169157263..c769e0128 100644
--- a/app/appearance/langs/ja_JP.json
+++ b/app/appearance/langs/ja_JP.json
@@ -977,6 +977,9 @@
"heading4": "見出し4",
"heading5": "見出し5",
"heading6": "見出し6",
+ "outlineExpandLevel": "展開レベル",
+ "expandAll": "すべて展開",
+ "outlineKeepCurrentExpand": "現在の見出しを展開し続ける",
"general": "一般",
"list1": "リスト",
"element": "要素",
diff --git a/app/appearance/langs/pl_PL.json b/app/appearance/langs/pl_PL.json
index 7c4bc6161..d01387837 100644
--- a/app/appearance/langs/pl_PL.json
+++ b/app/appearance/langs/pl_PL.json
@@ -977,6 +977,9 @@
"heading4": "Nagłówek 4",
"heading5": "Nagłówek 5",
"heading6": "Nagłówek 6",
+ "outlineExpandLevel": "Poziom rozwinięcia",
+ "expandAll": "Rozwiń wszystko",
+ "outlineKeepCurrentExpand": "Utrzymuj bieżący tytuł rozwinięty",
"general": "Ogólne",
"list1": "Lista",
"element": "element",
diff --git a/app/appearance/langs/pt_BR.json b/app/appearance/langs/pt_BR.json
index 7bca7b18c..7f2f8f53d 100644
--- a/app/appearance/langs/pt_BR.json
+++ b/app/appearance/langs/pt_BR.json
@@ -977,6 +977,9 @@
"heading4": "Título 4",
"heading5": "Título 5",
"heading6": "Título 6",
+ "outlineExpandLevel": "Nível de expansão",
+ "expandAll": "Expandir tudo",
+ "outlineKeepCurrentExpand": "Manter título atual expandido",
"general": "Geral",
"list1": "Lista",
"element": "Elemento",
diff --git a/app/appearance/langs/ru_RU.json b/app/appearance/langs/ru_RU.json
index 23e09ce5b..d8f8e276a 100644
--- a/app/appearance/langs/ru_RU.json
+++ b/app/appearance/langs/ru_RU.json
@@ -977,6 +977,9 @@
"heading4": "Заголовок 4",
"heading5": "Заголовок 5",
"heading6": "Заголовок 6",
+ "outlineExpandLevel": "Уровень развертывания",
+ "expandAll": "Развернуть все",
+ "outlineKeepCurrentExpand": "Сохранять текущий заголовок развернутым",
"general": "Общее",
"list1": "Список",
"element": "элемент",
diff --git a/app/appearance/langs/zh_CHT.json b/app/appearance/langs/zh_CHT.json
index c70591c72..480240d5a 100644
--- a/app/appearance/langs/zh_CHT.json
+++ b/app/appearance/langs/zh_CHT.json
@@ -977,6 +977,9 @@
"heading4": "四級標題",
"heading5": "五級標題",
"heading6": "六級標題",
+ "outlineExpandLevel": "展開層級",
+ "expandAll": "全部展開",
+ "outlineKeepCurrentExpand": "保持當前標題展開",
"general": "通用",
"list1": "列表",
"element": "元素",
diff --git a/app/appearance/langs/zh_CN.json b/app/appearance/langs/zh_CN.json
index bd90776cb..1e0979367 100644
--- a/app/appearance/langs/zh_CN.json
+++ b/app/appearance/langs/zh_CN.json
@@ -977,6 +977,9 @@
"heading4": "四级标题",
"heading5": "五级标题",
"heading6": "六级标题",
+ "outlineExpandLevel": "展开层级",
+ "expandAll": "全部展开",
+ "outlineKeepCurrentExpand": "保持当前标题展开",
"general": "通用",
"list1": "列表",
"element": "元素",
diff --git a/app/src/layout/dock/Outline.ts b/app/src/layout/dock/Outline.ts
index 999cdb481..7a269dd9e 100644
--- a/app/src/layout/dock/Outline.ts
+++ b/app/src/layout/dock/Outline.ts
@@ -1,22 +1,25 @@
-import {Tab} from "../Tab";
-import {Model} from "../Model";
-import {Tree} from "../../util/Tree";
-import {getInstanceById, setPanelFocus} from "../util";
-import {getDockByType} from "../tabUtil";
-import {fetchPost} from "../../util/fetch";
-import {getAllModels} from "../getAll";
-import {hasClosestBlock, hasClosestByClassName, hasTopClosestByClassName} from "../../protyle/util/hasClosest";
-import {setStorageVal, updateHotkeyAfterTip} from "../../protyle/util/compatibility";
-import {openFileById} from "../../editor/util";
-import {Constants} from "../../constants";
-import {escapeHtml} from "../../util/escape";
-import {unicode2Emoji} from "../../emoji";
-import {getPreviousBlock} from "../../protyle/wysiwyg/getBlock";
-import {App} from "../../index";
-import {checkFold} from "../../util/noRelyPCFunction";
-import {transaction} from "../../protyle/wysiwyg/transaction";
-import {goHome} from "../../protyle/wysiwyg/commonHotkey";
-import {Editor} from "../../editor";
+import { Tab } from "../Tab";
+import { Model } from "../Model";
+import { Tree } from "../../util/Tree";
+import { getInstanceById, setPanelFocus } from "../util";
+import { getDockByType } from "../tabUtil";
+import { fetchPost } from "../../util/fetch";
+import { getAllModels } from "../getAll";
+import { hasClosestBlock, hasClosestByClassName, hasTopClosestByClassName } from "../../protyle/util/hasClosest";
+import { setStorageVal, updateHotkeyAfterTip } from "../../protyle/util/compatibility";
+import { openFileById } from "../../editor/util";
+import { Constants } from "../../constants";
+import { MenuItem } from "../../menus/Menu";
+import { escapeHtml } from "../../util/escape";
+import { unicode2Emoji } from "../../emoji";
+import { getPreviousBlock } from "../../protyle/wysiwyg/getBlock";
+import { App } from "../../index";
+import { checkFold } from "../../util/noRelyPCFunction";
+import { transaction, turnsIntoTransaction } from "../../protyle/wysiwyg/transaction";
+import { goHome } from "../../protyle/wysiwyg/commonHotkey";
+import { Editor } from "../../editor";
+import { writeText, isInAndroid, isInHarmony } from "../../protyle/util/compatibility";
+import { mathRender } from "../../protyle/render/mathRender";
export class Outline extends Model {
public tree: Tree;
@@ -25,7 +28,10 @@ export class Outline extends Model {
public type: "pin" | "local";
public blockId: string;
public isPreview: boolean;
- private openNodes: { [key: string]: string[] } = {};
+ // 筛选相关
+ private searchInput: HTMLInputElement;
+ private searchKeyword = "";
+ private preFilterExpandIds: string[] | null = null;
constructor(options: {
app: App,
@@ -39,7 +45,7 @@ export class Outline extends Model {
id: options.tab.id,
callback() {
if (this.type === "local") {
- fetchPost("/api/block/checkBlockExist", {id: this.blockId}, existResponse => {
+ fetchPost("/api/block/checkBlockExist", { id: this.blockId }, existResponse => {
if (!existResponse.data) {
this.parent.parent.removeTab(this.parent.id);
}
@@ -64,7 +70,7 @@ export class Outline extends Model {
break;
case "unmount":
if (this.type === "local") {
- fetchPost("/api/block/checkBlockExist", {id: this.blockId}, existResponse => {
+ fetchPost("/api/block/checkBlockExist", { id: this.blockId }, existResponse => {
if (!existResponse.data) {
this.parent.parent.removeTab(this.parent.id);
}
@@ -89,10 +95,21 @@ export class Outline extends Model {
${window.siyuan.languages.outline}
-
+
+
+
+
+
+
+
+
+
+
+
+
@@ -101,10 +118,49 @@ export class Outline extends Model {
`;
- this.element = options.tab.panelElement.lastElementChild as HTMLElement;
+ this.element = options.tab.panelElement.children[2] as HTMLElement; // 更新为第三个子元素(大纲内容)
this.headerElement = options.tab.panelElement.firstElementChild as HTMLElement;
+ // 绑定筛选输入框交互,参考 Backlink.ts
+ this.searchInput = this.headerElement.querySelector("input.b3-text-field.search__label") as HTMLInputElement;
+ if (this.searchInput) {
+ this.searchInput.addEventListener("blur", (event: KeyboardEvent) => {
+ const inputElement = event.target as HTMLInputElement;
+ inputElement.classList.add("fn__none");
+ const filterIconElement = inputElement.nextElementSibling as HTMLElement; // search 图标
+ const val = inputElement.value.trim();
+ if (val) {
+ filterIconElement.classList.add("block__icon--active");
+ filterIconElement.setAttribute("aria-label", window.siyuan.languages.filter + " " + val);
+ } else {
+ filterIconElement.classList.remove("block__icon--active");
+ filterIconElement.setAttribute("aria-label", window.siyuan.languages.filter);
+ // 若之前有筛选,且清空,则恢复
+ if (this.searchKeyword) {
+ this.clearFilter();
+ }
+ }
+ });
+ this.searchInput.addEventListener("keydown", (event: KeyboardEvent) => {
+ if (!event.isComposing && event.key === "Enter") {
+ const kw = this.searchInput.value.trim();
+ if (kw) {
+ this.applyFilter(kw);
+ } else {
+ this.clearFilter();
+ }
+ }
+ });
+ this.searchInput.addEventListener("input", (event: KeyboardEvent) => {
+ const inputElement = event.target as HTMLInputElement;
+ if (inputElement.value === "") {
+ inputElement.classList.remove("search__input--block");
+ } else {
+ inputElement.classList.add("search__input--block");
+ }
+ });
+ }
this.tree = new Tree({
- element: options.tab.panelElement.lastElementChild as HTMLElement,
+ element: options.tab.panelElement.children[2] as HTMLElement, // 使用第三个子元素作为树容器
data: null,
click: (element: HTMLElement) => {
const id = element.getAttribute("data-node-id");
@@ -142,28 +198,80 @@ export class Outline extends Model {
action: [Constants.CB_GET_FOCUS, Constants.CB_GET_ALL, Constants.CB_GET_HTML],
zoomIn: true,
});
+ },
+ altClick: (element: HTMLElement, event?: MouseEvent) => {
+ // 检查是否点击的是标题层级图标
+ if (event && event.target) {
+ const target = event.target as HTMLElement;
+ const graphicElement = target.closest(".b3-list-item__graphic.popover__block");
+ if (graphicElement) {
+ this.collapseSameLevel(element);
+ }
+ }
+ },
+ rightClick: (element: HTMLElement, event: MouseEvent) => {
+ // 右键菜单
+ event.preventDefault();
+ event.stopPropagation();
+ this.showContextMenu(element, event);
+ },
+ onToggleChange: () => {
+ // 实时保存折叠状态变化
+ if (!this.isPreview) {
+ const expandIds = this.tree.getExpandIds();
+ fetchPost("/api/storage/setOutlineStorage", {
+ docID: this.blockId,
+ val: {
+ expandIds: expandIds
+ }
+ });
+ }
}
});
// 为了快捷键的 dispatch
options.tab.panelElement.querySelector('[data-type="collapse"]').addEventListener("click", () => {
this.tree.collapseAll();
});
- options.tab.panelElement.querySelector('[data-type="expand"]').addEventListener("click", (event: MouseEvent & {
+
+ // 普通的全部展开按钮
+ options.tab.panelElement.querySelector('[data-type="expand"]').addEventListener("click", () => {
+ this.tree.expandAll();
+ // 保存展开状态
+ if (!this.isPreview) {
+ fetchPost("/api/storage/setOutlineStorage", {
+ docID: this.blockId,
+ val: {
+ expandIds: this.tree.getExpandIds()
+ }
+ });
+ }
+ });
+
+ // 保持当前标题展开功能
+ options.tab.panelElement.querySelector('[data-type="keepCurrentExpand"]').addEventListener("click", (event: MouseEvent & {
target: Element
}) => {
const iconElement = hasClosestByClassName(event.target, "block__icon");
if (!iconElement) {
return;
}
- if (iconElement.classList.contains("block__icon--active")) {
- iconElement.classList.remove("block__icon--active");
- window.siyuan.storage[Constants.LOCAL_OUTLINE].keepExpand = false;
- } else {
- iconElement.classList.add("block__icon--active");
- window.siyuan.storage[Constants.LOCAL_OUTLINE].keepExpand = true;
- this.tree.expandAll();
+
+ // 确保存储对象存在
+ if (!window.siyuan.storage[Constants.LOCAL_OUTLINE]) {
+ window.siyuan.storage[Constants.LOCAL_OUTLINE] = {};
}
+ if (iconElement.classList.contains("block__icon--active")) {
+ iconElement.classList.remove("block__icon--active");
+ window.siyuan.storage[Constants.LOCAL_OUTLINE].keepCurrentExpand = false;
+ } else {
+ iconElement.classList.add("block__icon--active");
+ window.siyuan.storage[Constants.LOCAL_OUTLINE].keepCurrentExpand = true;
+ // 立即展开到真正的当前标题
+ this.expandToCurrentHeading();
+ }
+
+ // 保存keepCurrentExpand状态到localStorage
setStorageVal(Constants.LOCAL_OUTLINE, window.siyuan.storage[Constants.LOCAL_OUTLINE]);
});
options.tab.panelElement.addEventListener("click", (event: MouseEvent & { target: HTMLElement }) => {
@@ -176,6 +284,18 @@ export class Outline extends Model {
case "min":
getDockByType("outline").toggleModel("outline", false, true);
break;
+ case "search":
+ // 显示输入框并选中
+ if (this.searchInput) {
+ this.searchInput.classList.remove("fn__none");
+ this.searchInput.select();
+ }
+ break;
+ case "expandLevel":
+ this.showExpandLevelMenu(event);
+ event.preventDefault();
+ event.stopPropagation();
+ break;
}
break;
} else if (this.blockId && (target === this.headerElement.nextElementSibling || target.classList.contains("block__icons"))) {
@@ -212,9 +332,432 @@ export class Outline extends Model {
preview: this.isPreview
}, response => {
this.update(response);
+ // 初始化时从新的存储恢复折叠状态
+ if (!this.isPreview) {
+ fetchPost("/api/storage/getOutlineStorage", {
+ docID: this.blockId
+ }, storageResponse => {
+ const storageData = storageResponse.data;
+ if (storageData && storageData.expandIds) {
+ this.tree.setExpandIds(storageData.expandIds);
+ }
+ // 若存在筛选关键词,初始化后应用一次筛选
+ if (this.searchKeyword) {
+ this.applyFilter(this.searchKeyword);
+ }
+ });
+ }
});
}
+ /**
+ * 切换同层级的所有标题的展开/折叠状态(基于标题级别而不是DOM层级)
+ * @param element 当前点击的元素
+ */
+ private collapseSameLevel(element: HTMLElement) {
+ if (!element) {
+ return;
+ }
+
+ // 获取当前元素的标题级别
+ const currentHeadingLevel = this.getHeadingLevel(element);
+ if (currentHeadingLevel === 0) {
+ return; // 如果不是有效的标题,直接返回
+ }
+
+ // 获取所有相同标题级别的元素
+ const allListItems = this.element.querySelectorAll("li.b3-list-item");
+ const sameLevelElements: HTMLElement[] = [];
+
+ allListItems.forEach(item => {
+ const headingLevel = this.getHeadingLevel(item as HTMLElement);
+ if (headingLevel === currentHeadingLevel) {
+ sameLevelElements.push(item as HTMLElement);
+ }
+ });
+
+ // 过滤出有子元素的项
+ const elementsWithChildren = sameLevelElements.filter(item =>
+ item.nextElementSibling && item.nextElementSibling.tagName === "UL"
+ );
+
+ if (elementsWithChildren.length === 0) {
+ return;
+ }
+
+ // 检查当前状态:如果大部分元素是展开的,则执行折叠;否则执行展开
+ let expandedCount = 0;
+ elementsWithChildren.forEach(item => {
+ const arrowElement = item.querySelector(".b3-list-item__arrow");
+ if (arrowElement && arrowElement.classList.contains("b3-list-item__arrow--open")) {
+ expandedCount++;
+ }
+ });
+
+ // 如果超过一半的元素是展开的,则折叠所有;否则展开所有
+ const shouldCollapse = expandedCount > elementsWithChildren.length / 2;
+
+ elementsWithChildren.forEach(item => {
+ const arrowElement = item.querySelector(".b3-list-item__arrow");
+
+ if (shouldCollapse) {
+ // 折叠
+ if (arrowElement && arrowElement.classList.contains("b3-list-item__arrow--open")) {
+ arrowElement.classList.remove("b3-list-item__arrow--open");
+ item.nextElementSibling.classList.add("fn__none");
+ }
+ } else {
+ // 展开
+ if (arrowElement && !arrowElement.classList.contains("b3-list-item__arrow--open")) {
+ arrowElement.classList.add("b3-list-item__arrow--open");
+ item.nextElementSibling.classList.remove("fn__none");
+ }
+ }
+ });
+
+ // 触发折叠状态变化事件,保存状态
+ if (this.tree.onToggleChange) {
+ this.tree.onToggleChange();
+ }
+ }
+
+ /**
+ * 获取元素在大纲中的层级深度
+ * @param element li元素
+ * @returns 层级深度(从0开始)
+ */
+ private getElementLevel(element: HTMLElement): number {
+ let level = 0;
+ let parent = element.parentElement;
+
+ while (parent && !parent.classList.contains("fn__flex-1")) {
+ if (parent.tagName === "UL" && !parent.classList.contains("b3-list")) {
+ level++;
+ }
+ parent = parent.parentElement;
+ }
+
+ return level;
+ }
+
+ /**
+ * 获取所有同层级的元素
+ * @param level 目标层级
+ * @returns 同层级的li元素数组
+ */
+ private getSameLevelElements(level: number): HTMLElement[] {
+ const allListItems = this.element.querySelectorAll("li.b3-list-item");
+ const sameLevelElements: HTMLElement[] = [];
+
+ allListItems.forEach(item => {
+ if (this.getElementLevel(item as HTMLElement) === level) {
+ sameLevelElements.push(item as HTMLElement);
+ }
+ });
+
+ return sameLevelElements;
+ }
+
+ /**
+ * 展开到当前标题路径
+ */
+ private expandToCurrentHeading() {
+ // 获取当前真正的标题ID
+ this.getCurrentHeadingId((currentHeadingId) => {
+ if (currentHeadingId) {
+ this.expandToHeadingByIdSmart(currentHeadingId);
+ }
+ });
+ }
+
+ /**
+ * 智能展开到指定标题ID,保持兄弟分支的原有状态
+ */
+ private expandToHeadingByIdSmart(headingId: string) {
+ // 确保目标标题在大纲中可见
+ this.ensureHeadingVisibleSmart(headingId);
+
+ // 设置为当前焦点(这会触发自动展开)
+ this.setCurrentById(headingId);
+ }
+
+ /**
+ * 智能确保指定标题在大纲中可见(展开其所有父级路径,但保持兄弟分支状态)
+ */
+ private ensureHeadingVisibleSmart(headingId: string) {
+ const targetElement = this.element.querySelector(`.b3-list-item[data-node-id="${headingId}"]`) as HTMLElement;
+ if (targetElement) {
+ this.expandPathToElement(targetElement);
+
+ // 额外检查:确保目标元素真的可见
+ setTimeout(() => {
+ const checkElement = this.element.querySelector(`.b3-list-item[data-node-id="${headingId}"]`) as HTMLElement;
+ if (checkElement && checkElement.offsetParent === null) {
+ // 如果元素仍然不可见,再次尝试展开路径
+ this.expandPathToElement(checkElement);
+ }
+ }, 50);
+ }
+ }
+
+ /**
+ * 获取当前真正的标题ID
+ */
+ private getCurrentHeadingId(callback: (id: string) => void) {
+ // 首先尝试从编辑器获取当前光标位置的块
+ let currentBlockId: string = null;
+
+ getAllModels().editor.find(editItem => {
+ if (editItem.editor.protyle.block.rootID === this.blockId) {
+ const selection = getSelection();
+ if (selection.rangeCount > 0) {
+ const blockElement = hasClosestBlock(selection.getRangeAt(0).startContainer);
+ if (blockElement) {
+ currentBlockId = blockElement.getAttribute("data-node-id");
+ return true;
+ }
+ }
+ }
+ });
+
+ if (currentBlockId) {
+ // 如果当前块就是标题,直接使用
+ const currentBlockElement = document.querySelector(`[data-node-id="${currentBlockId}"]`);
+ if (currentBlockElement && currentBlockElement.getAttribute("data-type") === "NodeHeading") {
+ callback(currentBlockId);
+ return;
+ }
+
+ // 如果当前块不是标题,查找前面最近的标题
+ let previousElement = getPreviousBlock(currentBlockElement as HTMLElement);
+ while (previousElement) {
+ if (previousElement.getAttribute("data-type") === "NodeHeading") {
+ callback(previousElement.getAttribute("data-node-id"));
+ return;
+ }
+ previousElement = getPreviousBlock(previousElement);
+ }
+
+ // 如果没有找到前面的标题,通过API获取面包屑
+ fetchPost("/api/block/getBlockBreadcrumb", {
+ id: currentBlockId,
+ excludeTypes: []
+ }, (response) => {
+ const headingItem = response.data.reverse().find((item: IBreadcrumb) => {
+ return item.type === "NodeHeading";
+ });
+ if (headingItem) {
+ callback(headingItem.id);
+ }
+ });
+ } else {
+ // 如果无法获取当前块,使用现有的focus元素
+ const currentElement = this.element.querySelector(".b3-list-item--focus");
+ if (currentElement) {
+ callback(currentElement.getAttribute("data-node-id"));
+ }
+ }
+ }
+
+ /**
+ * 展开到指定标题ID
+ */
+ private expandToHeadingById(headingId: string) {
+ // 确保目标标题在大纲中可见
+ this.ensureHeadingVisible(headingId);
+
+ // 设置为当前焦点(这会触发自动展开)
+ this.setCurrentById(headingId);
+ }
+
+ /**
+ * 确保指定标题在大纲中可见(展开其所有父级路径)
+ */
+ private ensureHeadingVisible(headingId: string) {
+ const targetElement = this.element.querySelector(`.b3-list-item[data-node-id="${headingId}"]`) as HTMLElement;
+ if (targetElement) {
+ this.expandPathToElement(targetElement);
+ }
+ }
+
+ /**
+ * 展开到指定元素的路径,智能处理兄弟分支的折叠状态
+ */
+ private expandPathToElement(element: HTMLElement) {
+ if (!element) {
+ return;
+ }
+
+ // 收集所有需要展开的ul元素路径,以及它们的折叠状态
+ const pathToExpand: Array<{ ul: HTMLElement, wasCollapsed: boolean, parentLi: HTMLElement }> = [];
+ let current = element.parentElement; // 从父级ul开始
+
+ while (current && !current.classList.contains("fn__flex-1")) {
+ if (current.tagName === "UL" && !current.classList.contains("b3-list")) {
+ // 这是一个可折叠的ul元素
+ const parentLi = current.previousElementSibling as HTMLElement;
+ const wasCollapsed = current.classList.contains("fn__none");
+
+ pathToExpand.push({
+ ul: current,
+ wasCollapsed: wasCollapsed,
+ parentLi: parentLi
+ });
+ }
+ current = current.parentElement;
+ }
+
+ // 从最外层开始展开,确保每一层都能正确展开
+ pathToExpand.reverse().forEach((pathItem, index) => {
+ const { ul, wasCollapsed, parentLi } = pathItem;
+
+ if (ul.classList.contains("fn__none")) {
+ ul.classList.remove("fn__none");
+
+ // 设置箭头状态
+ if (parentLi && parentLi.classList.contains("b3-list-item")) {
+ const arrowElement = parentLi.querySelector(".b3-list-item__arrow");
+ if (arrowElement && !arrowElement.classList.contains("b3-list-item__arrow--open")) {
+ arrowElement.classList.add("b3-list-item__arrow--open");
+ }
+ }
+ }
+
+ // 如果这个节点原本是折叠的,需要折叠其他分支
+ if (wasCollapsed && parentLi) {
+ this.collapseSiblingsExceptPath(parentLi, pathToExpand.slice(index + 1));
+ }
+ });
+
+ // 保存展开状态
+ if (!this.isPreview) {
+ fetchPost("/api/storage/setOutlineStorage", {
+ docID: this.blockId,
+ val: {
+ expandIds: this.tree.getExpandIds()
+ }
+ });
+ }
+ }
+
+ /**
+ * 折叠指定li下的所有兄弟分支,除了通往目标的路径
+ * @param parentLi 父级li元素
+ * @param targetPath 目标路径上的ul元素列表
+ */
+ private collapseSiblingsExceptPath(parentLi: HTMLElement, targetPath: Array<{ ul: HTMLElement, wasCollapsed: boolean, parentLi: HTMLElement }>) {
+ // 获取父li下的直接ul子元素
+ const directChildUl = parentLi.nextElementSibling;
+ if (!directChildUl || directChildUl.tagName !== "UL") {
+ return;
+ }
+
+ // 获取目标路径上的下一个ul(如果存在)
+ const nextTargetUl = targetPath.length > 0 ? targetPath[0].ul : null;
+
+ // 遍历所有直接子li元素
+ const childLiElements = directChildUl.children;
+ for (let i = 0; i < childLiElements.length; i++) {
+ const childLi = childLiElements[i] as HTMLElement;
+ if (!childLi.classList.contains("b3-list-item")) {
+ continue;
+ }
+
+ // 获取这个li的子ul
+ const childUl = childLi.nextElementSibling;
+ if (childUl && childUl.tagName === "UL") {
+ // 如果这个ul不是目标路径上的ul,则折叠它
+ if (childUl !== nextTargetUl) {
+ if (!childUl.classList.contains("fn__none")) {
+ childUl.classList.add("fn__none");
+
+ // 更新箭头状态
+ const arrowElement = childLi.querySelector(".b3-list-item__arrow");
+ if (arrowElement && arrowElement.classList.contains("b3-list-item__arrow--open")) {
+ arrowElement.classList.remove("b3-list-item__arrow--open");
+ }
+ }
+ }
+ }
+ }
+ }
+
+ /**
+ * 获取标题元素的实际标题级别(H1=1, H2=2, 等等)
+ * @param element li元素
+ * @returns 标题级别(1-6)
+ */
+ private getHeadingLevel(element: HTMLElement): number {
+ const subtype = element.getAttribute("data-subtype");
+ if (!subtype) {
+ return 0;
+ }
+
+ // 从data-subtype属性中提取标题级别(h1=1, h2=2, h3=3, 等等)
+ const match = subtype.match(/^h(\d+)$/);
+ if (match) {
+ return parseInt(match[1], 10);
+ }
+
+ return 0;
+ }
+
+ /**
+ * 展开到指定标题级别
+ * @param targetLevel 目标标题级别,1-6级(H1-H6),6级表示全部展开
+ */
+ private expandToLevel(targetLevel: number) {
+ if (targetLevel >= 6) {
+ // 全部展开
+ this.tree.expandAll();
+ } else {
+ // 展开到指定标题级别
+ const allListItems = this.element.querySelectorAll("li.b3-list-item");
+
+ allListItems.forEach(item => {
+ const headingLevel = this.getHeadingLevel(item as HTMLElement);
+ const arrowElement = item.querySelector(".b3-list-item__arrow");
+
+ if (item.nextElementSibling && item.nextElementSibling.tagName === "UL" && arrowElement) {
+ if (headingLevel > 0 && headingLevel < targetLevel) {
+ // 当前标题级别小于目标级别,展开
+ arrowElement.classList.add("b3-list-item__arrow--open");
+ item.nextElementSibling.classList.remove("fn__none");
+ } else if (headingLevel >= targetLevel) {
+ // 当前标题级别大于等于目标级别,折叠
+ arrowElement.classList.remove("b3-list-item__arrow--open");
+ item.nextElementSibling.classList.add("fn__none");
+ }
+ }
+ });
+ }
+
+ // 保存状态
+ if (this.tree.onToggleChange) {
+ this.tree.onToggleChange();
+ }
+ }
+
+ /**
+ * 显示展开层级菜单
+ */
+ private showExpandLevelMenu(event: MouseEvent) {
+ window.siyuan.menus.menu.remove();
+ for (let i = 1; i <= 6; i++) {
+ window.siyuan.menus.menu.append(new MenuItem({
+ icon: `iconH${i}`,
+ label: window.siyuan.languages[`heading${i}`],
+ click: () => this.expandToLevel(i)
+ }).element);
+ }
+ window.siyuan.menus.menu.popup({
+ x: event.clientX - 11,
+ y: event.clientY + 11,
+ w: 12
+ });
+ return window.siyuan.menus.menu;
+ }
+
private bindSort() {
this.element.addEventListener("mousedown", (event: MouseEvent) => {
const item = hasClosestByClassName(event.target as HTMLElement, "b3-list-item");
@@ -332,6 +875,10 @@ export class Outline extends Model {
}
if (hasChange) {
this.element.setAttribute("data-loading", "true");
+
+ // 保存拖拽前的折叠状态
+ const expandIdsBeforeDrag = this.tree.getExpandIds();
+
transaction(editor, [{
action: "moveOutlineHeading",
id: item.dataset.nodeId,
@@ -343,6 +890,17 @@ export class Outline extends Model {
previousID: undoPreviousID,
parentID: undoParentID,
}]);
+
+ // 拖拽操作完成后恢复折叠状态
+ setTimeout(() => {
+ fetchPost("/api/storage/setOutlineStorage", {
+ docID: this.blockId,
+ val: {
+ expandIds: expandIdsBeforeDrag
+ }
+ });
+ }, 300);
+
// https://github.com/siyuan-note/siyuan/issues/10828#issuecomment-2044099675
editor.wysiwyg.element.querySelectorAll('[data-type="NodeHeading"] [contenteditable="true"][spellcheck]').forEach(item => {
item.setAttribute("contenteditable", "false");
@@ -479,15 +1037,46 @@ export class Outline extends Model {
this.element.querySelectorAll(".b3-list-item.b3-list-item--focus").forEach(item => {
item.classList.remove("b3-list-item--focus");
});
+
+ // 如果启用了保持当前标题展开功能,先确保目标标题可见
+ if (window.siyuan.storage[Constants.LOCAL_OUTLINE]?.keepCurrentExpand) {
+ this.ensureHeadingVisibleSmart(id);
+ }
+
let currentElement = this.element.querySelector(`.b3-list-item[data-node-id="${id}"]`) as HTMLElement;
- while (currentElement && currentElement.clientHeight === 0) {
- currentElement = currentElement.parentElement.previousElementSibling as HTMLElement;
- }
- if (currentElement) {
- currentElement.classList.add("b3-list-item--focus");
- const elementRect = this.element.getBoundingClientRect();
- this.element.scrollTop = this.element.scrollTop + (currentElement.getBoundingClientRect().top - (elementRect.top + elementRect.height / 2));
- }
+
+ // 如果元素仍然不可见,尝试多次查找和展开
+ let retryCount = 0;
+ const maxRetries = 3;
+
+ const trySetCurrent = () => {
+ currentElement = this.element.querySelector(`.b3-list-item[data-node-id="${id}"]`) as HTMLElement;
+
+ while (currentElement && currentElement.clientHeight === 0 && retryCount < maxRetries) {
+ // 如果启用了保持当前标题展开功能,再次尝试展开路径
+ if (window.siyuan.storage[Constants.LOCAL_OUTLINE]?.keepCurrentExpand) {
+ this.expandPathToElement(currentElement);
+ }
+ currentElement = currentElement.parentElement?.previousElementSibling as HTMLElement;
+ retryCount++;
+ }
+
+ if (currentElement) {
+ currentElement.classList.add("b3-list-item--focus");
+
+ const elementRect = this.element.getBoundingClientRect();
+ this.element.scrollTop = this.element.scrollTop + (currentElement.getBoundingClientRect().top - (elementRect.top + elementRect.height / 2));
+ } else if (retryCount < maxRetries && window.siyuan.storage[Constants.LOCAL_OUTLINE]?.keepCurrentExpand) {
+ // 如果还没找到元素且启用了展开功能,延迟重试
+ setTimeout(() => {
+ retryCount++;
+ this.ensureHeadingVisibleSmart(id);
+ setTimeout(trySetCurrent, 50);
+ }, 50);
+ }
+ };
+
+ trySetCurrent();
}
public update(data: IWebSocketData, callbackId?: string) {
@@ -497,21 +1086,47 @@ export class Outline extends Model {
currentId = currentElement.getAttribute("data-node-id");
}
- if (!this.isPreview && this.openNodes[this.blockId]) {
- this.openNodes[this.blockId] = this.tree.getExpandIds();
+ // 保存当前文档的折叠状态到新的持久化存储
+ if (!this.isPreview) {
+ const currentExpandIds = this.tree.getExpandIds();
+ fetchPost("/api/storage/setOutlineStorage", {
+ docID: this.blockId,
+ val: {
+ expandIds: currentExpandIds
+ }
+ });
}
+
if (typeof callbackId !== "undefined") {
this.blockId = callbackId;
}
this.tree.updateData(data.data);
- if (!this.isPreview && this.openNodes[this.blockId] && !this.headerElement.querySelector('[data-type="expand"]').classList.contains("block__icon--active")) {
- this.tree.setExpandIds(this.openNodes[this.blockId]);
- } else {
- this.tree.expandAll();
- if (!this.isPreview) {
- this.openNodes[this.blockId] = this.tree.getExpandIds();
- }
+
+ // 从新的持久化存储恢复折叠状态
+ if (!this.isPreview) {
+ fetchPost("/api/storage/getOutlineStorage", {
+ docID: this.blockId
+ }, storageResponse => {
+ const storageData = storageResponse.data;
+ if (storageData && storageData.expandIds) {
+ this.tree.setExpandIds(storageData.expandIds);
+ } else {
+ this.tree.expandAll();
+ // 保存展开全部的状态到新的存储
+ fetchPost("/api/storage/setOutlineStorage", {
+ docID: this.blockId,
+ val: {
+ expandIds: this.tree.getExpandIds()
+ }
+ });
+ }
+ // 若当前存在筛选词,更新后重新应用筛选
+ if (this.searchKeyword) {
+ this.applyFilter(this.searchKeyword);
+ }
+ });
}
+
if (this.isPreview) {
this.tree.element.querySelectorAll(".popover__block").forEach(item => {
item.classList.remove("popover__block");
@@ -526,4 +1141,780 @@ export class Outline extends Model {
}
this.element.removeAttribute("data-loading");
}
+
+ /**
+ * 应用大纲筛选
+ */
+ private applyFilter(keyword: string) {
+ const kw = keyword.trim();
+ if (!kw) {
+ this.clearFilter();
+ return;
+ }
+ this.searchKeyword = kw;
+ // 首次筛选时记录折叠状态
+ if (!this.preFilterExpandIds) {
+ try {
+ this.preFilterExpandIds = this.tree.getExpandIds();
+ } catch (e) {
+ this.preFilterExpandIds = [];
+ }
+ }
+
+ // 递归过滤 DOM
+ const rootUL = this.element.querySelector("ul.b3-list");
+ if (!rootUL) return;
+
+ const kwLower = kw.toLowerCase();
+ const matchedItems = new Set(); // 记录所有命中的标题
+
+ // 第一遍:收集所有命中的项目
+ const collectMatches = (ul: Element) => {
+ ul.querySelectorAll("li.b3-list-item").forEach(li => {
+ const textEl = (li as HTMLElement).querySelector(".b3-list-item__text") as HTMLElement;
+ const textContent = (textEl?.textContent || "").trim().toLowerCase();
+ if (textContent.includes(kwLower)) {
+ matchedItems.add(li as HTMLElement);
+ }
+ });
+ };
+ collectMatches(rootUL);
+
+ // 展开所有命中项的父级路径
+ matchedItems.forEach(matchedLi => {
+ this.expandPathToElement(matchedLi);
+ });
+
+ const processUL = (ul: Element): { hasMatch: boolean, hasChildMatch: boolean } => {
+ let hasMatch = false;
+ let hasChildMatch = false;
+ const children = ul.querySelectorAll(":scope > li.b3-list-item");
+
+ children.forEach((li) => {
+ const textEl = (li as HTMLElement).querySelector(".b3-list-item__text") as HTMLElement;
+ const textContent = (textEl?.textContent || "").trim().toLowerCase();
+ const selfMatch = textContent.includes(kwLower);
+ const next = (li as HTMLElement).nextElementSibling;
+
+ let childResult = { hasMatch: false, hasChildMatch: false };
+ if (next && next.tagName === "UL") {
+ childResult = processUL(next);
+ }
+
+ if (selfMatch) {
+ // 当前标题命中
+ (li as HTMLElement).style.display = "";
+ hasMatch = true;
+
+ if (next && next.tagName === "UL") {
+ (next as HTMLElement).style.display = "";
+
+ if (childResult.hasMatch || childResult.hasChildMatch) {
+ // 子项也有命中,保持展开状态,但隐藏未命中的子项由子级处理
+ const arrow = li.querySelector(".b3-list-item__arrow");
+ if (arrow) {
+ arrow.classList.add("b3-list-item__arrow--open");
+ }
+ } else {
+ // 子项无命中,折叠所有子项但保持可展开
+ const arrow = li.querySelector(".b3-list-item__arrow");
+ if (arrow) {
+ arrow.classList.remove("b3-list-item__arrow--open");
+ }
+ // 折叠但不完全隐藏,保持子项可访问性
+ next.classList.add("fn__none");
+ // 确保所有子项内容保持可见状态,用户可以手动展开查看
+ next.querySelectorAll("li.b3-list-item").forEach(childLi => {
+ (childLi as HTMLElement).style.display = "";
+ });
+ next.querySelectorAll("ul").forEach(childUl => {
+ (childUl as HTMLElement).style.display = "";
+ // 移除子ul的fn__none,保证嵌套结构的可访问性
+ childUl.classList.remove("fn__none");
+ });
+ }
+ }
+ } else if (childResult.hasMatch || childResult.hasChildMatch) {
+ // 当前标题未命中,但子级有命中
+ (li as HTMLElement).style.display = "";
+ hasChildMatch = true;
+
+ if (next && next.tagName === "UL") {
+ (next as HTMLElement).style.display = "";
+ // 展开以显示命中的子项
+ const arrow = li.querySelector(".b3-list-item__arrow");
+ if (arrow) {
+ arrow.classList.add("b3-list-item__arrow--open");
+ }
+ }
+ } else {
+ // 当前标题和子级都未命中,隐藏
+ (li as HTMLElement).style.display = "none";
+ if (next && next.tagName === "UL") {
+ (next as HTMLElement).style.display = "none";
+ }
+ }
+ });
+
+ return { hasMatch, hasChildMatch };
+ };
+
+ processUL(rootUL);
+ } /**
+ * 清除大纲筛选并恢复展开状态
+ */
+ private clearFilter() {
+ this.searchKeyword = "";
+ // 还原 display
+ this.element.querySelectorAll("li.b3-list-item, .fn__flex-1 ul").forEach((el) => {
+ (el as HTMLElement).style.display = "";
+ });
+ // 恢复折叠状态
+ if (this.preFilterExpandIds) {
+ this.tree.setExpandIds(this.preFilterExpandIds);
+ }
+ this.preFilterExpandIds = null;
+ // 复位图标状态
+ const filterIconElement = this.headerElement.querySelector('[data-type="search"]') as HTMLElement;
+ if (filterIconElement) {
+ filterIconElement.classList.remove("block__icon--active");
+ filterIconElement.setAttribute("aria-label", window.siyuan.languages.filter);
+ }
+ }
+
+ /**
+ * 显示右键菜单
+ */
+ private showContextMenu(element: HTMLElement, event: MouseEvent) {
+ if (this.isPreview) {
+ return; // 预览模式下不显示右键菜单
+ }
+
+ const id = element.getAttribute("data-node-id");
+ const subtype = element.getAttribute("data-subtype");
+ if (!id || !subtype) {
+ return;
+ }
+
+ const currentLevel = this.getHeadingLevel(element);
+
+ window.siyuan.menus.menu.remove();
+
+ // 升级
+ if (currentLevel > 1) {
+ window.siyuan.menus.menu.append(new MenuItem({
+ icon: "iconUp",
+ label: "升级",
+ click: () => this.upgradeHeading(element)
+ }).element);
+ }
+
+ // 降级
+ if (currentLevel < 6) {
+ window.siyuan.menus.menu.append(new MenuItem({
+ icon: "iconDown",
+ label: "降级",
+ click: () => this.downgradeHeading(element)
+ }).element);
+ }
+
+ // 带子标题转换
+ const headingSubMenu = [];
+ if (currentLevel !== 1) {
+ headingSubMenu.push(this.genHeadingTransform(id, 1));
+ }
+ if (currentLevel !== 2) {
+ headingSubMenu.push(this.genHeadingTransform(id, 2));
+ }
+ if (currentLevel !== 3) {
+ headingSubMenu.push(this.genHeadingTransform(id, 3));
+ }
+ if (currentLevel !== 4) {
+ headingSubMenu.push(this.genHeadingTransform(id, 4));
+ }
+ if (currentLevel !== 5) {
+ headingSubMenu.push(this.genHeadingTransform(id, 5));
+ }
+ if (currentLevel !== 6) {
+ headingSubMenu.push(this.genHeadingTransform(id, 6));
+ }
+
+ if (headingSubMenu.length > 0) {
+ window.siyuan.menus.menu.append(new MenuItem({
+ id: "tWithSubtitle",
+ type: "submenu",
+ icon: "iconRefresh",
+ label: window.siyuan.languages.tWithSubtitle,
+ submenu: headingSubMenu
+ }).element);
+ }
+
+ window.siyuan.menus.menu.append(new MenuItem({type: "separator"}).element);
+
+ // 在前面插入同级标题
+ window.siyuan.menus.menu.append(new MenuItem({
+ icon: "iconBefore",
+ label: "在前面插入同级标题",
+ click: () => this.insertHeadingBefore(element)
+ }).element);
+
+ // 在后面插入同级标题
+ window.siyuan.menus.menu.append(new MenuItem({
+ icon: "iconAfter",
+ label: "在后面插入同级标题",
+ click: () => this.insertHeadingAfter(element)
+ }).element);
+
+ // 添加子标题
+ if (currentLevel < 6) { // 只有当前级别小于6时才能添加子标题
+ window.siyuan.menus.menu.append(new MenuItem({
+ icon: "iconAdd",
+ label: "添加子标题",
+ click: () => this.addChildHeading(element)
+ }).element);
+ }
+
+ window.siyuan.menus.menu.append(new MenuItem({ type: "separator" }).element);
+
+
+ // 复制带子标题
+ window.siyuan.menus.menu.append(new MenuItem({
+ icon: "iconCopy",
+ label: `${window.siyuan.languages.copy} ${window.siyuan.languages.headings1}`,
+ click: () => this.copyHeadingWithChildren(element)
+ }).element);
+
+ // 剪切带子标题
+ window.siyuan.menus.menu.append(new MenuItem({
+ icon: "iconCut",
+ label: `${window.siyuan.languages.cut} ${window.siyuan.languages.headings1}`,
+ click: () => this.cutHeadingWithChildren(element)
+ }).element);
+
+
+ // 删除
+ window.siyuan.menus.menu.append(new MenuItem({
+ icon: "iconTrashcan",
+ label: `${window.siyuan.languages.delete} ${window.siyuan.languages.headings1}`,
+ click: () => this.deleteHeading(element)
+ }).element);
+
+ window.siyuan.menus.menu.append(new MenuItem({type: "separator"}).element);
+
+ // 展开子标题
+ window.siyuan.menus.menu.append(new MenuItem({
+ icon: "iconExpand",
+ label: "展开子标题",
+ click: () => this.expandChildren(element)
+ }).element);
+
+ // 折叠子标题
+ window.siyuan.menus.menu.append(new MenuItem({
+ icon: "iconContract",
+ label: "折叠子标题",
+ click: () => this.collapseChildren(element)
+ }).element);
+
+ // 展开同级标题
+ window.siyuan.menus.menu.append(new MenuItem({
+ icon: "iconExpand",
+ label: "展开同级标题",
+ click: () => this.expandSameLevel(element)
+ }).element);
+
+ // 折叠同级标题
+ window.siyuan.menus.menu.append(new MenuItem({
+ icon: "iconContract",
+ label: "折叠同级标题",
+ click: () => this.collapseSameLevel(element)
+ }).element);
+
+ // 全部展开
+ window.siyuan.menus.menu.append(new MenuItem({
+ icon: "iconExpand",
+ label: "全部展开",
+ click: () => {
+ this.tree.expandAll();
+ if (!this.isPreview) {
+ fetchPost("/api/storage/setOutlineStorage", {
+ docID: this.blockId,
+ val: {
+ expandIds: this.tree.getExpandIds()
+ }
+ });
+ }
+ }
+ }).element);
+
+ // 全部折叠
+ window.siyuan.menus.menu.append(new MenuItem({
+ icon: "iconContract",
+ label: "全部折叠",
+ click: () => this.tree.collapseAll()
+ }).element);
+
+ window.siyuan.menus.menu.popup({
+ x: event.clientX,
+ y: event.clientY
+ });
+ }
+
+ /**
+ * 升级标题
+ */
+ private upgradeHeading(element: HTMLElement) {
+ const id = element.getAttribute("data-node-id");
+ const currentLevel = this.getHeadingLevel(element);
+
+ if (currentLevel <= 1) {
+ return;
+ }
+
+ // 找到编辑器实例和文档中的标题元素
+ let editor: any;
+ let blockElement: HTMLElement;
+ getAllModels().editor.find(editItem => {
+ if (editItem.editor.protyle.block.rootID === this.blockId) {
+ editor = editItem.editor.protyle;
+ blockElement = editor.wysiwyg.element.querySelector(`[data-node-id="${id}"]`);
+ return true;
+ }
+ });
+
+ if (!editor || !blockElement) {
+ return;
+ }
+
+ // 使用turnsIntoTransaction来变更标题级别
+ turnsIntoTransaction({
+ protyle: editor,
+ selectsElement: [blockElement],
+ type: "Blocks2Hs",
+ level: currentLevel - 1
+ });
+ }
+
+ /**
+ * 降级标题
+ */
+ private downgradeHeading(element: HTMLElement) {
+ const id = element.getAttribute("data-node-id");
+ const currentLevel = this.getHeadingLevel(element);
+
+ if (currentLevel >= 6) {
+ return;
+ }
+
+ // 找到编辑器实例和文档中的标题元素
+ let editor: any;
+ let blockElement: HTMLElement;
+ getAllModels().editor.find(editItem => {
+ if (editItem.editor.protyle.block.rootID === this.blockId) {
+ editor = editItem.editor.protyle;
+ blockElement = editor.wysiwyg.element.querySelector(`[data-node-id="${id}"]`);
+ return true;
+ }
+ });
+
+ if (!editor || !blockElement) {
+ return;
+ }
+
+ // 使用turnsIntoTransaction来变更标题级别
+ turnsIntoTransaction({
+ protyle: editor,
+ selectsElement: [blockElement],
+ type: "Blocks2Hs",
+ level: currentLevel + 1
+ });
+ }
+
+ /**
+ * 在前面插入同级标题
+ */
+ private insertHeadingBefore(element: HTMLElement) {
+ const id = element.getAttribute("data-node-id");
+ const currentLevel = this.getHeadingLevel(element);
+ const headingPrefix = "#".repeat(currentLevel) + " ";
+
+ fetchPost("/api/block/insertBlock", {
+ data: headingPrefix,
+ dataType: "markdown",
+ nextID: id
+ }, (response) => {
+ if (response.code === 0) {
+ // 插入成功后,可以选择聚焦到新插入的标题
+ const newId = response.data[0].doOperations[0].id;
+ openFileById({
+ app: this.app,
+ id: newId,
+ action: [Constants.CB_GET_FOCUS, Constants.CB_GET_OUTLINE]
+ });
+ }
+ });
+ }
+
+ /**
+ * 在后面插入同级标题
+ */
+ private insertHeadingAfter(element: HTMLElement) {
+ const currentLevel = this.getHeadingLevel(element);
+ const headingPrefix = "#".repeat(currentLevel) + " ";
+
+ // 获取父节点ID,如果当前标题是顶级标题,使用文档根ID
+ const parentElement = element.parentElement;
+ let parentID = this.blockId; // 默认为文档根ID
+
+ if (parentElement && parentElement.tagName === "UL") {
+ const parentLi = parentElement.previousElementSibling;
+ if (parentLi && parentLi.classList.contains("b3-list-item")) {
+ parentID = parentLi.getAttribute("data-node-id");
+ }
+ }
+
+ fetchPost("/api/block/appendBlock", {
+ data: headingPrefix,
+ dataType: "markdown",
+ parentID: parentID
+ }, (response) => {
+ if (response.code === 0) {
+ // 插入成功后,可以选择聚焦到新插入的标题
+ const newId = response.data[0].doOperations[0].id;
+ openFileById({
+ app: this.app,
+ id: newId,
+ action: [Constants.CB_GET_FOCUS, Constants.CB_GET_OUTLINE]
+ });
+ }
+ });
+ }
+
+ /**
+ * 删除标题
+ */
+ private deleteHeading(element: HTMLElement) {
+ const id = element.getAttribute("data-node-id");
+
+ // 找到编辑器实例
+ let editor: any;
+ getAllModels().editor.find(editItem => {
+ if (editItem.editor.protyle.block.rootID === this.blockId) {
+ editor = editItem.editor.protyle;
+ return true;
+ }
+ });
+
+ if (!editor) {
+ return;
+ }
+
+ fetchPost("/api/block/getHeadingDeleteTransaction", {
+ id: id,
+ }, (response) => {
+ response.data.doOperations.forEach((operation: any) => {
+ editor.wysiwyg.element.querySelectorAll(`[data-node-id="${operation.id}"]`).forEach((itemElement: HTMLElement) => {
+ itemElement.remove();
+ });
+ });
+ transaction(editor, response.data.doOperations, response.data.undoOperations);
+ });
+ }
+
+ /**
+ * 展开子标题
+ */
+ private expandChildren(element: HTMLElement) {
+ const nextElement = element.nextElementSibling;
+ if (nextElement && nextElement.tagName === "UL") {
+ const arrowElement = element.querySelector(".b3-list-item__arrow");
+ if (arrowElement) {
+ arrowElement.classList.add("b3-list-item__arrow--open");
+ nextElement.classList.remove("fn__none");
+
+ // 递归展开所有子元素
+ const expandAllChildren = (ul: Element) => {
+ ul.querySelectorAll(":scope > li.b3-list-item").forEach(li => {
+ const childUl = li.nextElementSibling;
+ if (childUl && childUl.tagName === "UL") {
+ const childArrow = li.querySelector(".b3-list-item__arrow");
+ if (childArrow) {
+ childArrow.classList.add("b3-list-item__arrow--open");
+ childUl.classList.remove("fn__none");
+ expandAllChildren(childUl);
+ }
+ }
+ });
+ };
+ expandAllChildren(nextElement);
+
+ // 保存展开状态
+ if (this.tree.onToggleChange) {
+ this.tree.onToggleChange();
+ }
+ }
+ }
+ }
+
+ /**
+ * 折叠子标题
+ */
+ private collapseChildren(element: HTMLElement) {
+ const nextElement = element.nextElementSibling;
+ if (nextElement && nextElement.tagName === "UL") {
+ const arrowElement = element.querySelector(".b3-list-item__arrow");
+ if (arrowElement) {
+ arrowElement.classList.remove("b3-list-item__arrow--open");
+ nextElement.classList.add("fn__none");
+
+ // 保存折叠状态
+ if (this.tree.onToggleChange) {
+ this.tree.onToggleChange();
+ }
+ }
+ }
+ }
+
+ /**
+ * 展开同级标题 - 基于标题级别而不是DOM层级
+ */
+ private expandSameLevel(element: HTMLElement) {
+ const currentHeadingLevel = this.getHeadingLevel(element);
+ if (currentHeadingLevel === 0) {
+ return; // 如果不是有效的标题,直接返回
+ }
+
+ // 获取所有相同标题级别的元素
+ const allListItems = this.element.querySelectorAll("li.b3-list-item");
+ const sameLevelElements: HTMLElement[] = [];
+
+ allListItems.forEach(item => {
+ const headingLevel = this.getHeadingLevel(item as HTMLElement);
+ if (headingLevel === currentHeadingLevel) {
+ sameLevelElements.push(item as HTMLElement);
+ }
+ });
+
+ // 检查当前状态:如果大部分同级标题是展开的,则折叠;否则展开
+ let expandedCount = 0;
+ const elementsWithChildren = sameLevelElements.filter(item => {
+ const nextElement = item.nextElementSibling;
+ return nextElement && nextElement.tagName === "UL";
+ });
+
+ elementsWithChildren.forEach(item => {
+ const arrowElement = item.querySelector(".b3-list-item__arrow");
+ if (arrowElement && arrowElement.classList.contains("b3-list-item__arrow--open")) {
+ expandedCount++;
+ }
+ });
+
+ // 如果超过一半的元素是展开的,则折叠所有;否则展开所有
+ const shouldExpand = expandedCount <= elementsWithChildren.length / 2;
+
+ elementsWithChildren.forEach(item => {
+ const nextElement = item.nextElementSibling;
+ const arrowElement = item.querySelector(".b3-list-item__arrow");
+
+ if (arrowElement && nextElement && nextElement.tagName === "UL") {
+ if (shouldExpand) {
+ // 展开
+ if (!arrowElement.classList.contains("b3-list-item__arrow--open")) {
+ arrowElement.classList.add("b3-list-item__arrow--open");
+ nextElement.classList.remove("fn__none");
+ }
+ } else {
+ // 折叠
+ if (arrowElement.classList.contains("b3-list-item__arrow--open")) {
+ arrowElement.classList.remove("b3-list-item__arrow--open");
+ nextElement.classList.add("fn__none");
+ }
+ }
+ }
+ });
+
+ // 保存展开状态
+ if (this.tree.onToggleChange) {
+ this.tree.onToggleChange();
+ }
+ }
+
+ /**
+ * 复制标题带子标题
+ */
+ private copyHeadingWithChildren(element: HTMLElement) {
+ const id = element.getAttribute("data-node-id");
+ if (!id) {
+ return;
+ }
+
+ // 找到编辑器实例
+ let editor: any;
+ getAllModels().editor.find(editItem => {
+ if (editItem.editor.protyle.block.rootID === this.blockId) {
+ editor = editItem.editor.protyle;
+ return true;
+ }
+ });
+
+ if (!editor) {
+ return;
+ }
+
+ fetchPost("/api/block/getHeadingChildrenDOM", {
+ id: id,
+ removeFoldAttr: false
+ }, (response) => {
+ if (isInAndroid()) {
+ window.JSAndroid.writeHTMLClipboard(editor.lute.BlockDOM2StdMd(response.data).trimEnd(), response.data + Constants.ZWSP);
+ } else if (isInHarmony()) {
+ window.JSHarmony.writeHTMLClipboard(editor.lute.BlockDOM2StdMd(response.data).trimEnd(), response.data + Constants.ZWSP);
+ } else {
+ writeText(response.data + Constants.ZWSP);
+ }
+ });
+ }
+
+ /**
+ * 剪切标题带子标题
+ */
+ private cutHeadingWithChildren(element: HTMLElement) {
+ const id = element.getAttribute("data-node-id");
+ if (!id) {
+ return;
+ }
+
+ // 找到编辑器实例
+ let editor: any;
+ getAllModels().editor.find(editItem => {
+ if (editItem.editor.protyle.block.rootID === this.blockId) {
+ editor = editItem.editor.protyle;
+ return true;
+ }
+ });
+
+ if (!editor) {
+ return;
+ }
+
+ fetchPost("/api/block/getHeadingChildrenDOM", {
+ id: id,
+ removeFoldAttr: false
+ }, (response) => {
+ if (isInAndroid()) {
+ window.JSAndroid.writeHTMLClipboard(editor.lute.BlockDOM2StdMd(response.data).trimEnd(), response.data + Constants.ZWSP);
+ } else if (isInHarmony()) {
+ window.JSHarmony.writeHTMLClipboard(editor.lute.BlockDOM2StdMd(response.data).trimEnd(), response.data + Constants.ZWSP);
+ } else {
+ writeText(response.data + Constants.ZWSP);
+ }
+
+ // 复制完成后删除标题及其子标题
+ fetchPost("/api/block/getHeadingDeleteTransaction", {
+ id: id,
+ }, (response) => {
+ response.data.doOperations.forEach((operation: any) => {
+ editor.wysiwyg.element.querySelectorAll(`[data-node-id="${operation.id}"]`).forEach((itemElement: HTMLElement) => {
+ itemElement.remove();
+ });
+ });
+ transaction(editor, response.data.doOperations, response.data.undoOperations);
+ });
+ });
+ }
+
+ /**
+ * 生成标题级别转换菜单项
+ */
+ private genHeadingTransform(id: string, level: number) {
+ return {
+ id: "heading" + level,
+ iconHTML: "",
+ icon: "iconHeading" + level,
+ label: window.siyuan.languages["heading" + level],
+ click: () => {
+ // 找到编辑器实例
+ let editor: any;
+ getAllModels().editor.find(editItem => {
+ if (editItem.editor.protyle.block.rootID === this.blockId) {
+ editor = editItem.editor.protyle;
+ return true;
+ }
+ });
+
+ if (!editor) {
+ return;
+ }
+
+ fetchPost("/api/block/getHeadingLevelTransaction", {
+ id,
+ level
+ }, (response) => {
+ response.data.doOperations.forEach((operation: any, index: number) => {
+ editor.wysiwyg.element.querySelectorAll(`[data-node-id="${operation.id}"]`).forEach((itemElement: HTMLElement) => {
+ itemElement.outerHTML = operation.data;
+ });
+ // 使用 outer 后元素需要重新查询
+ editor.wysiwyg.element.querySelectorAll(`[data-node-id="${operation.id}"]`).forEach((itemElement: HTMLElement) => {
+ mathRender(itemElement);
+ });
+ if (index === 0) {
+ const focusElement = editor.wysiwyg.element.querySelector(`[data-node-id="${operation.id}"]`);
+ if (focusElement) {
+ focusElement.scrollIntoView({behavior: "smooth", block: "center"});
+ }
+ }
+ });
+ transaction(editor, response.data.doOperations, response.data.undoOperations);
+ });
+ }
+ };
+ }
+
+ /**
+ * 添加子标题
+ */
+ private addChildHeading(element: HTMLElement) {
+ const id = element.getAttribute("data-node-id");
+ if (!id) {
+ return;
+ }
+
+ const currentLevel = this.getHeadingLevel(element);
+ const childLevel = Math.min(currentLevel + 1, 6); // 子标题级别比当前标题高一级,最大到H6
+ const headingPrefix = "#".repeat(childLevel) + " ";
+
+ // 使用当前标题作为父标题,在其内部添加子标题
+ fetchPost("/api/block/appendBlock", {
+ data: headingPrefix,
+ dataType: "markdown",
+ parentID: id
+ }, (response) => {
+ if (response.code === 0 && response.data && response.data.length > 0) {
+ // 确保父标题保持展开状态 - 使用expandIds方式
+ const currentExpandIds = this.tree.getExpandIds();
+ if (!currentExpandIds.includes(id)) {
+ currentExpandIds.push(id);
+ this.tree.setExpandIds(currentExpandIds);
+
+ // 保存展开状态到持久化存储
+ if (!this.isPreview) {
+ fetchPost("/api/storage/setOutlineStorage", {
+ docID: this.blockId,
+ val: {
+ expandIds: currentExpandIds
+ }
+ });
+ }
+ }
+
+ // 插入成功后,聚焦到新插入的标题
+ const newId = response.data[0].doOperations[0].id;
+ openFileById({
+ app: this.app,
+ id: newId,
+ action: [Constants.CB_GET_FOCUS, Constants.CB_GET_OUTLINE]
+ });
+ }
+ });
+ }
}
diff --git a/app/src/protyle/util/compatibility.ts b/app/src/protyle/util/compatibility.ts
index a13ea1d4d..9e227c5cc 100644
--- a/app/src/protyle/util/compatibility.ts
+++ b/app/src/protyle/util/compatibility.ts
@@ -388,7 +388,7 @@ export const getLocalStorage = (cb: () => void) => {
defaultStorage[Constants.LOCAL_AI] = []; // {name: "", memo: ""}
defaultStorage[Constants.LOCAL_PLUGIN_DOCKS] = {}; // { pluginName: {dockId: IPluginDockTab}}
defaultStorage[Constants.LOCAL_PLUGINTOPUNPIN] = [];
- defaultStorage[Constants.LOCAL_OUTLINE] = {keepExpand: true};
+ defaultStorage[Constants.LOCAL_OUTLINE] = {keepExpand: true, expand: {}};
defaultStorage[Constants.LOCAL_FILEPOSITION] = {}; // {id: IScrollAttr}
defaultStorage[Constants.LOCAL_DIALOGPOSITION] = {}; // {id: IPosition}
defaultStorage[Constants.LOCAL_HISTORY] = {
diff --git a/app/src/util/Tree.ts b/app/src/util/Tree.ts
index 6c2715ed0..07bea0cb4 100644
--- a/app/src/util/Tree.ts
+++ b/app/src/util/Tree.ts
@@ -16,8 +16,9 @@ export class Tree {
private ctrlClick: (element: HTMLElement) => void;
private toggleClick: (element: Element) => void;
private shiftClick: (element: HTMLElement) => void;
- private altClick: (element: HTMLElement) => void;
+ private altClick: (element: HTMLElement, event?: MouseEvent) => void;
private rightClick: (element: HTMLElement, event: MouseEvent) => void;
+ public onToggleChange: () => void;
constructor(options: {
element: HTMLElement,
@@ -26,10 +27,11 @@ export class Tree {
topExtHTML?: string,
click?(element: HTMLElement, event: MouseEvent): void
ctrlClick?(element: HTMLElement): void
- altClick?(element: HTMLElement): void
+ altClick?(element: HTMLElement, event?: MouseEvent): void
shiftClick?(element: HTMLElement): void
toggleClick?(element: HTMLElement): void
rightClick?(element: HTMLElement, event: MouseEvent): void
+ onToggleChange?: () => void
}) {
this.click = options.click;
this.ctrlClick = options.ctrlClick;
@@ -37,6 +39,7 @@ export class Tree {
this.shiftClick = options.shiftClick;
this.rightClick = options.rightClick;
this.toggleClick = options.toggleClick;
+ this.onToggleChange = options.onToggleChange;
this.element = options.element;
this.blockExtHTML = options.blockExtHTML;
this.topExtHTML = options.topExtHTML;
@@ -204,7 +207,18 @@ data-def-path="${item.defPath}">
this.element.addEventListener("contextmenu", (event) => {
let target = event.target as HTMLElement;
while (target && !target.isEqualNode(this.element)) {
- if (target.tagName === "LI" && this.rightClick) {
+ if (target.classList.contains("b3-list-item__toggle") && !target.classList.contains("fn__hidden")) {
+ // 右键点击toggle时,展开所有子标题
+ this.expandAllChildren(target.parentElement);
+ this.setCurrent(target.parentElement);
+ // 触发折叠状态变化事件
+ if (this.onToggleChange) {
+ this.onToggleChange();
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ break;
+ } else if (target.tagName === "LI" && this.rightClick) {
this.rightClick(target, event);
event.preventDefault();
event.stopPropagation();
@@ -219,6 +233,10 @@ data-def-path="${item.defPath}">
if (target.classList.contains("b3-list-item__toggle") && !target.classList.contains("fn__hidden")) {
this.toggleBlocks(target.parentElement);
this.setCurrent(target.parentElement);
+ // 触发折叠状态变化事件
+ if (this.onToggleChange) {
+ this.onToggleChange();
+ }
event.preventDefault();
break;
}
@@ -237,7 +255,7 @@ data-def-path="${item.defPath}">
if (this.ctrlClick && window.siyuan.ctrlIsPressed) {
this.ctrlClick(target);
} else if (this.altClick && window.siyuan.altIsPressed) {
- this.altClick(target);
+ this.altClick(target, event);
} else if (this.shiftClick && window.siyuan.shiftIsPressed) {
this.shiftClick(target);
} else if (this.click) {
@@ -271,6 +289,86 @@ data-def-path="${item.defPath}">
});
}
+ public expandAllChildren(liElement: Element) {
+ if (!liElement || !liElement.nextElementSibling) {
+ return;
+ }
+
+ // 获取当前项的子列表
+ const nextElement = liElement.nextElementSibling;
+ if (!nextElement || nextElement.tagName !== "UL") {
+ return;
+ }
+
+ // 检查子元素的展开状态,如果所有子元素都已展开,则折叠;否则展开所有
+ const areAllChildrenExpanded = this.areAllChildrenExpanded(nextElement);
+
+ // 确保当前元素保持展开状态
+ const svgElement = liElement.firstElementChild.firstElementChild;
+ if (!svgElement.classList.contains("b3-list-item__arrow--open")) {
+ svgElement.classList.add("b3-list-item__arrow--open");
+ nextElement.classList.remove("fn__none");
+ if (nextElement.nextElementSibling && nextElement.nextElementSibling.tagName === "UL") {
+ nextElement.nextElementSibling.classList.remove("fn__none");
+ }
+ }
+
+ if (areAllChildrenExpanded) {
+ // 折叠所有子元素,但保持当前元素展开
+ this.collapseAllChildren(nextElement);
+ } else {
+ // 展开所有子元素
+ this.expandAllChildrenRecursive(nextElement);
+ }
+ }
+
+ private areAllChildrenExpanded(ulElement: Element): boolean {
+ const childItems = ulElement.querySelectorAll(":scope > li");
+ for (const childLi of childItems) {
+ const arrow = childLi.querySelector(".b3-list-item__arrow");
+ if (arrow && !arrow.classList.contains("b3-list-item__arrow--open")) {
+ return false;
+ }
+ const childUl = childLi.nextElementSibling;
+ if (childUl && childUl.tagName === "UL") {
+ if (!this.areAllChildrenExpanded(childUl)) {
+ return false;
+ }
+ }
+ }
+ return true;
+ }
+
+ private expandAllChildrenRecursive(ulElement: Element) {
+ ulElement.classList.remove("fn__none");
+ const childItems = ulElement.querySelectorAll(":scope > li");
+ childItems.forEach(childLi => {
+ const arrow = childLi.querySelector(".b3-list-item__arrow");
+ if (arrow) {
+ arrow.classList.add("b3-list-item__arrow--open");
+ }
+ const childUl = childLi.nextElementSibling;
+ if (childUl && childUl.tagName === "UL") {
+ this.expandAllChildrenRecursive(childUl);
+ }
+ });
+ }
+
+ private collapseAllChildren(ulElement: Element) {
+ const childItems = ulElement.querySelectorAll(":scope > li");
+ childItems.forEach(childLi => {
+ const arrow = childLi.querySelector(".b3-list-item__arrow");
+ if (arrow) {
+ arrow.classList.remove("b3-list-item__arrow--open");
+ }
+ const childUl = childLi.nextElementSibling;
+ if (childUl && childUl.tagName === "UL") {
+ childUl.classList.add("fn__none");
+ this.collapseAllChildren(childUl);
+ }
+ });
+ }
+
public expandAll() {
this.element.querySelectorAll("ul").forEach(item => {
if (!item.classList.contains("b3-list")) {
diff --git a/kernel/api/router.go b/kernel/api/router.go
index 7a40058a4..45f1088ac 100644
--- a/kernel/api/router.go
+++ b/kernel/api/router.go
@@ -78,6 +78,9 @@ func ServeAPI(ginServer *gin.Engine) {
ginServer.Handle("POST", "/api/storage/getCriteria", model.CheckAuth, getCriteria)
ginServer.Handle("POST", "/api/storage/removeCriterion", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, removeCriterion)
ginServer.Handle("POST", "/api/storage/getRecentDocs", model.CheckAuth, getRecentDocs)
+ ginServer.Handle("POST", "/api/storage/getOutlineStorage", model.CheckAuth, getOutlineStorage)
+ ginServer.Handle("POST", "/api/storage/setOutlineStorage", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, setOutlineStorage)
+ ginServer.Handle("POST", "/api/storage/removeOutlineStorage", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, removeOutlineStorage)
ginServer.Handle("POST", "/api/account/login", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, login)
ginServer.Handle("POST", "/api/account/checkActivationcode", model.CheckAuth, model.CheckAdminRole, model.CheckReadonly, checkActivationcode)
diff --git a/kernel/api/storage.go b/kernel/api/storage.go
index 9f26e4ebc..370103466 100644
--- a/kernel/api/storage.go
+++ b/kernel/api/storage.go
@@ -180,3 +180,59 @@ func getLocalStorage(c *gin.Context) {
data := model.GetLocalStorage()
ret.Data = data
}
+
+func getOutlineStorage(c *gin.Context) {
+ ret := gulu.Ret.NewResult()
+ defer c.JSON(http.StatusOK, ret)
+
+ arg, ok := util.JsonArg(c, ret)
+ if !ok {
+ return
+ }
+
+ docID := arg["docID"].(string)
+ data, err := model.GetOutlineStorage(docID)
+ if err != nil {
+ ret.Code = -1
+ ret.Msg = err.Error()
+ return
+ }
+ ret.Data = data
+}
+
+func setOutlineStorage(c *gin.Context) {
+ ret := gulu.Ret.NewResult()
+ defer c.JSON(http.StatusOK, ret)
+
+ arg, ok := util.JsonArg(c, ret)
+ if !ok {
+ return
+ }
+
+ docID := arg["docID"].(string)
+ val := arg["val"].(interface{})
+ err := model.SetOutlineStorage(docID, val)
+ if err != nil {
+ ret.Code = -1
+ ret.Msg = err.Error()
+ return
+ }
+}
+
+func removeOutlineStorage(c *gin.Context) {
+ ret := gulu.Ret.NewResult()
+ defer c.JSON(http.StatusOK, ret)
+
+ arg, ok := util.JsonArg(c, ret)
+ if !ok {
+ return
+ }
+
+ docID := arg["docID"].(string)
+ err := model.RemoveOutlineStorage(docID)
+ if err != nil {
+ ret.Code = -1
+ ret.Msg = err.Error()
+ return
+ }
+}
diff --git a/kernel/model/storage.go b/kernel/model/storage.go
index 33cac1ca1..377e93361 100644
--- a/kernel/model/storage.go
+++ b/kernel/model/storage.go
@@ -37,6 +37,11 @@ type RecentDoc struct {
Title string `json:"title"`
}
+type OutlineDoc struct {
+ DocID string `json:"docID"`
+ Data map[string]interface{} `json:"data"`
+}
+
var recentDocLock = sync.Mutex{}
func RemoveRecentDoc(ids []string) {
@@ -402,3 +407,124 @@ func getLocalStorage() (ret map[string]interface{}) {
}
return
}
+
+var outlineStorageLock = sync.Mutex{}
+
+func GetOutlineStorage(docID string) (ret map[string]interface{}, err error) {
+ outlineStorageLock.Lock()
+ defer outlineStorageLock.Unlock()
+
+ ret = map[string]interface{}{}
+ outlineDocs, err := getOutlineDocs()
+ if err != nil {
+ return
+ }
+
+ for _, doc := range outlineDocs {
+ if doc.DocID == docID {
+ ret = doc.Data
+ break
+ }
+ }
+ return
+}
+
+func SetOutlineStorage(docID string, val interface{}) (err error) {
+ outlineStorageLock.Lock()
+ defer outlineStorageLock.Unlock()
+
+ outlineDoc := &OutlineDoc{
+ DocID: docID,
+ Data: make(map[string]interface{}),
+ }
+
+ if valMap, ok := val.(map[string]interface{}); ok {
+ outlineDoc.Data = valMap
+ }
+
+ outlineDocs, err := getOutlineDocs()
+ if err != nil {
+ return
+ }
+
+ // 如果文档已存在,先移除旧的
+ for i, doc := range outlineDocs {
+ if doc.DocID == docID {
+ outlineDocs = append(outlineDocs[:i], outlineDocs[i+1:]...)
+ break
+ }
+ }
+
+ // 将新的文档信息添加到最前面
+ outlineDocs = append([]*OutlineDoc{outlineDoc}, outlineDocs...)
+
+ // 限制为2000个文档
+ if 2000 < len(outlineDocs) {
+ outlineDocs = outlineDocs[:2000]
+ }
+
+ err = setOutlineDocs(outlineDocs)
+ return
+}
+
+func RemoveOutlineStorage(docID string) (err error) {
+ outlineStorageLock.Lock()
+ defer outlineStorageLock.Unlock()
+
+ outlineDocs, err := getOutlineDocs()
+ if err != nil {
+ return
+ }
+
+ for i, doc := range outlineDocs {
+ if doc.DocID == docID {
+ outlineDocs = append(outlineDocs[:i], outlineDocs[i+1:]...)
+ break
+ }
+ }
+
+ err = setOutlineDocs(outlineDocs)
+ return
+}
+
+func setOutlineDocs(outlineDocs []*OutlineDoc) (err error) {
+ dirPath := filepath.Join(util.DataDir, "storage")
+ if err = os.MkdirAll(dirPath, 0755); err != nil {
+ logging.LogErrorf("create storage [outline] dir failed: %s", err)
+ return
+ }
+
+ data, err := gulu.JSON.MarshalJSON(outlineDocs)
+ if err != nil {
+ logging.LogErrorf("marshal storage [outline] failed: %s", err)
+ return
+ }
+
+ lsPath := filepath.Join(dirPath, "outline.json")
+ err = filelock.WriteFile(lsPath, data)
+ if err != nil {
+ logging.LogErrorf("write storage [outline] failed: %s", err)
+ return
+ }
+ return
+}
+
+func getOutlineDocs() (ret []*OutlineDoc, err error) {
+ ret = []*OutlineDoc{}
+ dataPath := filepath.Join(util.DataDir, "storage/outline.json")
+ if !filelock.IsExist(dataPath) {
+ return
+ }
+
+ data, err := filelock.ReadFile(dataPath)
+ if err != nil {
+ logging.LogErrorf("read storage [outline] failed: %s", err)
+ return
+ }
+
+ if err = gulu.JSON.UnmarshalJSON(data, &ret); err != nil {
+ logging.LogErrorf("unmarshal storage [outline] failed: %s", err)
+ return
+ }
+ return
+}