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

使用方式

- 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 ===================
})();



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