[js] 外观刮擦

演示

实现类似标记工具的行为,选中含外观(span 标签)段落按下 Alt+X 快捷键不再是叠加样式而是取消设置样式。

仅选中部分无外观时会按照默认行为设置样式。

PixPin20250731151227.gif

使用方式

如何使用代码片段? - 思源笔记社区平台

PixPin20250731151351.png

  • 2025-08-14 支持了段落样式的切换

代码

(function() {
    'use strict';

    const SCRIPT_NAME = 'Siyuan Style Remover v12.9 (Patched)';
    console.log(`--- [${SCRIPT_NAME}] Loaded ---`);

    // Helper functions: Stable and correct.
    function getPathFromNode(base, node) {
        if (!node || !base.contains(node)) return null;
        const path = [];
        let currentNode = node;
        while (currentNode && currentNode !== base) {
            const parent = currentNode.parentNode;
            if (!parent) return null;
            path.unshift(Array.from(parent.childNodes).indexOf(currentNode));
            currentNode = parent;
        }
        return path;
    }

    function getNodeFromPath(base, path) {
        if (!path) return null;
        let node = base;
        for (const index of path) {
            if (node && node.childNodes[index] !== undefined) {
                node = node.childNodes[index];
            } else { return null; }
        }
        return node;
    }

    function saveSelectionPath(blockElement, range) {
        if (!range) return null;
        const startPath = getPathFromNode(blockElement, range.startContainer);
        const endPath = getPathFromNode(blockElement, range.endContainer);
        if (!startPath || !endPath) {
            console.warn(`[${SCRIPT_NAME}] Could not save selection path.`);
            return null;
        }
        return { startPath, startOffset: range.startOffset, endPath, endOffset: range.endOffset };
    }


    /**
     * The definitive core logic: Explicit Node Reconstruction for selected text.
     */
    async function executeSandboxStyleRemoval() {
        const selection = window.getSelection();
        // This function is only for non-collapsed selections.
        if (!selection || !selection.rangeCount || selection.isCollapsed) return;

        const liveRange = selection.getRangeAt(0);
        const startElement = liveRange.startContainer.nodeType === Node.ELEMENT_NODE ? liveRange.startContainer : liveRange.startContainer.parentElement;
        const liveBlockElement = startElement.closest('.p[data-node-id]');
        const protyleElement = liveBlockElement?.closest('.protyle');

        if (!liveBlockElement || !protyleElement) { console.error(`[${SCRIPT_NAME}] Critical error: Context lost.`); return; }
    
        const blockId = liveBlockElement.dataset.nodeId;
        const oldBlockHTML = liveBlockElement.outerHTML;

        const sandboxBlock = liveBlockElement.cloneNode(true);
        const selectionPath = saveSelectionPath(liveBlockElement, liveRange);
        if (!selectionPath) { console.error(`[${SCRIPT_NAME}] Could not save initial selection path. Aborting.`); return; }

        const sandboxRange = document.createRange();
        const sandboxStartContainer = getNodeFromPath(sandboxBlock, selectionPath.startPath);
        const sandboxEndContainer = getNodeFromPath(sandboxBlock, selectionPath.endPath);

        if (!sandboxStartContainer || !sandboxEndContainer) { console.error(`[${SCRIPT_NAME}] Failed to recreate range in sandbox. Aborting.`); return; }
        try {
            sandboxRange.setStart(sandboxStartContainer, selectionPath.startOffset);
            sandboxRange.setEnd(sandboxEndContainer, selectionPath.endOffset);
        } catch (e) { console.error(`[${SCRIPT_NAME}] Error setting range in sandbox. Aborting.`, e); return; }

        // --- THE FINAL SURGERY: EXPLICIT RECONSTRUCTION ---
        const plainText = sandboxRange.toString();
        const markerId = `style-remover-marker-${Date.now()}`;
        const marker = document.createElement('span');
        marker.id = markerId;
    
        const commonAncestor = sandboxRange.commonAncestorContainer;
        const searchElement = commonAncestor.nodeType === Node.ELEMENT_NODE ? commonAncestor : commonAncestor.parentElement;
        const enclosingSpan = searchElement.closest('span[style]:not([style=""])');
    
        // BRANCH 1: The "Explicit Rebuilder" for selections inside a single styled span.
        if (enclosingSpan && enclosingSpan.contains(sandboxRange.startContainer) && enclosingSpan.contains(sandboxRange.endContainer)) {
            const fullSpanText = enclosingSpan.textContent;
            const selectionRangeInSpan = document.createRange();
            selectionRangeInSpan.selectNodeContents(enclosingSpan);
            selectionRangeInSpan.setStart(sandboxRange.startContainer, sandboxRange.startOffset);
            selectionRangeInSpan.setEnd(sandboxRange.endContainer, sandboxRange.endOffset);
        
            const startOffsetInSpan = selectionRangeInSpan.startOffset;
            const endOffsetInSpan = selectionRangeInSpan.endOffset;
        
            const beforeText = fullSpanText.substring(0, startOffsetInSpan);
            const afterText = fullSpanText.substring(endOffsetInSpan);

            const replacementNodes = [];
        
            if (beforeText) {
                const beforeSpan = enclosingSpan.cloneNode(false);
                beforeSpan.textContent = beforeText;
                replacementNodes.push(beforeSpan);
            }
        
            replacementNodes.push(document.createTextNode(plainText), marker);
        
            if (afterText) {
                const afterSpan = enclosingSpan.cloneNode(false);
                afterSpan.textContent = afterText;
                replacementNodes.push(afterSpan);
            }
        
            enclosingSpan.replaceWith(...replacementNodes);
    
        // BRANCH 2: The "General Purge" for selections crossing multiple elements.
        } else {
            sandboxRange.deleteContents();
            sandboxRange.insertNode(marker);
            sandboxRange.insertNode(document.createTextNode(plainText));
            const parent = marker.parentNode;
            if (parent && marker.previousSibling) {
                parent.insertBefore(marker, marker.previousSibling.nextSibling);
            }
        }
    
        sandboxBlock.normalize();
    
        const newBlockHTML = sandboxBlock.outerHTML;
    
        if (newBlockHTML.replace(/<span id="[^"]*style-remover-marker[^"]*"><\/span>/g, '') === oldBlockHTML) {
            return; // Abort if no meaningful change occurred.
        }

        // --- Transaction & Restore ---
        const protyleId = protyleElement.dataset.id;
        const payload = {
            session: protyleId, app: window.siyuan.config.system.id, reqId: Date.now(),
            transactions: [{
                doOperations: [{ action: 'update', id: blockId, data: newBlockHTML }],
                undoOperations: [{ action: 'update', id: blockId, data: oldBlockHTML }]
            }]
        };
        await fetch('/api/transactions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });

        setTimeout(() => {
            const newBlockInDOM = document.querySelector(`.protyle-content [data-node-id="${blockId}"]`);
            if (newBlockInDOM) {
                const markerInDOM = newBlockInDOM.querySelector(`#${markerId}`);
                if (markerInDOM) {
                    const rangeToRestore = document.createRange();
                    rangeToRestore.setStartBefore(markerInDOM);
                    rangeToRestore.collapse(true);
                    markerInDOM.remove();
                    window.getSelection().removeAllRanges();
                    window.getSelection().addRange(rangeToRestore);
                } else { console.warn(`[${SCRIPT_NAME}] Could not find marker in updated block.`); }
            } else { console.warn(`[${SCRIPT_NAME}] Could not find block after transaction.`); }
        }, 100);
    }
  
    // ================== PATCH START ==================
    /**
     * New function to remove style from the parent block container (div.p).
     * This is triggered when the selection is collapsed (i.e., it's a cursor).
     */
    async function removeContainerStyle(liveBlockElement) {
        const selection = window.getSelection();
        if (!selection || !selection.rangeCount) return;
    
        const liveRange = selection.getRangeAt(0);
        const protyleElement = liveBlockElement.closest('.protyle');

        if (!protyleElement) { console.error(`[${SCRIPT_NAME}] Critical error: Context lost.`); return; }
    
        // Save cursor position before modification
        const selectionPath = saveSelectionPath(liveBlockElement, liveRange);
        if (!selectionPath) { console.error(`[${SCRIPT_NAME}] Could not save cursor path. Aborting.`); return; }

        const blockId = liveBlockElement.dataset.nodeId;
        const oldBlockHTML = liveBlockElement.outerHTML;

        // Create a clone, remove the style attribute, and get the new HTML
        const sandboxBlock = liveBlockElement.cloneNode(true);
        sandboxBlock.removeAttribute('style');
        const newBlockHTML = sandboxBlock.outerHTML;
    
        if (newBlockHTML === oldBlockHTML) {
            console.log(`[${SCRIPT_NAME}] No style to remove from container.`);
            return; // Abort if no change occurred.
        }

        // --- Transaction & Restore ---
        const protyleId = protyleElement.dataset.id;
        const payload = {
            session: protyleId, app: window.siyuan.config.system.id, reqId: Date.now(),
            transactions: [{
                doOperations: [{ action: 'update', id: blockId, data: newBlockHTML }],
                undoOperations: [{ action: 'update', id: blockId, data: oldBlockHTML }]
            }]
        };
        await fetch('/api/transactions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });

        // Restore cursor position after DOM update
        setTimeout(() => {
            const newBlockInDOM = document.querySelector(`.protyle-content [data-node-id="${blockId}"]`);
            if (newBlockInDOM) {
                const rangeToRestore = document.createRange();
                const startContainer = getNodeFromPath(newBlockInDOM, selectionPath.startPath);
                const endContainer = getNodeFromPath(newBlockInDOM, selectionPath.endPath);

                if (startContainer && endContainer) {
                    try {
                        rangeToRestore.setStart(startContainer, selectionPath.startOffset);
                        rangeToRestore.setEnd(endContainer, selectionPath.endOffset);
                        selection.removeAllRanges();
                        selection.addRange(rangeToRestore);
                    } catch (e) {
                        console.error(`[${SCRIPT_NAME}] Failed to restore cursor position.`, e);
                    }
                } else {
                    console.warn(`[${SCRIPT_NAME}] Could not find cursor path in updated block.`);
                }
            } else {
                console.warn(`[${SCRIPT_NAME}] Could not find block after transaction.`);
            }
        }, 100);
    }
  
    // Listener is now bifurcated to handle both collapsed and non-collapsed selections.
    document.addEventListener('keydown', (event) => {
        const isOurShortcut = event.altKey && !event.ctrlKey && !event.shiftKey && !event.metaKey && event.key.toLowerCase() === 'x';
        if (!isOurShortcut) return;

        const selection = window.getSelection();
        if (!selection || !selection.rangeCount) return;

        const range = selection.getRangeAt(0);
        const startElement = range.startContainer.nodeType === Node.ELEMENT_NODE ? range.startContainer : range.startContainer.parentElement;
        const blockElement = startElement?.closest('.p[data-node-id]');
        if (!blockElement) return;
    
        if (selection.isCollapsed) {
            // CASE 1: No text is selected (cursor only). Target the container block.
            if (blockElement.hasAttribute('style') && blockElement.getAttribute('style') !== "") {
                event.preventDefault();
                event.stopImmediatePropagation();
                removeContainerStyle(blockElement);
            }
        } else {
            // CASE 2: Text is selected. Target styled spans within the selection.
            const isContainedInStyledSpan = startElement?.closest('span[style]:not([style=""])');
            const intersectsStyledSpan = Array.from(blockElement.querySelectorAll('span[style]:not([style=""])')).some(span => {
                try { return range.intersectsNode(span); } catch (e) { return false; }
            });
        
            if (isContainedInStyledSpan || intersectsStyledSpan) {
                event.preventDefault();
                event.stopImmediatePropagation();
                executeSandboxStyleRemoval();
            }
        }
    }, { capture: true });
    // =================== PATCH END ===================

})();
  • 思源笔记

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

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

    28446 引用 • 119790 回帖
  • 代码片段

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

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

    285 引用 • 1988 回帖
1 操作
xqh042 在 2025-08-14 18:16:29 更新了该帖

相关帖子

欢迎来到这里!

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

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