代码片段分享:文档树展开状态保存

文档树自动展开_release_v1.0

写在前面(重要!重要!重要!)

  1. 抄的大佬的代码: https://ld246.com/article/1721771759337
  2. 我只简单测了一下, 肯定有 bug, 欢迎反馈 pc 端的 bug
  3. 其他设备如果有问题, 请自行排查(我没需求), 手动狗头
  4. 平时没啥时间, bug 和开发中功能, 有时间再搞
  5. 帖子不会更新了, 后续有更新会放到 回帖 里, 请随意食用
  6. 实现原理比较简单粗暴, 如果文档比较多, 会有性能问题, 如果是性能问题, 请自行优化(我没需求)

当前功能

  1. 记录文档树的展开状态, 下次打开或刷新时自动展开上次已展开的目录
  2. [重点] 保存子节点的展开状态, 下次展开当前节点, 会自动展开上次展开的子节点
  3. 自动定位当前文件所在的目录
  4. 移除 文档树的新建笔记按钮
  5. 移除 文档树和大纲的最小化按钮

效果

存储展开状态.gif

开发中

  1. 目前, 新建笔记本无法使用, 需要手动刷新页面
  2. 监听 "全部折叠按钮"
  3. 在全部折叠旁边: 增加全部展开按钮
  4. 在文档节点的按钮中, 增加全部展开按钮
  5. 增加支持 单击文档节点 展开的功能

实现原理:

  1. 记录已展开目录的 data-path, 笔记以 data-url 区分,以 object 嵌套的方式记录展开目录所在的层级关系
  2. 加载时,会根据记录的目录层级关系,依次展开
  3. 点击展开按钮,先从 BACK 里面查找是否有这个节点,如果有,就移动到 CFG 里面,然后展开
  4. 点击折叠按钮,将 CFG 的节点 移动到 BACK 里面

代码

/*******************************简介********************************************
# 文档树自动展开_release_v1.0

## 写在前面(重要!重要!重要!)

1. 抄的大佬的代码: https://ld246.com/article/1721771759337
2. 我只简单测了一下, 肯定有bug, 欢迎反馈 pc端的bug
3. 其他设备如果有问题, 请自行排查(我没需求), 手动狗头
4. 平时没啥时间, bug和开发中功能, 有时间再搞
5. 帖子不会更新了, 后续有更新会放到 回帖 里, 请随意食用
6. 实现原理比较简单粗暴, 如果文档比较多, 会有性能问题, 如果是性能问题, 请自行优化(我没需求)

## 当前功能

1. 记录文档树的展开状态, 下次打开或刷新时自动展开上次已展开的目录
2. [重点] 保存子节点的展开状态, 下次展开当前节点, 会自动展开上次展开的子节点
3. 自动定位当前文件所在的目录
4. 移除 文档树的新建笔记按钮
5. 移除 文档树和大纲的最小化按钮

## 开发中

1. 目前, 新建笔记本无法使用, 需要手动刷新页面
2. 监听 "全部折叠按钮"
3. 在全部折叠旁边: 增加全部展开按钮
4. 在文档节点的按钮中, 增加全部展开按钮
5. 增加支持 单击文档节点 展开的功能

## 实现原理:

1. 记录已展开目录的data-path, 笔记以data-url区分,以object嵌套的方式记录展开目录所在的层级关系
2. 加载时,会根据记录的目录层级关系,依次展开
3. 点击展开按钮,先从 BACK 里面查找是否有这个节点,如果有,就移动到 CFG 里面,然后展开
4. 点击折叠按钮,将 CFG 的节点 移动到 BACK 里面

*******************************************************************************/

/*******************************可自行修改的配置*********************************/
// 配置存储路径
const file_path = "/data/storage/tree_extend.json";
// 保存配置的时间间隔
const cfg_check_interval_t = 1 * (30*1000); // 30s检查一次
// 是否启用自动定位当前文件所在的目录
const is_show_file_position = false;
// 是否移除 文档树的新建笔记按钮
const remove_new_file_btn = false;
// 是否移除 文档树和大纲的最小化按钮
const remove_min_btn = false;

/*******************************************************************************/


/*******************************更新日志*****************************************
## 更新日志
**[增加/删除/修改/修复/优化/重构/文档/发布]**

---
### [2024/07/28_16:55:50] release_v1.0 基础版本
* [增加] 记录文档树的展开状态, 下次打开或刷新时自动展开上次已展开的目录
* [增加] 定位当前打开的文档
* [增加] 保存配置的时间间隔
* [增加] 移除 文档树的新建笔记按钮
* [增加] 移除 文档树和大纲的最小化按钮

*******************************************************************************/

/*******************************下面是实现代码**********************************/

const file_def_path = "/data/storage/tree_extend.json";
const CFG = "expand_element"
const BACK = "expand_back"
const VIS = "is_change"
const file_def_json = {
    [CFG]: {},
    [BACK]: {},
    [VIS]: true
};
const file_def_info = JSON.stringify(file_def_json, null, 2);
const is_debug = false
const sbin_id = Date.now()
var main_cfg = file_def_json

function my_log(...args) {
    if (is_debug) {
        console.log(`[${sbin_id}]:`, ...args)
    }
}
// 延迟执行
function sleep(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}
// 功能: 监听直到 元素存在
// 接受一个选择器参数(可以是字符串也可以是函数)
function when_element_exist(selector) {
    // 返回一个 Promise 对象
    return new Promise(resolve => {
        // 定义一个内部函数 checkForElement 来检测是否存在特定元素
        const checkForElement = () => {
            // 初始化 isExist 变量为 false
            let isExist = false;
            // 如果传入的选择器是函数
            if (typeof selector === 'function') {
                // 执行选择器函数,获取返回值
                isExist = selector();
            } else {
                // 如果选择器不是函数,则使用 document.querySelector 获取元素
                isExist = document.querySelector(selector);
            }
            // 如果元素存在
            if (isExist) {
                // 解决 Promise,表示元素存在
                resolve(true);
            } else {
                // 如果元素不存在,通过 requestAnimationFrame 在下一个动画帧继续检查
                requestAnimationFrame(checkForElement);
            }
        };
        // 第一次调用 checkForElement 函数开始检查元素是否存在
        checkForElement();
    });
}

// dfs遍历配置, 展开所有
async function expand_element_dfs(parent_cfg, book_url, parent_url = "") {
    if (!parent_cfg) {
        return;
    }
    my_log("auto parent_cfg:", parent_cfg)

    // 遍历 parent_cfg 下面的节点, 并展开
    for (let node_name in parent_cfg) {
        if (!(node_name && parent_cfg[node_name])) {
            // 异常判断, 跳过
            continue;
        }
        // 拼接出节点路径 和 文档路径
        let node_path = ""
        let el_path = ""
        if (parent_url == "") {
            node_path = "/"
            el_path = "/"
        } else if (parent_url == "/") {
            node_path = `${parent_url}${node_name}`;
            el_path = `${node_path}.sy`;
        } else {
            node_path = `${parent_url}/${node_name}`;
            el_path = `${node_path}.sy`;
        }
        // 找到折叠按钮
        my_log("auto parent_url:", parent_url, "node_name:", node_name)
        my_log("auto click node data-url:", book_url, "data-path:", el_path)
        let arrow_btn = document.querySelector("ul.b3-list[data-url='" + book_url + "'] li[data-path='" + el_path + "'] span.b3-list-item__toggle");
        if (!arrow_btn) {
            // 如果没有找到,说明这个元素还没有渲染出来,等待一下
            await sleep(10);
        }
        arrow_btn = document.querySelector("ul.b3-list[data-url='" + book_url + "'] li[data-path='" + el_path + "'] span.b3-list-item__toggle");
        if (!arrow_btn) {
            // 如果还是没有找到,说明异常了, 跳过
            my_log("arrow_btn is nil, continue")
            continue
        }
        my_log("auto find arrow_btn", arrow_btn)
        // 判断是否已经展开
        if (!arrow_btn.firstElementChild.classList.contains("b3-list-item__arrow--open")) {
            my_log("auto click arrow_btn")
            // 之前是折叠状态, 要展开
            arrow_btn.click();
            // 等待子元素渲染完毕
            await sleep(10);
            await when_element_exist(()=>{
                // 监听, 直到找到 折叠元素的父级的兄弟节点 是ul
                // 监听, 直到找到 这个文档树节点已经展开
                return arrow_btn.closest("li").nextElementSibling?.tagName === 'UL';
            });
        }
        // 递归展开配置里 当前节点的子节点
        if (parent_cfg[node_name] !== null && typeof parent_cfg[node_name] === 'object' &&  !Array.isArray(parent_cfg[node_name])) {
            my_log("auto dfs:", node_name)
            await expand_element_dfs(parent_cfg[node_name], book_url, node_path);
        }
    }
}

// 获取文件内容
async function get_file_data() {
    let path = file_path || file_def_path
    return fetch("/api/file/getFile", {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
        },
        body: JSON.stringify({
            path,
        }),
    })
    .then((response) => {
        if (response.ok) {
            return response.text();
        }
        else {
            throw new Error("Failed to get file content");
        }
    })
    .catch((error) => {
        console.error(error);
    });
}
// 加载配置, 保证 返回的json里面一定有 CFG 和 BACK
async function get_expand_cfg_from_file() {
    // 获取json配置里面的内容
    let data = await get_file_data()
    // 转换成json
    if (data) {
        data = JSON.parse(data);
    }
    if (!data) {
        data = file_def_json;
    }

    if(data.code === 404) data = {};
    if (!data[CFG] ||
        !data[BACK]) {
        data = file_def_json
    }
    data[VIS] = true
    return data;
}
// 初始化配置, 在 get_expand_cfg_from_file 的基础上, 保证 每个笔记本的目录都有
function init_expand_cfg(book_url) {
    if (!main_cfg[CFG][book_url]) {
        main_cfg[CFG][book_url] = {};
    }
    if (!main_cfg[BACK][book_url]) {
        main_cfg[BACK][book_url] = {};
    }
}

// 写入文件内容
async function set_file_data(content) {
    let path = file_path || file_def_path
    const formData = new FormData();
    formData.append("path", path);
    formData.append("file", new Blob([content]));
    return fetch("/api/file/putFile", {
        method: "POST",
        body: formData,
    })
    .then((response) => {
        if (response.ok) {
            my_log("File saved successfully");
        }
        else {
            throw new Error("Failed to save file");
        }
    })
    .catch((error) => {
        console.error(error);
    });
}
// 保存配置
function save_expand_cfg_to_file(data) {
    // my_log(JSON.stringify(data, null, 2))
    // 格式化json再保存, 方便查看
    if (is_debug) {
        set_file_data(JSON.stringify(data, null, 2))
    } else {
        set_file_data(JSON.stringify(data))
    }
}

// 根据 path_arr 在 parent_obj 里面创建对象
function create_expand_obj_dfs(path_arr, parent_obj) {
    if (path_arr.length === 0) return parent_obj; // 如果数组为空,退出递归
    const node_name = path_arr.shift(); // 取出数组的第一个元素
    if (!parent_obj[node_name]) {
        parent_obj[node_name] = {}; // 创建一个新的空对象
    }
    return create_expand_obj_dfs(path_arr, parent_obj[node_name]); // 递归调用,传入新的路径数组和当前节点的子对象
}

// 用户点击折叠按钮, 修改并保存配置
async function change_and_save_expand_cfg(book, real_cb, arrow_btn, isOpen) {
    const book_url = book.getAttribute("data-url");
    // 找到arrowBtn父级的父级, 那里面有路径
    const path_str = arrow_btn?.parentElement?.parentElement?.getAttribute("data-path");
    // 将路径转换为数组, 删掉 ".sy" 结尾的字符, 按照 "/" 分割, 将路径转换成数组
    const path_arr = path_str === "/" ? ["/"] : path_str.replace(/.sy$/i, '').split("/");
    path_arr[0] = path_arr[0] === "" ? "/" : path_arr[0];
    const path_arr_n = path_arr.length;
    if (path_arr_n == 0 ) {
        my_log("path_arr is empty")
        return
    }
    if (!main_cfg[CFG][book_url]) {
        main_cfg[CFG][book_url] = {};
    }
    if (!main_cfg[BACK][book_url]) {
        main_cfg[BACK][book_url] = {};
    }
    // 找到父级节点(如果原来不存在, 就创建)
    const node_name = path_arr.pop();
    const parent_obj = create_expand_obj_dfs(path_arr, main_cfg[CFG][book_url]);
    main_cfg[VIS] = true
    my_log("tree node:", node_name, ", convert:", isOpen)
    if (isOpen) {
        // 需要展开
        // 理论上: 父级节点里面是没有 node_name 的, 如果有, 说明异常, 但是不能影响正常展开
        if (parent_obj[node_name]) {
            my_log("move error: ", parent_obj[node_name])
        }
        // 如果是展开, 先去 BACK 里面查一下是否有这个节点
        my_log("back cfg:", main_cfg[BACK][book_url])
        if (main_cfg[BACK][book_url][node_name]) {
            my_log("expand tree node, move BACK to CFG")
            // 如果有, 就把 BACK 里面的节点 移动 到 CFG 里面
            parent_obj[node_name] = main_cfg[BACK][book_url][node_name]
            delete main_cfg[BACK][book_url][node_name];
            // 递归展开 节点的子节点
            my_log("user click before, removeEventListener click event")
            book.removeEventListener("click", real_cb);
            expand_element_dfs(parent_obj[node_name], book_url, path_str.replace(/.sy$/i, '')).then( () => {
                my_log("user click after, addEventListener click event")
                book.addEventListener("click", real_cb);
            })
        } else {
            if (typeof parent_obj[node_name] === 'object' && Object.keys(parent_obj[node_name]).length > 0) {
                // 走到这里, 说明是已经展开过的节点, 直接展开
                my_log("user click before, removeEventListener click event")
                book.removeEventListener("click", real_cb);
                expand_element_dfs(parent_obj[node_name], book_url, path_str.replace(/.sy$/i, '')).then( () => {
                    my_log("user click after, addEventListener click event")
                    book.addEventListener("click", real_cb);
                })
            } else {
                // 如果没有, 说明是新节点, 需要创建
                parent_obj[node_name] = {};
            }
        }
    } else {
        my_log("fold tree node, move cfg to BACK")
        // 如果是折叠, 需要把 CFG 里面的节点, 移走
        if (typeof parent_obj[node_name] === 'object' && Object.keys(parent_obj[node_name]).length > 0) {
            // 如果不是叶子节点, 要保存到 BACK 里面: 将 CFG 的节点 移动到 BACK 里面
            main_cfg[BACK][book_url][node_name] = parent_obj[node_name];
        }
        delete parent_obj[node_name]
    }
}

// 移除按钮
async function remove_btn() {
    // 移除 新建笔记按钮
    if (remove_new_file_btn) {
        document.querySelectorAll('[data-type="new"]').forEach(async element => element.remove())
    }
    // 移除 最小化按钮
    if (remove_min_btn) {
        document.querySelectorAll('[data-type="min"]').forEach(async element => element.remove())
    }
}
// 用户点击按钮的回调, 判断是否需要处理 折叠/展开
function click_arrow_btn_cb(book){
    async function click_arrow_btn_cb_real(event) {
        my_log("user click event:", book)
        //等待箭头按钮渲染完成
        // await sleep(40);
        let arrow_btn = event.target;
        if (arrow_btn.tagName == "LI") {
            arrow_btn = arrow_btn.firstElementChild?.firstElementChild
        } else if (arrow_btn.tagName == "SPAN") {
            if (arrow_btn.childElementCount == 0) {
                // 没有子元素, 说明点的是文档
                if (arrow_btn.parentElement.getAttribute("data-path") == "/") {
                    // 路径是 / 说明是最外层的笔记本, 要处理, 其他不处理
                    arrow_btn = arrow_btn.parentElement.firstElementChild;
                }
            }
            arrow_btn = arrow_btn.firstElementChild
        } else if (arrow_btn.tagName == "use") {
            // 点击的元素可能是折叠按钮的子元素
            arrow_btn = arrow_btn.parentElement;
        }
        my_log("user click btn info", arrow_btn)
        // 如果是箭头按钮
        if (arrow_btn && arrow_btn.tagName == "svg" && arrow_btn.classList.contains("b3-list-item__arrow")) {
            my_log("user click arrow btn")
            //等待箭头按钮切换完成
            const currentClassList = arrow_btn.classList.toString();
            await when_element_exist(()=>{
                return arrow_btn.classList.toString() !== currentClassList;
            });
          
            const isOpen = arrow_btn.classList.contains("b3-list-item__arrow--open");
            // 存储当前元素的折叠状态
            await change_and_save_expand_cfg(book, click_arrow_btn_cb_real, arrow_btn, isOpen);
        }
        remove_btn()
    }
    return click_arrow_btn_cb_real;
}

// 处理文档树的展开状态
async function monitor_tree_status() {
    // 笔记列表渲染完成后, 触发
    when_element_exist("ul.b3-list[data-url]").then(async myElement => {
        // 防止更多笔记列表尚未渲染完成(笔记目录只渲染第一级,一般20毫秒足够了)
        await sleep(20);
        my_log("monitor_tree_status begin")

      
        // 搜索笔记本
        document.querySelectorAll("ul.b3-list[data-url]").forEach(async book => {
            // 监听每个笔记本的折叠事件
            // 当前笔记的 data-url 
            const book_url = book.getAttribute("data-url");
            // 在cfg里面添加笔记本的url
            init_expand_cfg(book_url)

            // dfs遍历配置, 展开配置里面的节点
            expand_element_dfs(main_cfg[CFG][book_url], book_url, "").then( () => {
                // 展开之后, 监听这个笔记本的展开/折叠事件
                //todo 新建笔记本无法被监听, 也就无法使用
                my_log("init addEventListener click event:", book)
                const clickHandler = click_arrow_btn_cb(book)
                book.addEventListener("click", clickHandler);
            });
        });
        // 移除按钮
        remove_btn()
        // 设置定时器
        setInterval(async () => {
            my_log("check cfg is change")
            save_expand_cfg_to_file(main_cfg);
            // 保存配置
            if (!main_cfg.hasOwnProperty(VIS) || main_cfg[VIS] == true) {
                main_cfg[VIS] = false
            }
        }, cfg_check_interval_t);

        // 定位当前打开的文档
        if(is_show_file_position){
            await sleep(40);
            document.querySelector(".layout-tab-container .block__icons span[data-type=focus]")?.click();
        }
    });
}

//todo 监听全部折叠按钮
async function monitor_fold_all_tree() {
    const fold_all_btn_selector = "#layouts > div.fn__flex.fn__flex-1 > div.fn__flex-column.fn__flex-shrink.layout__dockl > div:nth-child(1) > div > div.layout-tab-container.fn__flex-1 > div > div.block__icons > span:nth-child(5)"
    when_element_exist(fold_all_btn_selector).then(async () => {
        const collapse_ele = document.querySelector(fold_all_btn_selector)
        collapse_ele.addEventListener("click", async event => {
            //todo
        })
      
    })
}

// 先加载配置
get_expand_cfg_from_file().then(async cfg => {
    my_log("script begin")
    main_cfg = cfg
    // 处理文档树的展开状态
    monitor_tree_status()
    // 监听 全部折叠按钮
    monitor_fold_all_tree()
});

  • 思源笔记

    思源笔记是一款隐私优先的个人知识管理系统,支持完全离线使用,同时也支持端到端加密同步。

    融合块、大纲和双向链接,重构你的思维。

    23020 引用 • 92599 回帖

相关帖子

欢迎来到这里!

我们正在构建一个小众社区,大家在这里相互信任,以平等 • 自由 • 奔放的价值观进行分享交流。最终,希望大家能够找到与自己志同道合的伙伴,共同成长。

注册 关于
请输入回帖内容 ...