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 +}