[js] 适配任何主题的 Callout

制作原因

  • 习惯于 Obsidian 和 Github 的 Callout 样式
  • 目前思源笔记的 Callout 使用方法不习惯(很麻烦)

特点

  1. 自动处理样式,不打断输入
  2. 纯 Javascript +CSS 实现,便于移植

使用方法

先把 Javascript 代码片段放到 设置-外观-代码片段-设置-JS 部分里。

再把 CSS 代码片段放到 设置-外观-代码片段-设置-CSS 部分里。

输入时和其他 callout 使用方法一样。

注:大小写无影响。

  • note 样式,输入 >[!note]
  • tip 样式,输入 >[!tip]
  • important 样式,输入 >[!important]
  • warning 样式,输入 >[!warning]
  • caution 样式,输入 >[!caution]

演示 GIF

callouttest.gif

代码

以下是 JavaScript

// 在文件顶部添加日志,确认加载
console.log("脚本文件开始加载!(v15.1 - Commented out Debug Logs)"); // 版本号更新

// Debounce 工具函数 (带日志)
function debounce(func, wait) {
  let timeout;
  let funcName = func.name || 'anonymous';
  return function(...args) {
    const context = this;
    const callTime = Date.now();
    clearTimeout(timeout);
    timeout = setTimeout(() => {
        const execTime = Date.now();
        const id = args[0] || 'Unknown'; // Assume first arg is mixedId
        // console.log(`[Debounce] 执行 ${funcName} for ID: ${id} at ${execTime} (距离上次请求 ${execTime - callTime}ms).`); // DEBUG
        func.apply(context, args);
    }, wait);
  };
}

// --- 状态跟踪 (统一,使用混合ID 和 上下文 Node ID) ---
const initializedContainers = new Set();         // 存储已初始化的容器 ID (docId or "preview_oid_Llevel")
const potentialBqBlocks = new Map();           // Map: mixedId -> Set<contextualNodeId> (e.g., "doc123||nodeABC")
const successfullyProcessedBlocks = new Map(); // Map: mixedId -> Set<contextualNodeId>
const processDebouncers = new Map();           // Map: mixedId -> debouncedFunction
const retryCounters = new Map();               // Map: mixedId -> retryCount

// --- 状态跟踪 Flags ---
let observer = null; // 保存 Observer 实例

// --- 常量 ---
const PROCESSING_DEBOUNCE_WAIT = 300; // ms - 处理队列的防抖延迟
const MAIN_DOC_INIT_SCAN_DELAY = 0;   // ms - 主文档检测到 finished 后,延迟多久进行扫描 (可为 0)
const PREVIEW_INIT_SCAN_DELAY = 50;   // ms - 预览窗口检测到 finished 后,延迟多久进行扫描 (推荐 > 0)
const RETRY_DELAY = 500;              // ms - 评估失败后的重试延迟
const MAX_RETRIES = 5;                // 最大重试次数
const PREVIEW_ID_PREFIX = "preview_"; // 用于预览窗口的混合 ID 前缀
const ID_SEPARATOR = "||";            // 用于分隔 mixedId 和 nodeId 的分隔符
const LOG_SEPARATOR = "____";         // 用于特定日志格式的分隔符
const LEVEL_SEPARATOR = "_L";

/** 清理元素样式 (通用) */
function clearCalloutStyles(element) {
    const nodeId = element.dataset.nodeId || 'Unknown ID';
    delete element.dataset.calloutType; delete element.dataset.calloutProcessed;
    element.classList.remove('callout', 'callout-default', 'callout-note', 'callout-tip', 'callout-important', 'callout-warning', 'callout-caution');
    element.querySelectorAll('[data-callout-title]').forEach(el => delete el.dataset.calloutTitle);
    // console.log(`[ClearStyles ${nodeId}] Styles cleared.`); // DEBUG
}

/** 构造上下文 Node ID */
function createContextualNodeId(mixedId, nodeId) {
    return `${mixedId}${ID_SEPARATOR}${nodeId}`;
}

/** 解析上下文 Node ID */
function parseContextualNodeId(contextualNodeId) {
    const parts = contextualNodeId.split(ID_SEPARATOR);
    if (parts.length === 2) { return { mixedId: parts[0], nodeId: parts[1] }; }
    console.warn(`[Parse ID] Invalid contextualNodeId format: ${contextualNodeId}`); // 保留警告
    return null;
}

/** 解析 Preview Mixed ID */
function parsePreviewMixedId(mixedId) {
    if (!mixedId || !mixedId.startsWith(PREVIEW_ID_PREFIX)) {
        return null;
    }
    const parts = mixedId.substring(PREVIEW_ID_PREFIX.length).split(LEVEL_SEPARATOR);
    if (parts.length === 2) {
        return { oid: parts[0], level: parts[1] };
    }
    console.warn(`[Parse Preview ID] Invalid preview mixedId format: ${mixedId}`); // 保留警告
    return null;
}

/** 获取元素所属容器 Mixed ID (v15.0: Preview ID 包含 level) */
function getElementContainerMixedId(element) {
    const popoverEl = element.closest('.block__popover[data-oid][data-level]');
    if (popoverEl) {
        const oid = popoverEl.dataset.oid;
        const level = popoverEl.dataset.level;
        if (oid && level) {
            return `${PREVIEW_ID_PREFIX}${oid}${LEVEL_SEPARATOR}${level}`;
        } else {
             console.warn("[Get ID] Preview popover found but missing oid or level.", popoverEl); // 保留警告
             return null;
        }
    } else {
        const protyleEl = element.closest('.protyle[data-id]');
        return protyleEl?.dataset.id || null;
    }
}

/** 评估 Blockquote (通用) */
function evaluateBlockquote(bqElement, mixedId) {
    const nodeId = bqElement?.dataset?.nodeId;
    if (!bqElement || !(bqElement instanceof HTMLElement) || !nodeId || !mixedId || !initializedContainers.has(mixedId)) {
        // console.warn(`[Evaluate ${mixedId}] Basic check failed.`); // DEBUG
        return false;
    }
    const contextualNodeId = createContextualNodeId(mixedId, nodeId);
    const successSet = successfullyProcessedBlocks.get(mixedId);
    const potentialSet = potentialBqBlocks.get(mixedId);
    if (bqElement.dataset.type !== 'NodeBlockquote') {
        if (successSet?.has(contextualNodeId) || bqElement.dataset.calloutProcessed) {
            // console.log(`[Evaluate ${mixedId}] Node ${nodeId}: Type changed. Clearing.`); // DEBUG
            clearCalloutStyles(bqElement);
            successSet?.delete(contextualNodeId);
        }
        potentialSet?.delete(contextualNodeId);
        return false;
    }
    let textContainer = null; let foundViaPath = 'None';
    const firstP = bqElement.querySelector(':scope > div[data-type="NodeParagraph"]:first-of-type');
    if (firstP) { textContainer = firstP.firstElementChild; if (textContainer) foundViaPath = 'Path 1 (Standard)'; }
    if (!textContainer) { const firstDivP = bqElement.querySelector(':scope > div.p:first-of-type'); if (firstDivP) { textContainer = firstDivP.firstElementChild; if(textContainer) foundViaPath = 'Path 2 (Compat .p)'; } }
    if (!textContainer) { textContainer = bqElement.querySelector(':scope > div[contenteditable="true"]'); if (textContainer) { if(!textContainer.closest('.protyle-attr')) { foundViaPath = 'Path 3 (Direct ContentEditable)'; } else { textContainer = null; } } }
    if (!textContainer || textContainer.nodeType !== Node.ELEMENT_NODE || textContainer.classList.contains('protyle-attr')) {
        // console.log(`[Evaluate ${mixedId}] Node ${nodeId}: No suitable text container (Path: ${foundViaPath}).`); // DEBUG
        if (bqElement.dataset.calloutProcessed === 'true') { clearCalloutStyles(bqElement); }
        successSet?.delete(contextualNodeId);
        return false;
    }
    // console.log(`[Evaluate ${mixedId}] Node ${nodeId}: Found text container via ${foundViaPath}.`); // DEBUG
    const textContent = textContainer.textContent?.trim() ?? '';
    const match = textContent.match(/^\[!([a-zA-Z]+)\]$/i);
    const validTypes = ['note', 'tip', 'important', 'warning', 'caution'];
    let determinedCalloutType = 'default';
    if (match && match[1]) { const potentialType = match[1].toLowerCase(); if (validTypes.includes(potentialType)) { determinedCalloutType = potentialType; } }
    const currentCalloutType = bqElement.dataset.calloutType;
    const needsUpdate = bqElement.dataset.calloutProcessed !== 'true' || currentCalloutType !== determinedCalloutType;
    if (needsUpdate) {
        // console.log(`[Evaluate ${mixedId}] Node ${nodeId}: Applying/Updating type to '${determinedCalloutType}'.`); // DEBUG
        clearCalloutStyles(bqElement);
        bqElement.dataset.calloutType = determinedCalloutType;
        bqElement.classList.add('callout', `callout-${determinedCalloutType}`);
        // console.log(`[Style Applied] ${mixedId}${LOG_SEPARATOR}${nodeId} (Type: ${determinedCalloutType})`); // DEBUG (Style Applied Log)
        bqElement.dataset.calloutProcessed = 'true';
        const titleDiv = firstP?.querySelector(':scope > div[contenteditable="true"]');
        if (titleDiv) titleDiv.dataset.calloutTitle = 'true';
    } else {
        // console.log(`[Evaluate ${mixedId}] Node ${nodeId}: No style update needed for type '${determinedCalloutType}'.`); // DEBUG
        const titleDiv = firstP?.querySelector(':scope > div[contenteditable="true"][data-callout-title="true"]');
        if(!titleDiv) {
            const targetTitleDiv = firstP?.querySelector(':scope > div[contenteditable="true"]');
            if (targetTitleDiv) targetTitleDiv.dataset.calloutTitle = 'true';
            // console.log(`[Evaluate ${mixedId}] Node ${nodeId}: Re-applied missing data-callout-title.`); // DEBUG
        }
    }
    return true;
}

/** Debounced Processing with Retry Logic (Unified) */
function processPotentialBqBlocksForContainer(mixedId) {
     if (!initializedContainers.has(mixedId)) return;
    const potentialBlocks = potentialBqBlocks.get(mixedId);
    const successBlocks = successfullyProcessedBlocks.get(mixedId);
    if (!potentialBlocks || !successBlocks) { console.error(`[ProcessQueue ${mixedId}] Error: Missing state maps.`); return; } // 保留错误
    // console.log(`[ProcessQueue EXEC ${mixedId}] Queue size: ${potentialBlocks.size}. Content: [${Array.from(potentialBlocks).join(', ')}]`); // DEBUG
    if (potentialBlocks.size === 0) { retryCounters.delete(mixedId); return; }
    const processedIdsThisRun = new Set(); const failedIdsThisRun = new Set(); const blocksToProcess = new Set(potentialBlocks);
    blocksToProcess.forEach(contextualNodeId => {
        processedIdsThisRun.add(contextualNodeId);
        const parsed = parseContextualNodeId(contextualNodeId);
        if (!parsed || parsed.mixedId !== mixedId) {
            // console.warn(`[ProcessQueue ${mixedId}] Mismatch/Invalid ID: ${contextualNodeId}. Removing.`); // DEBUG
            potentialBlocks.delete(contextualNodeId); return;
        }
        const { nodeId: originalNodeId } = parsed;
        const blockElement = document.querySelector(`[data-node-id="${originalNodeId}"]`);
        let actualContainerId = null;
        if (blockElement) actualContainerId = getElementContainerMixedId(blockElement);
        if (blockElement && actualContainerId === mixedId) {
            try {
                const evaluationResult = evaluateBlockquote(blockElement, mixedId);
                // console.log(`[ProcessQueue EVAL ${mixedId}] Node ${originalNodeId}: evaluateBlockquote returned ${evaluationResult}`); // DEBUG
                if (evaluationResult) { successBlocks.add(contextualNodeId); potentialBlocks.delete(contextualNodeId); failedIdsThisRun.delete(contextualNodeId); }
                else { failedIdsThisRun.add(contextualNodeId); successBlocks.delete(contextualNodeId); }
            } catch (error) { console.error(`[ProcessQueue EXEC LOOP ${mixedId}] Node ${originalNodeId}: Error:`, error); successBlocks.delete(contextualNodeId); failedIdsThisRun.add(contextualNodeId); } // 保留错误
        } else {
             // console.log(`[ProcessQueue SKIP ${mixedId}] Node ${originalNodeId}: Element not found or container mismatch (Expected: ${mixedId}, Found: ${actualContainerId}). Removing.`); // DEBUG
             successBlocks.delete(contextualNodeId); potentialBlocks.delete(contextualNodeId); failedIdsThisRun.delete(contextualNodeId);
        }
    });
    if (potentialBlocks.size > 0) {
        let currentRetries = retryCounters.get(mixedId) || 0;
        if (currentRetries < MAX_RETRIES) {
            currentRetries++; retryCounters.set(mixedId, currentRetries);
            // console.log(`[ProcessQueue RETRY ${mixedId}] Scheduling retry ${currentRetries}/${MAX_RETRIES} in ${RETRY_DELAY}ms. Remaining: ${potentialBlocks.size}`); // DEBUG
            setTimeout(() => { if (initializedContainers.has(mixedId)) { const processor = getDebouncedProcessor(mixedId); if (processor) processor(mixedId); } else { /*console.log(`[ProcessQueue RETRY ${mixedId}] Container closed before retry.`);*/ retryCounters.delete(mixedId); } }, RETRY_DELAY); // DEBUG in comment
        } else { console.warn(`[ProcessQueue RETRY ${mixedId}] Max retries reached. ${potentialBlocks.size} blocks unprocessed.`); retryCounters.delete(mixedId); } // 保留警告
    } else {
        retryCounters.delete(mixedId);
        // console.log(`[ProcessQueue EXEC ${mixedId}] Finished run. Potential queue empty.`); // DEBUG
    }
    // console.log(`[ProcessQueue EXEC ${mixedId}] Finished run. Success: ${successBlocks.size}, Remaining potential: ${potentialBlocks.size}`); // DEBUG
}

/** Function to get or create a debounced processor for a specific mixedId */
function getDebouncedProcessor(mixedId) {
    if (!processDebouncers.has(mixedId)) {
        const debouncedFunc = debounce(processPotentialBqBlocksForContainer, PROCESSING_DEBOUNCE_WAIT);
        processDebouncers.set(mixedId, debouncedFunc);
        // console.log(`[Debounce] Created debouncer for ${mixedId}`); // DEBUG
    }
    return processDebouncers.get(mixedId);
}

// --- Initialization and Cleanup Logic (Unified) ---

/** Runs the initial scan for blocks within a newly initialized container. */
function runScanForContainer(mixedId) {
    if (!initializedContainers.has(mixedId)) {
        // console.log(`[Initialize Scan ${mixedId}] Aborted, container no longer initialized.`); // DEBUG
        return;
    }
    const potentialBlocks = potentialBqBlocks.get(mixedId);
    const successBlocks = successfullyProcessedBlocks.get(mixedId);
    if (!potentialBlocks || !successBlocks) { console.error(`[Initialize Scan ${mixedId}] Error: Missing state maps.`); return; } // 保留错误

    let containerElement = null; let selector = '';
    if (mixedId.startsWith(PREVIEW_ID_PREFIX)) {
        const parsedPreviewId = parsePreviewMixedId(mixedId);
        if (parsedPreviewId) {
            const { oid, level } = parsedPreviewId;
            selector = `.block__popover[data-oid="${oid}"][data-level="${level}"] .protyle[data-loading="finished"]`;
            containerElement = document.querySelector(selector);
        } else { console.error(`[Initialize Scan ${mixedId}] Failed to parse preview mixedId.`); return; } // 保留错误
    } else {
        selector = `.protyle[data-id="${mixedId}"][data-loading="finished"]`;
        containerElement = document.querySelector(selector);
    }

    let initialBlocks = [];
    if (containerElement) {
        initialBlocks = containerElement.querySelectorAll(':scope div.bq[data-node-id]');
        // console.log(`[Initialize Scan ${mixedId}] Found container: ${selector}. Found ${initialBlocks.length} BQ blocks.`); // DEBUG
    } else {
        console.warn(`[Initialize Scan ${mixedId}] Could not find container element using selector: ${selector}`); // 保留警告
        return;
    }

    let addedCount = 0;
    initialBlocks.forEach((bqElement) => {
        const nodeId = bqElement.dataset.nodeId;
        if (nodeId) { const contextualNodeId = createContextualNodeId(mixedId, nodeId); if (!successBlocks.has(contextualNodeId) && !potentialBlocks.has(contextualNodeId)) { potentialBlocks.add(contextualNodeId); addedCount++; } }
        else { console.warn(`[Initialize Scan ${mixedId}] Block skipped, missing data-node-id.`); } // 保留警告
    });
    // console.log(`[Initialize Scan ${mixedId}] Scan finished. Added ${addedCount} new blocks. Queue size: ${potentialBlocks.size}`); // DEBUG

    if (potentialBlocks.size > 0) {
        // console.log(`[Initialize Scan ${mixedId}] Triggering process run for ${potentialBlocks.size} potential blocks.`); // DEBUG
        const processor = getDebouncedProcessor(mixedId);
        processor(mixedId);
    }
}

/** Initializes a container (doc or preview) */
function initializeContainer(mixedId) {
    if (initializedContainers.has(mixedId)) return;
    // 保留这个主要的生命周期日志
    console.log(`>>> [Initialize Container ${mixedId}] Starting initialization <<<`);
    initializedContainers.add(mixedId);
    potentialBqBlocks.set(mixedId, new Set());
    successfullyProcessedBlocks.set(mixedId, new Set());
    retryCounters.delete(mixedId);

    const delay = mixedId.startsWith(PREVIEW_ID_PREFIX) ? PREVIEW_INIT_SCAN_DELAY : MAIN_DOC_INIT_SCAN_DELAY;
    // console.log(`[Initialize Container ${mixedId}] Scheduling initial scan in ${delay}ms.`); // DEBUG
    setTimeout(() => runScanForContainer(mixedId), delay);
}

/** Cleans up state for a specific container */
function cleanupContainer(mixedId) {
    if (!initializedContainers.has(mixedId)) return;
     // 保留这个主要的生命周期日志
    console.log(`>>> [Cleanup Container ${mixedId}] Cleaning up state <<<`);
    initializedContainers.delete(mixedId);
    potentialBqBlocks.delete(mixedId);
    successfullyProcessedBlocks.delete(mixedId);
    retryCounters.delete(mixedId);
    const debouncer = processDebouncers.get(mixedId);
    if (debouncer && typeof debouncer.cancel === 'function') { debouncer.cancel(); }
    processDebouncers.delete(mixedId);
    // console.log(`[Cleanup Container ${mixedId}] State maps and debouncer removed.`); // DEBUG
}

// --- MutationObserver Setup ---
function setupObserver() {
    if (observer) { observer.disconnect(); observer = null; }
    // 保留 Observer 设置日志
    console.log(">>> [Setup Observer v15.1] Setting up MutationObserver <<<"); // 版本更新
    const targetNode = document.body;
    if (!targetNode) { console.error("[Setup Observer] Observer target (document.body) not found!"); return; } // 保留错误

    const config = { childList: true, subtree: true, attributes: true, attributeFilter: ['data-loading', 'data-oid', 'data-level', 'class', 'data-type', 'data-node-id'], characterData: true };

    const callback = function(mutationsList, obs) {
        const timeStamp = Date.now();
        let containersToProcess = new Set();

        for (const mutation of mutationsList) {

            // --- 1. Detect Container Load/Unload ---
            if (mutation.type === 'attributes' && mutation.attributeName === 'data-loading') {
                const targetElement = mutation.target;
                if (targetElement?.matches?.('.protyle')) {
                    const protyleEl = targetElement;
                    const containerId = getElementContainerMixedId(protyleEl);
                    const currentLoadingState = protyleEl.dataset.loading;

                    // v15.1: 注释掉详细的属性变化调试日志
                    // let sourceDesc = 'Unknown Container';
                    // if (containerId) { ... } else { ... }
                    // console.log(`[Observer Debug - Attr] data-loading changed on .protyle within ${sourceDesc}. New state: ${currentLoadingState}. Calculated containerId: ${containerId}`); // DEBUG

                    if (containerId) {
                        const isInitialized = initializedContainers.has(containerId);
                        // console.log(`[Observer Debug - Attr] Container ${containerId} initialized status: ${isInitialized}`); // DEBUG

                        if (currentLoadingState === 'finished' && !isInitialized) {
                            // console.log(`[Observer Debug - Attr] Triggering initializeContainer for ${containerId}`); // DEBUG
                            initializeContainer(containerId);
                        } else if (currentLoadingState !== 'finished' && isInitialized) {
                            // console.log(`[Observer Debug - Attr] Triggering cleanupContainer for ${containerId}`); // DEBUG
                            cleanupContainer(containerId);
                        } else if (currentLoadingState === 'finished' && isInitialized) {
                             // console.log(`[Observer Debug - Attr] Container ${containerId} already initialized. Ignoring 'finished' signal.`); // DEBUG
                        }
                    }
                }
            }
            // --- 2. Detect Preview Add/Remove ---
            else if (mutation.type === 'childList') {
                mutation.addedNodes.forEach(node => {
                    if (node.nodeType === Node.ELEMENT_NODE && node.matches?.('.block__popover[data-oid][data-level]')) {
                        const oid = node.dataset.oid;
                        const level = node.dataset.level;
                        // console.log(`[Observer Debug - ChildList] Detected Preview Popover ADDED (OID: ${oid}, Level: ${level})`); // DEBUG
                    }
                 });
                mutation.removedNodes.forEach(node => {
                    if (node.nodeType === Node.ELEMENT_NODE) {
                        if (node.matches?.('.block__popover[data-oid][data-level]')) {
                            const oid = node.dataset.oid;
                            const level = node.dataset.level;
                            const mixedId = `${PREVIEW_ID_PREFIX}${oid}${LEVEL_SEPARATOR}${level}`;
                            // console.log(`[Observer Debug - ChildList] Detected Preview Popover REMOVED (OID: ${oid}, Level: ${level}). Cleaning up container: ${mixedId}`); // DEBUG
                            cleanupContainer(mixedId);
                        }
                        else if (node.matches?.('.protyle[data-id]')) {
                            const docId = node.dataset.id;
                            if (initializedContainers.has(docId)) {
                                // console.log(`[Observer Debug - ChildList] Detected Doc Protyle REMOVED (${docId}). Cleaning up.`); // DEBUG
                                cleanupContainer(docId);
                             }
                        }
                    }
                });
            }

            // --- 3. Process Block Changes ---
            let affectedBqElement = null; let targetElement = mutation.target;
            if (mutation.type === 'childList') { const nodes = [...mutation.addedNodes, ...mutation.removedNodes]; for (const node of nodes) { if (node.nodeType === Node.ELEMENT_NODE) { if (node.matches && node.matches('div.bq[data-node-id]')) { affectedBqElement = node; break; } const parentBq = node.parentElement?.closest('div.bq[data-node-id]'); if (parentBq) { affectedBqElement = parentBq; break; } } else if (node.nodeType === Node.TEXT_NODE) { affectedBqElement = node.parentElement?.closest('div.bq[data-node-id]'); if (affectedBqElement) break; } } if (!affectedBqElement && targetElement.nodeType === Node.ELEMENT_NODE && targetElement.matches('div.bq[data-node-id]')) { affectedBqElement = targetElement; } }
            else if (mutation.type === 'attributes') { if (targetElement.nodeType === Node.ELEMENT_NODE) { affectedBqElement = targetElement.closest('div.bq[data-node-id]'); } }
            else if (mutation.type === 'characterData') { if (targetElement.parentElement) { affectedBqElement = targetElement.parentElement.closest('div.bq[data-node-id]'); } }

            if (affectedBqElement && affectedBqElement.dataset.nodeId) {
                const nodeId = affectedBqElement.dataset.nodeId;
                const containerId = getElementContainerMixedId(affectedBqElement);
                if (containerId && initializedContainers.has(containerId)) {
                    const potentialBlocks = potentialBqBlocks.get(containerId);
                    const successBlocks = successfullyProcessedBlocks.get(containerId);
                    const contextualNodeId = createContextualNodeId(containerId, nodeId);
                    if (potentialBlocks && successBlocks) {
                        // console.log(`[Observer ${timeStamp}] Node ${nodeId} (Container ${containerId}): Change detected. Adding to potential queue.`); // DEBUG
                        if (successBlocks.has(contextualNodeId)) { successBlocks.delete(contextualNodeId); }
                        if (!potentialBlocks.has(contextualNodeId)) { potentialBlocks.add(contextualNodeId); }
                        containersToProcess.add(containerId);
                        if (mutation.type === 'attributes' && mutation.attributeName === 'data-type' && affectedBqElement.dataset.type !== 'NodeBlockquote') {
                             // console.log(`[Observer ${timeStamp}] Node ${nodeId} (Container ${containerId}): Type changed away. Cleaning up.`); // DEBUG
                             clearCalloutStyles(affectedBqElement); successBlocks.delete(contextualNodeId); potentialBlocks.delete(contextualNodeId); containersToProcess.delete(containerId);
                        }
                    }
                } /* else { // DEBUG
                    if(containerId) console.log(`[Observer ${timeStamp}] Node ${nodeId} (Container ${containerId}): Change detected, but container not initialized.`);
                    else console.log(`[Observer ${timeStamp}] Node ${nodeId}: Change detected, but could not determine valid container.`);
                } */
            }
        } // End mutation loop

        containersToProcess.forEach(mixedId => {
            if (initializedContainers.has(mixedId)) {
                const processor = getDebouncedProcessor(mixedId);
                if (processor) {
                     // console.log(`[Observer Trigger] Triggering debounced processor for ${mixedId}`); // DEBUG
                     processor(mixedId);
                }
            }
        });
    };

    observer = new MutationObserver(callback);
    try {
        observer.observe(targetNode, config);
        // 保留启动成功日志
        console.log("[Setup Observer v15.1] MutationObserver started successfully.");
    } catch (error) {
        console.error("[Setup Observer v15.1] Failed to start MutationObserver:", error); // 保留错误
        observer = null;
    }
}

function startup() {
    // 保留启动日志
    console.log(">>> [Startup v15.1] Initializing or restarting script <<<"); // 版本更新
    initializedContainers.clear(); potentialBqBlocks.clear(); successfullyProcessedBlocks.clear();
    processDebouncers.clear(); retryCounters.clear();
    if (observer) {
        // console.log("[Startup v15.1] Disconnecting existing observer."); // DEBUG
        observer.disconnect();
        observer = null;
    }
    setupObserver();
}

// --- 启动逻辑 ---
// 保留 DOM 状态检查日志
console.log("正在检查 document.readyState:", document.readyState);
if (document.readyState === 'loading') {
    console.log("DOM not ready, adding DOMContentLoaded listener.");
    document.addEventListener('DOMContentLoaded', startup);
} else {
    console.log("DOM ready, executing startup directly.");
    startup();
}
// 保留脚本结束日志
console.log("Script execution finished. (v15.1)"); // 版本更新

以下是 CSS

/* --- Base Callout Styles (Applied to any block with data-callout-type) --- */
.bq[data-callout-type] {
    /* Common styles like padding, border-radius, text color */
    padding: 15px !important;
    border-radius: 0;
    color: var(--b3-theme-on-background);
    /* Add any other common styles here */
}

/* Style the first paragraph (title container) */
.bq[data-callout-type] > div[data-type="NodeParagraph"]:first-of-type {
    margin-bottom: 0.5em; /* Adjust spacing between title and content if needed */
}

/* Style the contenteditable div within the first paragraph (where [!TYPE] was) */
.bq[data-callout-type] > div[data-type="NodeParagraph"]:first-of-type > div[contenteditable="true"] {
    display: inline-flex; /* Used for aligning icon and text */
    align-items: center;
    font-weight: bold; /* Optional: Make the title bold */
    margin-bottom: 0; /* Prevent default paragraph margins on this specific div */
}

/* --- Specific Callout Types (Using data-callout-type) --- */

/* Note */
.bq[data-callout-type="note"] {
    border-left: .25em solid #1f6feb;
    background-color: #1f71eb16 !important;
}

/* Select the specific inner div for the icon and title color */
.bq[data-callout-type="note"] > div[data-type="NodeParagraph"]:first-of-type > div[contenteditable="true"] {
    color: #4493f8; /* Title text color */
}

/* Style the icon using ::before */
.bq[data-callout-type="note"] > div[data-type="NodeParagraph"]:first-of-type > div[contenteditable="true"]::before {
    content: ""; /* Required for ::before */
    display: inline-block;
    overflow: visible !important;
    vertical-align: middle;
    width: 22px;
    height: 22px;
    mask: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyOCIgaGVpZ2h0PSIyOCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIxLjcxNDI4NTcxNDI4NTcxNDIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgY2xhc3M9Imx1Y2lkZSBsdWNpZGUtY2lyY2xlLWFsZXJ0Ij48Y2lyY2xlIGN4PSIxMiIgY3k9IjEyIiByPSIxMCIvPjxsaW5lIHgxPSIxMiIgeDI9IjEyIiB5MT0iOCIgeTI9IjEyIi8+PGxpbmUgeDE9IjEyIiB4Mj0iMTIuMDEiIHkxPSIxNiIgeTI9IjE2Ii8+PC9zdmc+");
    mask-size: cover;
    background-color: currentColor; /* Inherits color from the parent (title text color) */
    margin-right: 10px;
    mask-repeat: no-repeat;
}

/* Tip */
.bq[data-callout-type="tip"] {
    border-left: .25em solid #238636;
    background-color: #23863615 !important;
}
.bq[data-callout-type="tip"] > div[data-type="NodeParagraph"]:first-of-type > div[contenteditable="true"] {
    color: #238636;
}
.bq[data-callout-type="tip"] > div[data-type="NodeParagraph"]:first-of-type > div[contenteditable="true"]::before {
    content: "";
    display: inline-block;
    overflow: visible !important;
    vertical-align: middle;
    width: 22px;
    height: 22px;
    mask: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyOCIgaGVpZ2h0PSIyOCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIxLjcxNDI4NTcxNDI4NTcxNDIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgY2xhc3M9Imx1Y2lkZSBsdWNpZGUtbGlnaHRidWxiIj48cGF0aCBkPSJNMTUgMTRjLjItMSAuNy0xLjcgMS41LTIuNSAxLS45IDEuNS0yLjIgMS41LTMuNUE2IDYgMCAwIDAgNiA4YzAgMSAuMiAyLjIgMS41IDMuNS43LjcgMS4zIDEuNSAxLjUgMi41Ii8+PHBhdGggZD0iTTkgMThoNiIvPjxwYXRoIGQ9Ik0xMCAyMmg0Ii8+PC9zdmc+");
    mask-size: cover;
    background-color: currentColor;
    margin-right: 10px;
    mask-repeat: no-repeat;
}

/* Important */
.bq[data-callout-type="important"] {
    border-left: .25em solid #8957e5;
    background-color: #8957e515 !important;
}
.bq[data-callout-type="important"] > div[data-type="NodeParagraph"]:first-of-type > div[contenteditable="true"] {
    color: #8957e5;
}
.bq[data-callout-type="important"] > div[data-type="NodeParagraph"]:first-of-type > div[contenteditable="true"]::before {
    content: "";
    display: inline-block;
    overflow: visible !important;
    vertical-align: middle;
    width: 22px;
    height: 22px;
    mask: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyOCIgaGVpZ2h0PSIyOCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIxLjcxNDI4NTcxNDI4NTcxNDIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgY2xhc3M9Imx1Y2lkZSBsdWNpZGUtbWVzc2FnZS1zcXVhcmUtd2FybmluZyI+PHBhdGggZD0iTTIxIDE1YTIgMiAwIDAgMS0yIDJIN2wtNCA0VjVhMiAyIDAgMCAxIDItMmgxNGEyIDIgMCAwIDEgMiAyeiIvPjxwYXRoIGQ9Ik0xMiA3djIiLz48cGF0aCBkPSJNMTIgMTNoLjAxIi8+PC9zdmc+");
    mask-size: cover;
    background-color: currentColor;
    margin-right: 10px;
    mask-repeat: no-repeat;
}

/* Warning */
.bq[data-callout-type="warning"] {
    border-left: .25em solid #9e6a03;
    background-color: #9e6a0315 !important;
}
.bq[data-callout-type="warning"] > div[data-type="NodeParagraph"]:first-of-type > div[contenteditable="true"] {
    color: #9e6a03;
}
.bq[data-callout-type="warning"] > div[data-type="NodeParagraph"]:first-of-type > div[contenteditable="true"]::before {
    content: "";
    display: inline-block;
    overflow: visible !important;
    vertical-align: middle;
    width: 22px;
    height: 22px;
    mask: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyOCIgaGVpZ2h0PSIyOCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIxLjcxNDI4NTcxNDI4NTcxNDIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgY2xhc3M9Imx1Y2lkZSBsdWNpZGUtdHJpYW5nbGUtYWxlcnQiPjxwYXRoIGQ9Im0yMS43MyAxOC04LTE0YTIgMiAwIDAgMC0zLjQ4IDBsLTggMTRBMiAyIDAgMCAwIDQgMjFoMTZhMiAyIDAgMCAwIDEuNzMtMyIvPjxwYXRoIGQ9Ik0xMiA5djQiLz48cGF0aCBkPSJNMTIgMTdoLjAxIi8+PC9zdmc+");
    mask-size: cover;
    background-color: currentColor;
    margin-right: 10px;
    mask-repeat: no-repeat;
}

/* Caution / Danger */
.bq[data-callout-type="caution"] {
    border-left: .25em solid #da3633;
    background-color: #da363315 !important;
}
.bq[data-callout-type="caution"] > div[data-type="NodeParagraph"]:first-of-type > div[contenteditable="true"] {
    color: #da3633;
}
.bq[data-callout-type="caution"] > div[data-type="NodeParagraph"]:first-of-type > div[contenteditable="true"]::before {
    content: "";
    display: inline-block;
    overflow: visible !important;
    vertical-align: middle;
    width: 22px;
    height: 22px;
    mask: url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyOCIgaGVpZ2h0PSIyOCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIxLjcxNDI4NTcxNDI4NTcxNDIiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCIgc3Ryb2tlLWxpbmVqb2luPSJyb3VuZCIgY2xhc3M9Imx1Y2lkZSBsdWNpZGUtb2N0YWdvbi1hbGVydCI+PHBhdGggZD0iTTEyIDE2aC4wMSIvPjxwYXRoIGQ9Ik0xMiA4djQiLz48cGF0aCBkPSJNMTUuMzEyIDJhMiAyIDAgMCAxIDEuNDE0LjU4Nmw0LjY4OCA0LjY4OEEyIDIgMCAwIDEgMjIgOC42ODh2Ni42MjRhMiAyIDAgMCAxLS41ODYgMS40MTRsLTQuNjg4IDQuNjg4YTIgMiAwIDAgMS0xLjQxNC41ODZIOC42ODhhMiAyIDAgMCAxLTEuNDE0LS41ODZsLTQuNjg4LTQuNjg4QTIgMiAwIDAgMSAyIDE1LjMxMlY4LjY4OGEyIDIgMCAwIDEgLjU4Ni0xLjQxNGw0LjY4OC00LjY4OEEyIDIgMCAwIDEgOC42ODggMnoiLz48L3N2Zz4=");
    mask-size: cover;
    background-color: currentColor;
    margin-right: 10px;
    mask-repeat: no-repeat;
}

/* Default Callout (when no valid [!TYPE] is found) */
.bq[data-callout-type="default"] {
    border-left: .25em solid #7d8590; /* Example: GitHub's default blockquote color */
    background-color: #7d859015 !important; /* Example background */
    /* You might choose NOT to have an icon for default, or add a generic one */
}

/* Optional: Style the title div for default if needed */
.bq[data-callout-type="default"] > div[data-type="NodeParagraph"]:first-of-type > div[contenteditable="true"] {
    color: #7d8590; /* Example title color */
    /* If adding an icon for default: */
    /* &::before { ... icon styles ... } */
}

/* Optional: Hide the original [!TYPE] text visually */
/* If you want to hide the "[!TYPE]" text itself but keep the icon and maybe a generated title */
/* This targets the contenteditable div that contains "[!TYPE]" */
/* Make sure this is commented out or removed if you WANT the "[!TYPE]" text to be visible */
/*
.bq[data-callout-type] > div[data-type="NodeParagraph"]:first-of-type > div[contenteditable="true"] {
   display: none;
   /* Alternative visual hiding */
   /*
    position: absolute;
    left: -9999px;
    width: 1px;
    height: 1px;
    overflow: hidden;
    }
   */

代码说明

  • 通过识别 data-type 等字段来实现 callout 样式的自动设置

  • 100% 代码来自于 Gemini-2.5-pro-preview-03-25。

  • 重要说明

    • 本人不负责修任何 BUG,发完帖子 99% 概率不会再管。
    • 有任何需求直接问 AI。

已知 BUG

BUG

  • 可能在重启后,或者新建文档后,第一次输入 Callout 会出现无法自动应用样式的问题。

解决办法

  • 按 f5 刷新

可能原因

  • 根据 AI 分析(Gemini+Claude+Deepseek),均认为是思源笔记渲染笔记时微妙的渲染时间差异导致。
  • 无法从根本上解决。

测试案例

测试过如下主题。

  • 思源笔记官方自带主题
  • Asri 主题

其他主题没测试过,理论上不应该有问题。

致谢

  1. [css] 在思源中制作 callout
  2. Gemini
  • 思源笔记

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

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

    26013 引用 • 107949 回帖
  • 代码片段

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

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

    186 引用 • 1314 回帖

相关帖子

欢迎来到这里!

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

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