[js] 代码块功能增强:代码块折叠、锁定、html 预览、显示框拖拽、中英文字体设置

1、代码片段

// v5.1  2025.12.6
// 代码块折叠与展开、默认高度设置、锁定、预览HTML、实时调整高度、代码块和行级代码字体设置
// 更新:新增行级代码字体设置
// 功能描述
// 在代码块右上角添加 预览、锁定、折叠功能按钮(保留原本的复制、更多按钮),右下角添加拖拽控件
// 添加的功能
//   - 折叠代码块,将代码块折叠为指定的默认高度,折叠后可通过鼠标滚轮滚动浏览
//       可通过添加属性 set-height 调整某一代码块的默认折叠高度
//       设置为具体值,如 100px 150px 则默认折叠为该高度
//       设置为 auto ,则默认展开代码块
//   - 锁定代码块,锁定后被折叠代码块无法滚动,可设置全局默认锁定或不锁定
//       通过添加属性 set-lock 设置某一代码块的默认锁定状态
//       设置为 lock 或 true,则默认锁定,无法滚动
//       设置为 unlock 或 false,则默认解锁,可以滚动
//   - 拖拽代码块,折叠状态下通过拖拽代码块右下角角标,临时调整代码块显示区域高度
//   - 预览 HTML 代码块
//   - 代码块和行级代码字体修改,根据字体优先级设置中英文的字体(类似vscode)

(() => {
    // 功能参数配置
    const CONFIG = {
        codeMaxHeight: "112px",// 默认折叠高度,设置为无效数据则会默认展开
        enablePreview: false,
        defaultLockState: true, // 默认锁定状态:false=默认解锁,true=默认锁定
        enableResize: true, // 启用拖拽调整代码块高度
        // Fira Code, Consolas, Hack, MonacoB, Cascadia Code,'霞鹜文楷', '思源黑体', '黑体', Maple Mono NF CN
        codeFontFamily: '"Fira Code", Consolas, "霞鹜文楷", "黑体", "Courier New", monospace', // 代码块字体
        inlineCodeFontFamily: '"Fira Code", Consolas, "霞鹜文楷", "黑体", "Courier New", monospace', // 行级代码字体
        enableScrollbar: true, // 自动显示滚动栏
        minResizeHeight: 50, // 最小拖拽高度(px)
        maxResizeHeight: "80vh", // 最大拖拽高度
        throttleDelay: 50,
        resizeDelay: 100,
    };
    // 屏蔽手机版
    if (isMobile()) return;
    // 缓存系统
    const cache = {
        heightValues: new Map(),
        processingBlocks: new WeakSet(),
        scrollbarInstances: new WeakMap(),
        lockStates: new WeakMap(),
        expandStates: new WeakMap(),
        resizeInstances: new WeakMap(),
        customHeights: new WeakMap(),
    };
    // 常量定义
    const SELECTORS = {
        layoutCenter: ".layout__center, #editor",
        protyle: ".protyle",
        protyleContent: ".protyle-content",
        codeBlock: ".code-block",
        hljs: ".hljs",
        moreBtn: ".protyle-icon--last",
        scrollbarContainer: ".scrollbar-container",
    };
    const ICONS = {
        lock: { locked: "iconLock", unlocked: "iconUnlock" },
        expand: { collapsed: "iconLeft", expanded: "iconDown" },
        preview: "iconPreview",
    };
    // 节流函数
    function throttle(func, delay) {
        let timeoutId = null;
        let lastExecTime = 0;
        return function (...args) {
            const currentTime = performance.now();
            const timeSinceLastExec = currentTime - lastExecTime;
            if (timeSinceLastExec >= delay) {
                lastExecTime = currentTime;
                func.apply(this, args);
            } else if (!timeoutId) {
                timeoutId = setTimeout(() => {
                    timeoutId = null;
                    lastExecTime = performance.now();
                    func.apply(this, args);
                }, delay - timeSinceLastExec);
            }
        };
    }
    // 防抖函数
    function debounce(func, delay) {
        let timeoutId;
        return function (...args) {
            clearTimeout(timeoutId);
            timeoutId = setTimeout(() => func.apply(this, args), delay);
        };
    }
    // 全局测试元素(避免重复创建)
    let globalTestEl = null;
    function getTestElement() {
        if (!globalTestEl) {
            globalTestEl = document.createElement("div");
            globalTestEl.style.cssText = "position:absolute;visibility:hidden;";
        }
        return globalTestEl;
    }
    // 高度解析函数
    function parseHeightToPixels(value) {
        if (typeof value === "number") return value;
        if (cache.heightValues.has(value)) return cache.heightValues.get(value);
        let result = 0;
        if (typeof value === "string") {
            if (/^\d+\.?\d*$/.test(value)) {
                result = parseFloat(value);
            } else if (value.endsWith("px")) {
                result = parseFloat(value);
            } else {
                const testEl = getTestElement();
                testEl.style.height = value;
                document.body.appendChild(testEl);
                result = testEl.offsetHeight;
                document.body.removeChild(testEl);
            }
        }
        cache.heightValues.set(value, result);
        return result;
    }
    // 代码块高度获取
    function getCodeBlockMaxHeight(codeBlock) {
        // 优先使用拖拽设置的高度
        if (cache.customHeights.has(codeBlock)) {
            return cache.customHeights.get(codeBlock) + "px";
        }
        const setHeightValue = codeBlock.getAttribute("custom-set-height");
        if (!setHeightValue || setHeightValue === "auto") {
            return CONFIG.codeMaxHeight;
        }
        return /^\d+px$/.test(setHeightValue)
            ? setHeightValue
            : CONFIG.codeMaxHeight;
    }
    // 状态管理器
    class StateManager {
        static shouldDefaultExpand(codeBlock) {
            return codeBlock.getAttribute("custom-set-height") === "auto";
        }
        static shouldBeLocked(codeBlock) {
            const lockAttr = codeBlock.getAttribute("custom-set-lock");
            if (lockAttr === "lock" || lockAttr === "true") return true;
            if (lockAttr === "unlock" || lockAttr === "false") return false;
            return CONFIG.defaultLockState;
        }
        static getLockState(codeBlock) {
            return cache.lockStates.has(codeBlock)
                ? cache.lockStates.get(codeBlock)
                : StateManager.shouldBeLocked(codeBlock);
        }
        static setLockState(codeBlock, isLocked) {
            cache.lockStates.set(codeBlock, isLocked);
        }
        static getExpandState(codeBlock) {
            return cache.expandStates.has(codeBlock)
                ? cache.expandStates.get(codeBlock)
                : StateManager.shouldDefaultExpand(codeBlock);
        }
        static setExpandState(codeBlock, isExpanded) {
            cache.expandStates.set(codeBlock, isExpanded);
        }
    }
    // 样式应用器
    class StyleApplicator {
        static applyLockState(codeBlock, isLocked) {
            const hljs = codeBlock.querySelector(SELECTORS.hljs);
            const scrollbarInstance = cache.scrollbarInstances.get(codeBlock);
            const expandBtn = codeBlock.querySelector(".protyle-icon--expand");
            const resizeHandle = codeBlock.querySelector(".resize-handle");
            if (isLocked) {
                const isExpanded = StateManager.getExpandState(codeBlock);
                const maxHeight = isExpanded
                    ? "none"
                    : getCodeBlockMaxHeight(codeBlock);
                Object.assign(hljs.style, {
                    maxHeight: maxHeight,
                    overflow: "hidden",
                    overflowX: "hidden",
                    overflowY: "hidden",
                });
                scrollbarInstance?.disable();
                if (expandBtn) expandBtn.style.display = "none";
                if (resizeHandle) resizeHandle.style.display = "none";
            } else {
                const isExpanded = StateManager.getExpandState(codeBlock);
                const targetHeight = isExpanded
                    ? "none"
                    : getCodeBlockMaxHeight(codeBlock);
                Object.assign(hljs.style, {
                    maxHeight: targetHeight,
                    overflow: "",
                    overflowX: "",
                    overflowY: "",
                });
                scrollbarInstance?.enable();
                if (expandBtn) expandBtn.style.display = "";
                if (resizeHandle) resizeHandle.style.display = isExpanded ? "none" : "";
            }
            codeBlock.classList.toggle("code-block--locked", isLocked);
            codeBlock.dataset.locked = isLocked ? "true" : "false";
        }
        static applyExpandState(codeBlock, isExpanded) {
            const hljs = codeBlock.querySelector(SELECTORS.hljs);
            const isLocked = StateManager.getLockState(codeBlock);
            const resizeHandle = codeBlock.querySelector(".resize-handle");
            if (isLocked) return;
            const targetHeight = isExpanded
                ? "none"
                : getCodeBlockMaxHeight(codeBlock);
            hljs.style.maxHeight = targetHeight;
            if (resizeHandle) {
                resizeHandle.style.display = isExpanded ? "none" : "";
            }
            codeBlock.dataset.expanded = isExpanded ? "true" : "false";
            const scrollbarInstance = cache.scrollbarInstances.get(codeBlock);
            scrollbarInstance?.updateAll();
        }
    }
    // 滚动条控制器
    class ScrollbarController {
        constructor(code, hljs, protyleContent) {
            this.code = code;
            this.hljs = hljs;
            this.protyleContent = protyleContent;
            this.isDragging = false;
            this.isDisabled = false;
            this.startX = 0;
            this.thumbStartX = 0;
            this.originalOverflowX = getComputedStyle(hljs).overflowX;
            this.boundMethods = {
                handleMouseMove: this.handleMouseMove.bind(this),
                handleMouseUp: this.handleMouseUp.bind(this),
                updateVisibilityThrottled: throttle(
                    this.updateVisibility.bind(this),
                    CONFIG.throttleDelay
                ),
                updateAllThrottled: throttle(
                    this.updateAll.bind(this),
                    CONFIG.throttleDelay
                ),
                syncPosition: this.syncPosition.bind(this),
                handleHljsScroll: this.handleHljsScroll.bind(this),
            };
        }
        init() {
            if (this.code.querySelector(SELECTORS.scrollbarContainer)) return;
            const scrollbarHtml = `
                <div class="scrollbar-container protyle-custom">
                    <div class="scrollbar-thumb"></div>
                </div>`;
            this.hljs.insertAdjacentHTML("afterend", scrollbarHtml);
            this.container = this.code.querySelector(".scrollbar-container");
            this.thumb = this.container.querySelector(".scrollbar-thumb");
            this.bindEvents();
            this.updateAll();
        }
        bindEvents() {
            this.hljs.addEventListener("scroll", this.boundMethods.handleHljsScroll, {
                passive: true,
            });
            if (this.protyleContent) {
                this.protyleContent.addEventListener(
                    "scroll",
                    this.boundMethods.updateVisibilityThrottled,
                    { passive: true }
                );
            }
            this.thumb.addEventListener("mousedown", this.startDrag.bind(this));
            window.addEventListener("resize", this.boundMethods.updateAllThrottled, {
                passive: true,
            });
        }
        handleHljsScroll() {
            // 锁定状态下重置滚动位置,阻止代码块滚动
            // CSS overflow:hidden 会阻止鼠标滚轮滚动代码块,但滚轮事件会冒泡让页面滚动
            // 此方法作为后备,处理其他可能导致滚动的情况
            const isLocked = StateManager.getLockState(this.code);
            if (isLocked) {
                this.hljs.scrollTop = 0;
                this.hljs.scrollLeft = 0;
                return;
            }
            this.syncPosition();
        }
        updateVisibility() {
            // 早期返回,避免不必要的计算
            if (this.isDisabled) {
                this.container.classList.add("f__hidden");
                return;
            }
            const isLocked = StateManager.getLockState(this.code);
            const isExpanded = StateManager.getExpandState(this.code);
            if (isLocked || isExpanded) {
                this.container.classList.add("f__hidden");
                return;
            }
            // 仅在需要时计算
            const hasHorizontalScrollbar =
                this.hljs.scrollWidth > this.hljs.clientWidth;
            if (!hasHorizontalScrollbar) {
                this.container.classList.add("f__hidden");
                this.hljs.style.overflowX = this.originalOverflowX;
                return;
            }
            const currentMaxHeight = getCodeBlockMaxHeight(this.code);
            const isHeightExceeded =
                this.hljs.scrollHeight > parseHeightToPixels(currentMaxHeight);
            if (!isHeightExceeded) {
                this.container.classList.add("f__hidden");
                this.hljs.style.overflowX = this.originalOverflowX;
                return;
            }
            const isSticky = !isElementBottomInViewport(this.code);
            if (isSticky) {
                this.container.classList.remove("f__hidden");
                this.hljs.style.overflowX = "hidden";
            } else {
                this.container.classList.add("f__hidden");
                this.hljs.style.overflowX = this.originalOverflowX;
            }
        }
        updateSize() {
            const contentWidth = this.hljs.scrollWidth;
            const viewportWidth = this.hljs.clientWidth;
            if (contentWidth <= viewportWidth) {
                this.thumb.style.width = "0px";
                return;
            }
            const thumbWidth = Math.max(
                (viewportWidth / contentWidth) * this.container.offsetWidth,
                30
            );
            this.thumb.style.width = `${thumbWidth}px`;
        }
        syncPosition() {
            const maxScroll = this.hljs.scrollWidth - this.hljs.clientWidth;
            if (maxScroll <= 0) {
                this.thumb.style.left = "0px";
                return;
            }
            const scrollPercentage = this.hljs.scrollLeft / maxScroll;
            const thumbMaxMove = this.container.offsetWidth - this.thumb.offsetWidth;
            this.thumb.style.left = `${scrollPercentage * thumbMaxMove}px`;
        }
        updateAll() {
            if (this.isDisabled) {
                this.container?.classList.add("f__hidden");
                return;
            }
            const isLocked = StateManager.getLockState(this.code);
            if (isLocked) {
                this.container?.classList.add("f__hidden");
                return;
            }
            // 直接调用未throttle的方法,避免RAF内双重延迟
            this.updateVisibility();
            this.updateSize();
            this.syncPosition();
        }
        startDrag(e) {
            if (this.isDisabled || StateManager.getLockState(this.code)) {
                return;
            }
            this.isDragging = true;
            this.startX = e.clientX;
            this.thumbStartX = parseFloat(this.thumb.style.left) || 0;
            this.hljs.style.userSelect = "none";
            document.body.style.cursor = "grabbing";
            // 拖拽时禁用transition确保跟手
            this.thumb.style.transition = "none";
            document.addEventListener("mousemove", this.boundMethods.handleMouseMove);
            document.addEventListener("mouseup", this.boundMethods.handleMouseUp);
            e.preventDefault();
        }
        handleMouseMove(e) {
            if (!this.isDragging || this.isDisabled) return;
            const deltaX = e.clientX - this.startX;
            const thumbMaxMove = this.container.offsetWidth - this.thumb.offsetWidth;
            const newThumbPosition = Math.max(
                0,
                Math.min(this.thumbStartX + deltaX, thumbMaxMove)
            );
            this.thumb.style.left = `${newThumbPosition}px`;
            if (this.hljs.scrollWidth > this.hljs.clientWidth) {
                const scrollPercentage = newThumbPosition / thumbMaxMove;
                this.hljs.scrollLeft =
                    scrollPercentage * (this.hljs.scrollWidth - this.hljs.clientWidth);
            }
            e.preventDefault();
        }
        handleMouseUp() {
            if (!this.isDragging) return;
            this.isDragging = false;
            this.hljs.style.userSelect = "";
            document.body.style.cursor = "";
            // 恢复transition
            this.thumb.style.transition = "";
            document.removeEventListener(
                "mousemove",
                this.boundMethods.handleMouseMove
            );
            document.removeEventListener("mouseup", this.boundMethods.handleMouseUp);
        }
        disable() {
            this.isDisabled = true;
            this.container.classList.add("f__hidden");
            if (!StateManager.getLockState(this.code)) {
                this.hljs.style.overflowX = this.originalOverflowX;
            }
        }
        enable() {
            this.isDisabled = false;
            setTimeout(() => this.updateAll(), CONFIG.resizeDelay);
        }
        destroy() {
            document.removeEventListener(
                "mousemove",
                this.boundMethods.handleMouseMove
            );
            document.removeEventListener("mouseup", this.boundMethods.handleMouseUp);
            window.removeEventListener(
                "resize",
                this.boundMethods.updateAllThrottled
            );
            this.hljs.removeEventListener(
                "scroll",
                this.boundMethods.handleHljsScroll
            );
            this.container?.remove();
            cache.scrollbarInstances.delete(this.code);
        }
    }
    // 高度调整控制器
    class ResizeController {
        constructor(code, hljs) {
            this.code = code;
            this.hljs = hljs;
            this.isDragging = false;
            this.startY = 0;
            this.startHeight = 0;
            this.minHeight = CONFIG.minResizeHeight;
            this.maxHeight = parseHeightToPixels(CONFIG.maxResizeHeight);
            this.rafId = null;
            this.boundMethods = {
                handleMouseMove: this.handleMouseMove.bind(this),
                handleMouseUp: this.handleMouseUp.bind(this),
            };
        }
        init() {
            if (this.code.querySelector(".resize-handle")) return;
            const resizeHandleHtml = `<div class="resize-handle protyle-custom"></div>`;
            const scrollbarContainer = this.code.querySelector(
                SELECTORS.scrollbarContainer
            );
            if (scrollbarContainer) {
                scrollbarContainer.insertAdjacentHTML("afterend", resizeHandleHtml);
            } else {
                this.hljs.insertAdjacentHTML("afterend", resizeHandleHtml);
            }
            this.handle = this.code.querySelector(".resize-handle");
            this.bindEvents();
        }
        bindEvents() {
            this.handle.addEventListener("mousedown", this.startDrag.bind(this));
        }
        startDrag(e) {
            const isLocked = StateManager.getLockState(this.code);
            const isExpanded = StateManager.getExpandState(this.code);
            if (isLocked || isExpanded) return;
            this.isDragging = true;
            this.startY = e.clientY;
            this.startHeight = this.hljs.offsetHeight;
            document.body.style.cursor = "nwse-resize";
            document.body.style.userSelect = "none";
            document.addEventListener("mousemove", this.boundMethods.handleMouseMove);
            document.addEventListener("mouseup", this.boundMethods.handleMouseUp);
            e.preventDefault();
        }
        handleMouseMove(e) {
            if (!this.isDragging) return;
            // 取消上一帧的RAF,避免重复渲染
            if (this.rafId) {
                cancelAnimationFrame(this.rafId);
            }
            // 使用 requestAnimationFrame 优化性能
            this.rafId = requestAnimationFrame(() => {
                const deltaY = e.clientY - this.startY;
                let newHeight = this.startHeight + deltaY;
                // 限制高度范围
                newHeight = Math.max(
                    this.minHeight,
                    Math.min(newHeight, this.maxHeight)
                );
                this.hljs.style.maxHeight = `${newHeight}px`;
                cache.customHeights.set(this.code, newHeight);
            });
            e.preventDefault();
        }
        handleMouseUp() {
            if (!this.isDragging) return;
            this.isDragging = false;
            document.body.style.cursor = "";
            document.body.style.userSelect = "";
            if (this.rafId) {
                cancelAnimationFrame(this.rafId);
                this.rafId = null;
            }
            // 拖拽结束后统一更新滚动条
            const scrollbarInstance = cache.scrollbarInstances.get(this.code);
            scrollbarInstance?.updateAll();
            document.removeEventListener(
                "mousemove",
                this.boundMethods.handleMouseMove
            );
            document.removeEventListener("mouseup", this.boundMethods.handleMouseUp);
        }
        destroy() {
            if (this.rafId) {
                cancelAnimationFrame(this.rafId);
            }
            document.removeEventListener(
                "mousemove",
                this.boundMethods.handleMouseMove
            );
            document.removeEventListener("mouseup", this.boundMethods.handleMouseUp);
            this.handle?.remove();
            cache.resizeInstances.delete(this.code);
        }
    }
    // 按钮创建器
    class ButtonCreator {
        static createButton(iconName, ariaLabel, className) {
            return `<span class="b3-tooltips__nw b3-tooltips protyle-icon ${className} protyle-custom" aria-label="${ariaLabel}">
                <svg><use xlink:href="#${iconName}"></use></svg>
            </span>`;
        }
        static updateButtonIcon(button, iconName, ariaLabel) {
            const useEl = button.querySelector("svg > use");
            if (useEl) {
                useEl.setAttributeNS(
                    "http://www.w3.org/1999/xlink",
                    "xlink:href",
                    "#" + iconName
                );
            }
            button.setAttribute("aria-label", ariaLabel);
        }
        static createLockButton(isLocked) {
            const iconName = isLocked ? ICONS.lock.locked : ICONS.lock.unlocked;
            const ariaLabel = isLocked ? "取消锁定" : "锁定";
            return ButtonCreator.createButton(
                iconName,
                ariaLabel,
                "protyle-icon--lock protyle-action__lock"
            );
        }
        static createExpandButton(isExpanded) {
            const iconName = isExpanded
                ? ICONS.expand.expanded
                : ICONS.expand.collapsed;
            const ariaLabel = isExpanded ? "折叠" : "展开";
            return ButtonCreator.createButton(
                iconName,
                ariaLabel,
                "protyle-icon--expand protyle-action__expand"
            );
        }
        static createPreviewButton() {
            return ButtonCreator.createButton(
                ICONS.preview,
                "预览",
                "protyle-icon--preview protyle-action__preview"
            );
        }
    }
    // 事件处理器
    class EventHandlers {
        static handleLockClick(code, lockBtn) {
            return (e) => {
                e.preventDefault();
                e.stopPropagation();
                const currentLockState = StateManager.getLockState(code);
                const newLockState = !currentLockState;
                StateManager.setLockState(code, newLockState);
                const newIconName = newLockState
                    ? ICONS.lock.locked
                    : ICONS.lock.unlocked;
                const newAriaLabel = newLockState ? "取消锁定" : "锁定";
                ButtonCreator.updateButtonIcon(lockBtn, newIconName, newAriaLabel);
                StyleApplicator.applyLockState(code, newLockState);
                const scrollbarInstance = cache.scrollbarInstances.get(code);
                scrollbarInstance?.updateAll();
            };
        }
        static handleExpandClick(code, expandBtn) {
            return (e) => {
                e.preventDefault();
                e.stopPropagation();
                if (StateManager.getLockState(code)) return;
                const currentExpandState = StateManager.getExpandState(code);
                const newExpandState = !currentExpandState;
                StateManager.setExpandState(code, newExpandState);
                const newIconName = newExpandState
                    ? ICONS.expand.expanded
                    : ICONS.expand.collapsed;
                const newAriaLabel = newExpandState ? "折叠" : "展开";
                ButtonCreator.updateButtonIcon(expandBtn, newIconName, newAriaLabel);
                StyleApplicator.applyExpandState(code, newExpandState);
            };
        }
        static handlePreviewClick() {
            return (e) => {
                e.preventDefault();
                e.stopPropagation();
                const code = e.target.closest(SELECTORS.codeBlock);
                const lang = code
                    .querySelector(".protyle-action__language")
                    ?.textContent?.trim()
                    ?.toLowerCase();
                if (!["html", "js", "javascript"].includes(lang)) {
                    showMessage("目前仅支持HTML和JavaScript代码预览", true);
                    return;
                }
                const hljs = code.querySelector(SELECTORS.hljs);
                const codeElement = hljs.querySelector('[contenteditable="true"]');
                if (!codeElement) {
                    showMessage("无法获取代码内容", true);
                    return;
                }
                let codeText = codeElement.textContent;
                if (["js", "javascript"].includes(lang) && !/<script/i.test(codeText)) {
                    codeText = `<script>${codeText}</script>`;
                }
                ModalManager.open(codeText);
            };
        }
    }
    // 模态框管理器
    class ModalManager {
        static initialize() {
            if (document.getElementById("modal-preview")) return;
            document.body.insertAdjacentHTML(
                "beforeend",
                `
                <div id="modal-preview">
                    <div id="modal-preview-content">
                        <span id="modal-preview-close">❌</span>
                        <iframe id="modal-preview-iframe" sandbox="allow-scripts allow-same-origin allow-modals"></iframe>
                    </div>
                </div>
            `
            );
            const modal = document.getElementById("modal-preview");
            modal.addEventListener("click", (event) => {
                if (
                    event.target === modal ||
                    event.target.id === "modal-preview-close"
                ) {
                    ModalManager.close();
                }
            });
        }
        static open(code) {
            const iframe = document.getElementById("modal-preview-iframe");
            const modal = document.getElementById("modal-preview");
            if (!iframe || !modal) return;
            iframe.srcdoc = code;
            modal.style.display = "block";
        }
        static close() {
            const modal = document.getElementById("modal-preview");
            const iframe = document.getElementById("modal-preview-iframe");
            if (modal) modal.style.display = "none";
            if (iframe) iframe.srcdoc = "";
        }
    }
    // 注入样式
    function addStyle(css) {
        const existingStyle = document.getElementById("code-block-enhancer-style");
        if (existingStyle) {
            existingStyle.textContent = css;
            return;
        }
        const style = document.createElement("style");
        style.id = "code-block-enhancer-style";
        style.textContent = css;
        document.head.appendChild(style);
    }
    // 主要处理函数
    async function addCodeExtends(codeBlocks, protyle) {
        if (!codeBlocks?.length) return;
        const processPromises = Array.from(codeBlocks).map(async (code) => {
            if (
                cache.processingBlocks.has(code) ||
                code.querySelector(".protyle-icon--expand")
            ) {
                return;
            }
            cache.processingBlocks.add(code);
            try {
                const hljs = code.querySelector(SELECTORS.hljs);
                const moreBtn = code.querySelector(SELECTORS.moreBtn);
                if (!hljs || !moreBtn) return;
                await whenElementExist(() => moreBtn.getAttribute("aria-label"));
                // 初始化状态
                const isLocked = StateManager.getLockState(code);
                const shouldExpand = StateManager.shouldDefaultExpand(code);
                StateManager.setExpandState(code, shouldExpand);
                // 创建并添加按钮
                const buttons = await createButtons(
                    code,
                    moreBtn,
                    isLocked,
                    shouldExpand
                );
                setupButtonEvents(code, buttons, protyle);
                // 设置滚动条
                if (CONFIG.enableScrollbar) {
                    setupScrollbar(code, hljs, protyle);
                }
                // 设置高度拖拽
                if (CONFIG.enableResize) {
                    setupResize(code, hljs);
                }
                // 应用初始状态
                StyleApplicator.applyLockState(code, isLocked);
                StyleApplicator.applyExpandState(code, shouldExpand);
            } catch (error) {
                console.error("处理代码块失败:", error);
                cache.processingBlocks.delete(code);
            }
        });
        await Promise.allSettled(processPromises);
    }
    // 按钮创建和事件设置
    async function createButtons(code, moreBtn, isLocked, isExpanded) {
        const buttons = {};
        // 锁定按钮
        moreBtn.insertAdjacentHTML(
            "beforebegin",
            ButtonCreator.createLockButton(isLocked)
        );
        buttons.lock = code.querySelector(".protyle-icon--lock");
        // 展开按钮
        buttons.lock.insertAdjacentHTML(
            "beforebegin",
            ButtonCreator.createExpandButton(isExpanded)
        );
        buttons.expand = code.querySelector(".protyle-icon--expand");
        // 预览按钮
        if (CONFIG.enablePreview) {
            buttons.expand.insertAdjacentHTML(
                "beforebegin",
                ButtonCreator.createPreviewButton()
            );
            buttons.preview = code.querySelector(".protyle-icon--preview");
        }
        return buttons;
    }
    function setupButtonEvents(code, buttons, protyle) {
        // 锁定按钮事件
        buttons.lock.addEventListener(
            "click",
            EventHandlers.handleLockClick(code, buttons.lock)
        );
        // 展开按钮事件
        buttons.expand.addEventListener(
            "click",
            EventHandlers.handleExpandClick(code, buttons.expand)
        );
        // 预览按钮事件
        if (buttons.preview) {
            buttons.preview.addEventListener(
                "click",
                EventHandlers.handlePreviewClick()
            );
        }
    }
    function setupScrollbar(code, hljs, protyle) {
        if (code.querySelector(SELECTORS.scrollbarContainer)) return;
        const protyleContent = protyle.querySelector(SELECTORS.protyleContent);
        const scrollbarInstance = new ScrollbarController(
            code,
            hljs,
            protyleContent
        );
        scrollbarInstance.init();
        cache.scrollbarInstances.set(code, scrollbarInstance);
    }
    function setupResize(code, hljs) {
        if (!CONFIG.enableResize || code.querySelector(".resize-handle")) return;
        const resizeInstance = new ResizeController(code, hljs);
        resizeInstance.init();
        cache.resizeInstances.set(code, resizeInstance);
    }
    // 工具函数
    function observeProtyleLoaded(el, callback) {
        const observer = new MutationObserver((mutationsList) => {
            const protyles = new Set();
            const codeBlocks = new Set();
            for (const mutation of mutationsList) {
                if (mutation.type !== "childList") continue;
                for (const node of mutation.addedNodes) {
                    if (node.nodeType !== Node.ELEMENT_NODE) continue;
                    // 检查是否是protyle
                    if (
                        node.classList?.contains("protyle") ||
                        node.classList?.contains("protyle-content")
                    ) {
                        protyles.add(node);
                    }
                    // 检查是否是代码块
                    if (
                        node.matches?.(
                            SELECTORS.codeBlock + ":not(:has(.protyle-icon--expand))"
                        )
                    ) {
                        codeBlocks.add(node);
                    }
                    // 检查子元素中的代码块
                    const childCodeBlocks = node.querySelectorAll?.(
                        SELECTORS.codeBlock + ":not(:has(.protyle-icon--expand))"
                    );
                    if (childCodeBlocks?.length) {
                        childCodeBlocks.forEach((cb) => codeBlocks.add(cb));
                    }
                }
            }
            // 处理新发现的代码块
            if (codeBlocks.size > 0) {
                codeBlocks.forEach((codeBlock) => {
                    const protyle = codeBlock.closest(SELECTORS.protyle);
                    if (protyle) {
                        setTimeout(() => addCodeExtends([codeBlock], protyle), 10);
                    }
                });
            }
            // 处理新的protyle
            if (protyles.size > 0) {
                callback(Array.from(protyles));
            }
        });
        observer.observe(el, { childList: true, subtree: true });
        return () => observer.disconnect();
    }
    function whenElementExist(selector, node = document, timeout = 30000) {
        return new Promise((resolve, reject) => {
            const startTime = Date.now();
            const check = () => {
                try {
                    // 检查超时
                    if (Date.now() - startTime > timeout) {
                        reject(new Error(`等待元素超时: ${timeout}ms`));
                        return;
                    }
                    const el =
                        typeof selector === "function"
                            ? selector()
                            : node.querySelector(selector);
                    if (el) {
                        resolve(el);
                    } else {
                        requestAnimationFrame(check);
                    }
                } catch (e) {
                    requestAnimationFrame(check);
                }
            };
            check();
        });
    }
    function isMobile() {
        return !!document.getElementById("sidebar");
    }
    function showMessage(message, isError = false, delay = 7000) {
        const endpoint = isError ? "pushErrMsg" : "pushMsg";
        return fetch(`/api/notification/${endpoint}`, {
            method: "POST",
            headers: { "Content-Type": "application/json" },
            body: JSON.stringify({ msg: message, timeout: delay }),
        }).catch((error) => {
            console.warn("显示消息失败:", error);
        });
    }
    function isElementBottomInViewport(el) {
        if (!el) return false;
        try {
            const rect = el.getBoundingClientRect();
            const windowHeight =
                window.innerHeight || document.documentElement.clientHeight;
            return rect.bottom <= windowHeight;
        } catch (e) {
            return false;
        }
    }
    // 主初始化函数
    async function initialize() {
        try {
            // 注入样式
            const resizeGradient = `
                linear-gradient(
                    135deg,
                    transparent 0%,
                    transparent 40%,
                    var(--resize-handle-color) 40%,
                    var(--resize-handle-color) 45%,
                    transparent 45%,
                    transparent 55%,
                    var(--resize-handle-color) 55%,
                    var(--resize-handle-color) 60%,
                    transparent 60%,
                    transparent 70%,
                    var(--resize-handle-color) 70%,
                    var(--resize-handle-color) 75%,
                    transparent 75%
                )`;

            addStyle(`
                .b3-typography div.hljs, .protyle-wysiwyg .code-block div.hljs {
                    font-family: ${CONFIG.codeFontFamily};
                    transition: max-height 0.3s ease;
                }
              
                /* 行级代码字体样式 */
                .fn__code,
                .b3-typography code:not(.hljs),
                .b3-typography span[data-type~=code],
                .protyle-wysiwyg code:not(.hljs),
                .protyle-wysiwyg span[data-type~=code] {
                    font-family: ${CONFIG.inlineCodeFontFamily};
                }
                .b3-typography .code-block:not(pre), .protyle-wysiwyg .code-block:not(pre) {
                    margin: 2px 0; padding: 4px;
                    position: relative;
                }
                .b3-typography div.hljs, .protyle-wysiwyg div.hljs {
                    padding: 0px 1em 1.6em;
                }
                .b3-typography div.protyle-action, .protyle-wysiwyg .code-block div.protyle-action {
                    position: sticky;
                }
                .scrollbar-container {
                    position: sticky; bottom: 0; width: 100%; height: 8px; cursor: pointer;
                    border-radius: 5px; z-index: 9999;
                    transition: opacity 150ms ease;
                }
                .scrollbar-thumb {
                    position: absolute; top: 0; left: 0; height: 100%; width: 20%;
                    box-shadow: inset 0 0 5px 5px var(--b3-scroll-color);
                    border-radius: 5px; cursor: grab;
                    transition: left 0.1s ease;
                }
                .scrollbar-thumb:active { cursor: grabbing; }
                .scrollbar-container.f__hidden {
                    opacity: 0; pointer-events: none; height: 0;
                }
                .code-block--locked .hljs {
                    scrollbar-width: none !important;
                    -ms-overflow-style: none !important;
                }
                .code-block--locked .hljs::-webkit-scrollbar {
                    display: none !important;
                }
                .resize-handle {
                    --resize-handle-color: var(--b3-border-color);
                    position: absolute;
                    right: 0;
                    bottom: 0;
                    width: 14px;
                    height: 14px;
                    cursor: nwse-resize;
                    z-index: 10;
                    background: ${resizeGradient};
                    opacity: 0.5;
                    transition: opacity 150ms ease;
                }
                .resize-handle:hover {
                    --resize-handle-color: var(--b3-theme-primary);
                    opacity: 1;
                }
                .code-block--locked .resize-handle,
                .code-block[data-expanded="true"] .resize-handle {
                    display: none !important;
                }
                .protyle-icon--expand, .protyle-icon--preview, .protyle-icon--lock {
                    color: var(--b3-theme-on-background);
                    background-color: var(--b3-list-hover);
                    transition: opacity 150ms ease, transform 150ms ease;
                }
                .protyle-icon--expand:hover, .protyle-icon--preview:hover, .protyle-icon--lock:hover {
                    opacity: 0.86;
                    transform: scale(1.1);
                }
                .code-block--locked .hljs {
                    cursor: text;
                }
                .code-block--locked .protyle-icon--lock {
                    color: var(--b3-theme-primary);
                }
                #modal-preview {
                    display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%;
                    background-color: var(--b3-mask-background); 
                    z-index: 9999; 
                }
                #modal-preview-content {
                    position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%);
                    background-color: var(--b3-theme-background); padding: 20px; 
                    width: 80%; height: 80%; box-shadow: 0 5px 15px rgba(0, 0, 0, 0.3); 
                    border-radius: 5px;
                }
                #modal-preview-close { 
                    position: absolute; top: 10px; right: 10px; cursor: pointer; 
                    font-size: 14px; color: var(--b3-theme-on-background);
                    transition: transform 0.2s;
                }
                #modal-preview-close:hover {
                    transform: scale(1.2);
                }
                #modal-preview-iframe { width: 100%; height: calc(100% - 20px); border: none; }
            `);
            ModalManager.initialize();
            const layoutCenter = await whenElementExist(SELECTORS.layoutCenter);
            const protyle = await whenElementExist(() => {
                const p = layoutCenter.querySelector(SELECTORS.protyle);
                return p?.dataset?.loading === "finished" ? p : null;
            });
            const codeBlocks = protyle.querySelectorAll(
                SELECTORS.codeBlock + ":not(:has(.protyle-icon--expand))"
            );
            await addCodeExtends(codeBlocks, protyle);
            // 滚动监听
            const throttledAddCodeExtends = throttle(() => {
                const newCodeBlocks = protyle.querySelectorAll(
                    SELECTORS.codeBlock + ":not(:has(.protyle-icon--expand))"
                );
                addCodeExtends(newCodeBlocks, protyle);
            }, CONFIG.throttleDelay);
            const protyleContent = protyle.querySelector(SELECTORS.protyleContent);
            if (protyleContent) {
                protyleContent.addEventListener("scroll", throttledAddCodeExtends, {
                    passive: true,
                });
            }
            // 观察新的Protyle加载
            observeProtyleLoaded(layoutCenter, async (protyles) => {
                for (const p of protyles) {
                    const actualProtyle = p.classList.contains("protyle")
                        ? p
                        : p.closest(SELECTORS.protyle);
                    if (!actualProtyle) continue;
                    const newCodeBlocks = actualProtyle.querySelectorAll(
                        SELECTORS.codeBlock + ":not(:has(.protyle-icon--expand))"
                    );
                    await addCodeExtends(newCodeBlocks, actualProtyle);
                    const throttledHandler = throttle(() => {
                        const additionalCodeBlocks = actualProtyle.querySelectorAll(
                            SELECTORS.codeBlock + ":not(:has(.protyle-icon--expand))"
                        );
                        addCodeExtends(additionalCodeBlocks, actualProtyle);
                    }, CONFIG.throttleDelay);
                    const content = actualProtyle.querySelector(SELECTORS.protyleContent);
                    if (content) {
                        content.addEventListener("scroll", throttledHandler, {
                            passive: true,
                        });
                    }
                }
            });
        } catch (error) {
            console.error("代码块扩展初始化失败:", error);
        }
    }
    // 启动初始化
    if (document.readyState === "loading") {
        document.addEventListener("DOMContentLoaded", initialize);
    } else {
        initialize();
    }
})();

2、功能展示

(1)支持折叠、锁定

折叠可以使代码块默认折叠为指定的高度,并允许鼠标滚轮滚动显示代码

锁定状态下无法滚动代码(适用于折叠的代码块),支持默认锁定或解锁

img.gif

(2)支持拖拽显示区域

代码块右下角添加拖拽控件

img.gif

(3)支持 html 预览

将 enablePreview: false 设置为 true 时开启

img.gif

(4)支持设置自定义属性控制折叠高度和锁定状态

设置属性 set-height

img.gif

同样的方法添加 set-lock 值为 lock 或 true,unlock 或 false 控制默认锁定状态,锁定状态下无法滚动

如果有好的建议,欢迎评论交流

打赏作者

image.png

  • 思源笔记

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

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

    28442 引用 • 119754 回帖
  • 代码片段

    代码片段分为 CSS 与 JS 两种代码,添加在 [设置 - 外观 - 代码片段] 中,这些代码会在思源笔记加载时自动执行,用于改善笔记的样式或功能。

    用户在该标签下分享代码片段时需在帖子标题前添加 [css] [js] 用于区分代码片段类型。

    285 引用 • 1984 回帖
2 操作
Kroneko 在 2025-12-06 01:39:21 更新了该帖
Kroneko 在 2025-12-05 01:33:03 更新了该帖

相关帖子

欢迎来到这里!

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

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

推荐标签 标签

  • 支付宝

    支付宝是全球领先的独立第三方支付平台,致力于为广大用户提供安全快速的电子支付/网上支付/安全支付/手机支付体验,及转账收款/水电煤缴费/信用卡还款/AA 收款等生活服务应用。

    29 引用 • 347 回帖 • 2 关注
  • Firefox

    Mozilla Firefox 中文俗称“火狐”(正式缩写为 Fx 或 fx,非正式缩写为 FF),是一个开源的网页浏览器,使用 Gecko 排版引擎,支持多种操作系统,如 Windows、OSX 及 Linux 等。

    7 引用 • 30 回帖 • 367 关注
  • flomo

    flomo 是新一代 「卡片笔记」 ,专注在碎片化时代,促进你的记录,帮你积累更多知识资产。

    6 引用 • 144 回帖
  • 一些有用的避坑指南。

    69 引用 • 93 回帖
  • Vue.js

    Vue.js(读音 /vju ː/,类似于 view)是一个构建数据驱动的 Web 界面库。Vue.js 的目标是通过尽可能简单的 API 实现响应的数据绑定和组合的视图组件。

    269 引用 • 666 回帖 • 1 关注
  • AWS
    11 引用 • 28 回帖 • 2 关注
  • 安装

    你若安好,便是晴天。

    134 引用 • 1184 回帖 • 2 关注
  • MySQL

    MySQL 是一个关系型数据库管理系统,由瑞典 MySQL AB 公司开发,目前属于 Oracle 公司。MySQL 是最流行的关系型数据库管理系统之一。

    695 引用 • 538 回帖 • 1 关注
  • Kafka

    Kafka 是一种高吞吐量的分布式发布订阅消息系统,它可以处理消费者规模的网站中的所有动作流数据。 这种动作(网页浏览,搜索和其他用户的行动)是现代系统中许多功能的基础。 这些数据通常是由于吞吐量的要求而通过处理日志和日志聚合来解决。

    36 引用 • 35 回帖
  • 钉钉

    钉钉,专为中国企业打造的免费沟通协同多端平台, 阿里巴巴出品。

    15 引用 • 67 回帖 • 236 关注
  • RabbitMQ

    RabbitMQ 是一个开源的 AMQP 实现,服务器端用 Erlang 语言编写,支持多种语言客户端,如:Python、Ruby、.NET、Java、C、PHP、ActionScript 等。用于在分布式系统中存储转发消息,在易用性、扩展性、高可用性等方面表现不俗。

    49 引用 • 60 回帖 • 342 关注
  • 创业

    你比 99% 的人都优秀么?

    81 引用 • 1396 回帖 • 1 关注
  • Netty

    Netty 是一个基于 NIO 的客户端-服务器编程框架,使用 Netty 可以让你快速、简单地开发出一个可维护、高性能的网络应用,例如实现了某种协议的客户、服务端应用。

    49 引用 • 33 回帖 • 63 关注
  • FreeMarker

    FreeMarker 是一款好用且功能强大的 Java 模版引擎。

    23 引用 • 20 回帖 • 475 关注
  • 黑曜石

    黑曜石是一款强大的知识库工具,支持本地 Markdown 文件编辑,支持双向链接和关系图。

    A second brain, for you, forever.

    34 引用 • 333 回帖
  • jsDelivr

    jsDelivr 是一个开源的 CDN 服务,可为 npm 包、GitHub 仓库提供免费、快速并且可靠的全球 CDN 加速服务。

    5 引用 • 31 回帖 • 121 关注
  • Elasticsearch

    Elasticsearch 是一个基于 Lucene 的搜索服务器。它提供了一个分布式多用户能力的全文搜索引擎,基于 RESTful 接口。Elasticsearch 是用 Java 开发的,并作为 Apache 许可条款下的开放源码发布,是当前流行的企业级搜索引擎。设计用于云计算中,能够达到实时搜索,稳定,可靠,快速,安装使用方便。

    117 引用 • 99 回帖 • 191 关注
  • 百度

    百度(Nasdaq:BIDU)是全球最大的中文搜索引擎、最大的中文网站。2000 年 1 月由李彦宏创立于北京中关村,致力于向人们提供“简单,可依赖”的信息获取方式。“百度”二字源于中国宋朝词人辛弃疾的《青玉案·元夕》词句“众里寻他千百度”,象征着百度对中文信息检索技术的执著追求。

    63 引用 • 785 回帖 • 46 关注
  • IDEA

    IDEA 全称 IntelliJ IDEA,是一款 Java 语言开发的集成环境,在业界被公认为最好的 Java 开发工具之一。IDEA 是 JetBrains 公司的产品,这家公司总部位于捷克共和国的首都布拉格,开发人员以严谨著称的东欧程序员为主。

    182 引用 • 400 回帖
  • webpack

    webpack 是一个用于前端开发的模块加载器和打包工具,它能把各种资源,例如 JS、CSS(less/sass)、图片等都作为模块来使用和处理。

    43 引用 • 130 回帖 • 259 关注
  • abitmean

    有点意思就行了

    44 关注
  • 新人

    让我们欢迎这对新人。哦,不好意思说错了,让我们欢迎这位新人!
    新手上路,请谨慎驾驶!

    52 引用 • 228 回帖
  • 电影

    这是一个不能说的秘密。

    125 引用 • 610 回帖
  • Swift

    Swift 是苹果于 2014 年 WWDC(苹果开发者大会)发布的开发语言,可与 Objective-C 共同运行于 Mac OS 和 iOS 平台,用于搭建基于苹果平台的应用程序。

    34 引用 • 37 回帖 • 565 关注
  • 音乐

    你听到信仰的声音了么?

    63 引用 • 513 回帖
  • TGIF

    Thank God It's Friday! 感谢老天,总算到星期五啦!

    293 引用 • 4496 回帖 • 687 关注
  • TensorFlow

    TensorFlow 是一个采用数据流图(data flow graphs),用于数值计算的开源软件库。节点(Nodes)在图中表示数学操作,图中的线(edges)则表示在节点间相互联系的多维数据数组,即张量(tensor)。

    20 引用 • 19 回帖