标签: Typecho

Obsidian Callout

如果有使用 Obsidian 的读者,相信一定会知道有一个非常有意义的语法就是 callout ,不熟悉的朋友也可以参考官方链接 1 ,它就是用形如

> [!note] Note
> 方框当中的内容

来实现一个方框的效果,相比引用 (blockquote) 语法,它有更 fancy 的外观,可以根据内容的实际意图有不同的外观. 而在 Typecho 当中,实际上也有方法实现这类内容,用上面的代码,可以渲染得到如下的内容:

[!note] Note
方框当中的内容

利用这种格式,读者可以尝试写一些更规整的文档,例如对于数学定理:

[!note] 定理:选择公理 (Axiom of Choice)
任何非空集合均有选择函数。

我的实现主要目的为以下几种:

  1. 对 Obsidian 当中常用的 Note、Warning、Danger、Tip 等重要类别实现对应的类似方框;
  2. 题头标粗,和 Obsidian 的表现一致;
  3. 高度自定义,可以任意替换 Callout 的外观,利用自定义 CSS 可以实现更 fancy 的效果.

同时,还可以完全将 Obsidian 的创作习惯都保留,Callout 也可以随便写了。

预览效果

下面给出一些使用例子,需要注意的是,紧挨着的 callout 之间,还是需要用个分隔线或者一些文字来隔开两个,如果想完全实现分割,可以用 <!-- more --> 这个 HTML 注释来实现,用 Ctrl+M 作为快捷键。

[!note] 笔记框
这里可以是定理,也可以是各类需要注意的地方。

$$ \int_a^b f(x)\mathrm{d}x = F(b) - F(a) $$

[!warning] 警告框
警告,虽然很 fancy ,但是还是要注意内容!

$$ f(x) + g(x) > 0 \not\Rightarrow f(x) > 0 $$

[!tip] 灵光一现
很多时候,我们需要

  • 灵感
  • 动机

才能在学习的时候有所收获。

[!bug] 不要写 Bug
当然,写 Bug 很多时候是难免的:

import numpy as pd

除此之外,还有 danger 等,读者可以自行尝试。

实现方法

下面我们来说明在默认主题下的实现方法,示例以 Typecho 默认主题为例,你可以按自己主题稍作调整。

假设你的主题 footer.php 末尾大概长这样:

</footer><!-- end #footer -->

<?php $this->footer(); ?>
</body>
</html>

我们要做的,就是在 </footer> 和 <?php $this->footer(); ?> 之间插入一段 <style> + <script>。如果你已经有自定义样式/脚本,也可以合并到一起。

  1. Callout 的样式(背景、边框、图标位置)

插入如下 CSS(色彩刻意调得比较淡,类似 GitHub 那种“轻微着色”的感觉):

<style>
/* ===== Obsidian / GFM 风格 Callout(浅色) ===== */

.callout {
    position: relative;
    margin: 1.25em 0;
    padding: 0.85em 1em 0.9em 0.95em;
    border-radius: 6px;
    border-left: 4px solid rgba(140, 149, 159, 0.6);
    background-color: rgba(175, 184, 193, 0.08); /* 非类型时的兜底灰色 */
    font-size: 0.95em;
}

.callout + .callout {
    margin-top: 0.75em;
}

/* 标题行:图标 + 粗体文字 */
.callout-title {
    display: flex;
    align-items: center;
    gap: 0.35em;
    margin-bottom: 0.25em;
    line-height: 1.5;
}

.callout-icon {
    flex-shrink: 0;
    font-size: 1.05em;
}

.callout-title-text {
    font-weight: 600;
}

/* 内容区域:保留原有段落、行距 */
.callout-content > :first-child {
    margin-top: 0.15em;
}
.callout-content > :last-child {
    margin-bottom: 0;
}

/* 各类型配色:背景非常淡,仅轻微着色 */

/* note / info:蓝色调 */
.callout-note,
.callout-info {
    border-left-color: rgba(56, 139, 253, 0.8);
    background-color: rgba(56, 139, 253, 0.08);
}

/* tip / success:绿色调 */
.callout-tip {
    border-left-color: rgba(46, 160, 67, 0.8);
    background-color: rgba(46, 160, 67, 0.08);
}

/* warning / caution:黄色调 */
.callout-warning {
    border-left-color: rgba(210, 153, 34, 0.8);
    background-color: rgba(210, 153, 34, 0.08);
}

/* danger / error:红色调 */
.callout-danger {
    border-left-color: rgba(248, 81, 73, 0.8);
    background-color: rgba(248, 81, 73, 0.08);
}

/* quote:灰色调 */
.callout-quote {
    border-left-color: rgba(110, 118, 129, 0.8);
    background-color: rgba(110, 118, 129, 0.08);
}

/* 简单的暗色模式适配 */
@media (prefers-color-scheme: dark) {
    .callout {
        background-color: rgba(49, 54, 63, 0.4);
        border-left-color: rgba(110, 118, 129, 0.9);
    }
    .callout-note,
    .callout-info {
        background-color: rgba(56, 139, 253, 0.16);
    }
    .callout-tip {
        background-color: rgba(46, 160, 67, 0.16);
    }
    .callout-warning {
        background-color: rgba(210, 153, 34, 0.16);
    }
    .callout-danger {
        background-color: rgba(248, 81, 73, 0.16);
    }
    .callout-quote {
        background-color: rgba(110, 118, 129, 0.16);
    }
}
</style>

如果你有自己的主题配色,可以直接调整 rgba(...) 里的色值。

  1. Callout 的解析脚本(识别 [!note]

紧接着上面的 <style> 后面,加上这段 <script>

<script>
(function () {
    function ready(fn) {
        if (document.readyState === 'loading') {
            document.addEventListener('DOMContentLoaded', fn);
        } else {
            fn();
        }
    }

    // 支持的 Callout 类型及图标、默认标题
    var CALLOUT_TYPES = {
        note:    { icon: '📝', label: 'Note' },
        tip:     { icon: '💡', label: 'Tip' },
        info:    { icon: 'ℹ️', label: 'Info' },
        todo:    { icon: '✅', label: 'Todo' },
        abstract:{ icon: '📚', label: 'Abstract' },
        summary: { icon: '📌', label: 'Summary' },
        tldr:    { icon: '📌', label: 'TL;DR' },
        question:{ icon: '❓', label: 'Question' },
        help:    { icon: '❓', label: 'Help' },
        faq:     { icon: '❓', label: 'FAQ' },
        warning: { icon: '⚠️', label: 'Warning' },
        caution: { icon: '⚠️', label: 'Caution' },
        important:{icon: '⚠️', label: 'Important' },
        danger:  { icon: '🔥', label: 'Danger' },
        error:   { icon: '⛔', label: 'Error' },
        bug:     { icon: '🐛', label: 'Bug' },
        example: { icon: '🧪', label: 'Example' },
        quote:   { icon: '💬', label: 'Quote' }
    };

    // 匹配 [!note] / [!NOTE]+ / [!warning]- 标题...
    var CALLOUT_HEADER_RE = /^\s*\[!([^\]\s]+)\]\s*([+-])?\s*(.*)$/i;

    // 在 blockquote 内找到第一个以 [!xxx] 开头的文本节点
    function findCalloutHeader(blockquote) {
        var walker = document.createTreeWalker(
            blockquote,
            NodeFilter.SHOW_TEXT,
            {
                acceptNode: function (node) {
                    if (!node.textContent) return NodeFilter.FILTER_REJECT;
                    var trimmed = node.textContent.replace(/^\s+/, '');
                    return CALLOUT_HEADER_RE.test(trimmed)
                        ? NodeFilter.FILTER_ACCEPT
                        : NodeFilter.FILTER_SKIP;
                }
            }
        );

        var node = walker.nextNode();
        if (!node) return null;

        var full = node.textContent;
        var trimmed = full.replace(/^\s+/, '');
        var leadingSpaces = full.length - trimmed.length;
        var match = trimmed.match(CALLOUT_HEADER_RE);

        return {
            node: node,
            full: full,
            trimmed: trimmed,
            leadingSpaces: leadingSpaces,
            match: match
        };
    }

    function transformBlockquote(blockquote) {
        if (!blockquote || !blockquote.parentNode || blockquote._calloutProcessed) return;

        var headerInfo = findCalloutHeader(blockquote);
        if (!headerInfo) return;

        var match = headerInfo.match;
        var rawType = (match[1] || '').toLowerCase();
        var collapseSign = match[2] || null;  // 目前未使用,可以以后扩展折叠
        var titleText = match[3] || '';

        var typeInfo = CALLOUT_TYPES[rawType] || {
            icon: '💬',
            label: rawType ? (rawType.charAt(0).toUpperCase() + rawType.slice(1)) : 'Note'
        };

        // 从原文本中删除 "[!type] 标题" 这一段,避免正文重复
        var headerLength = match[0].length;
        var start = headerInfo.leadingSpaces;
        var newText =
            headerInfo.full.slice(0, start) +
            headerInfo.full.slice(start + headerLength);
        headerInfo.node.textContent = newText;

        // 构造 callout DOM
        var calloutDiv = document.createElement('div');
        calloutDiv.className = 'callout callout-' + rawType;
        calloutDiv.setAttribute('data-callout', rawType);
        calloutDiv.setAttribute('data-callout-raw', rawType);

        var titleDiv = document.createElement('div');
        titleDiv.className = 'callout-title';

        var iconSpan = document.createElement('span');
        iconSpan.className = 'callout-icon';
        iconSpan.textContent = typeInfo.icon;

        var titleSpan = document.createElement('span');
        titleSpan.className = 'callout-title-text';
        titleSpan.textContent = titleText.trim() || typeInfo.label;

        titleDiv.appendChild(iconSpan);
        titleDiv.appendChild(titleSpan);

        var contentDiv = document.createElement('div');
        contentDiv.className = 'callout-content';

        // 把 blockquote 里的子节点整体搬进内容区,保留 MathJax/代码块等结构
        while (blockquote.firstChild) {
            contentDiv.appendChild(blockquote.firstChild);
        }

        calloutDiv.appendChild(titleDiv);
        calloutDiv.appendChild(contentDiv);

        blockquote.parentNode.replaceChild(calloutDiv, blockquote);
        calloutDiv._calloutProcessed = true;
    }

    function transformAllCallouts(root) {
        var container = root || document;
        var blocks = container.querySelectorAll(
            '.post-content blockquote, .comment-content blockquote'
        );
        blocks.forEach(transformBlockquote);
    }

    ready(function () {
        transformAllCallouts();

        // 如果页面使用 MathJax,渲染后一般会重建公式节点,
        // 这里加一层兜底:在 MathJax 结束时再次尝试转换(若可用)
        if (window.MathJax && MathJax.Hub && MathJax.Hub.Register) {
            try {
                MathJax.Hub.Register.MessageHook('End Process', function () {
                    transformAllCallouts();
                });
            } catch (e) {
                // 不同版本 MathJax API 可能不同,失败就忽略
            }
        }
    });
})();
</script>

这段脚本的几个关键点:

  • 不依赖具体的 <p> / <br> 结构:通过 TreeWalker 在整个 <blockquote> 内找第一个以 [!xxx] 开头的文本节点,只要 Markdown 解析出来了,哪怕之后被 MathJax 改造过 DOM,也很稳。
  • 只会把 [!type] 标题 这一小段从文本中删掉,剩余结构原封不动搬到 .callout-content,所以公式、代码块不会被破坏。
  • 识别的类型和别名基本覆盖了 Obsidian / Admonition 的主类型和大部分别名。

至此,Obsidian 支持的最大难点其实在 Typecho 也就此解决了。

已有 2 条评论

  1. YH

    用AI改成插件,就可以避免污染原来的css

    <?php
    /**
     * Obsidian Callout 风格插件
     * 
     * @package CalloutForTypecho
     * @author  YourName
     * @version 1.0.0
     * @link    https://yourblog.com
     */
    class CalloutForTypecho_Plugin implements Typecho_Plugin_Interface
    {
        /**
         * 激活插件
         */
        public static function activate()
        {
            // 在主题头部插入 CSS,在尾部插入 JS
            Typecho_Plugin::factory('Widget_Archive')->header = array(__CLASS__, 'header');
            Typecho_Plugin::factory('Widget_Archive')->footer = array(__CLASS__, 'footer');
            return _t('插件已激活');
        }
    
        /**
         * 禁用插件
         */
        public static function deactivate()
        {
            return _t('插件已禁用');
        }
    
        /**
         * 插件配置面板(无需配置)
         */
        public static function config(Typecho_Widget_Helper_Form \$form) {}
    
        /**
         * 个人用户配置(无需配置)
         */
        public static function personalConfig(Typecho_Widget_Helper_Form \$form) {}
    
        /**
         * 输出 CSS 样式
         */
        public static function header()
        {
            \$css = <<<CSS
    <style>
    /* ===== Obsidian / GFM 风格 Callout(浅色) ===== */
    .callout {
        position: relative;
        margin: 1.25em 0;
        padding: 0.85em 1em 0.9em 0.95em;
        border-radius: 6px;
        border-left: 4px solid rgba(140, 149, 159, 0.6);
        background-color: rgba(175, 184, 193, 0.08);
        font-size: 0.95em;
    }
    .callout + .callout {
        margin-top: 0.75em;
    }
    .callout-title {
        display: flex;
        align-items: center;
        gap: 0.35em;
        margin-bottom: 0.25em;
        line-height: 1.5;
    }
    .callout-icon {
        flex-shrink: 0;
        font-size: 1.05em;
    }
    .callout-title-text {
        font-weight: 600;
    }
    .callout-content > :first-child {
        margin-top: 0.15em;
    }
    .callout-content > :last-child {
        margin-bottom: 0;
    }
    /* 各类型配色 */
    .callout-note, .callout-info {
        border-left-color: rgba(56, 139, 253, 0.8);
        background-color: rgba(56, 139, 253, 0.08);
    }
    .callout-tip {
        border-left-color: rgba(46, 160, 67, 0.8);
        background-color: rgba(46, 160, 67, 0.08);
    }
    .callout-warning {
        border-left-color: rgba(210, 153, 34, 0.8);
        background-color: rgba(210, 153, 34, 0.08);
    }
    .callout-danger {
        border-left-color: rgba(248, 81, 73, 0.8);
        background-color: rgba(248, 81, 73, 0.08);
    }
    .callout-quote {
        border-left-color: rgba(110, 118, 129, 0.8);
        background-color: rgba(110, 118, 129, 0.08);
    }
    /* 暗色模式适配 */
    @media (prefers-color-scheme: dark) {
        .callout {
            background-color: rgba(49, 54, 63, 0.4);
            border-left-color: rgba(110, 118, 129, 0.9);
        }
        .callout-note, .callout-info {
            background-color: rgba(56, 139, 253, 0.16);
        }
        .callout-tip {
            background-color: rgba(46, 160, 67, 0.16);
        }
        .callout-warning {
            background-color: rgba(210, 153, 34, 0.16);
        }
        .callout-danger {
            background-color: rgba(248, 81, 73, 0.16);
        }
        .callout-quote {
            background-color: rgba(110, 118, 129, 0.16);
        }
    }
    </style>
    CSS;
            echo \$css;
        }
    
        /**
         * 输出 JavaScript 脚本
         */
        public static function footer()
        {
            \$js = <<<JS
    <script>
    (function () {
        function ready(fn) {
            if (document.readyState === 'loading') {
                document.addEventListener('DOMContentLoaded', fn);
            } else {
                fn();
            }
        }
    
        var CALLOUT_TYPES = {
            note:    { icon: '📝', label: 'Note' },
            tip:     { icon: '💡', label: 'Tip' },
            info:    { icon: 'ℹ️', label: 'Info' },
            todo:    { icon: '✅', label: 'Todo' },
            abstract:{ icon: '📚', label: 'Abstract' },
            summary: { icon: '📌', label: 'Summary' },
            tldr:    { icon: '📌', label: 'TL;DR' },
            question:{ icon: '❓', label: 'Question' },
            help:    { icon: '❓', label: 'Help' },
            faq:     { icon: '❓', label: 'FAQ' },
            warning: { icon: '⚠️', label: 'Warning' },
            caution: { icon: '⚠️', label: 'Caution' },
            important:{icon: '⚠️', label: 'Important' },
            danger:  { icon: '🔥', label: 'Danger' },
            error:   { icon: '⛔', label: 'Error' },
            bug:     { icon: '🐛', label: 'Bug' },
            example: { icon: '🧪', label: 'Example' },
            quote:   { icon: '💬', label: 'Quote' }
        };
    
        var CALLOUT_HEADER_RE = /^\s*\[!([^\]\s]+)\]\s*([+-])?\s*(.*)\$/i;
    
        function findCalloutHeader(blockquote) {
            var walker = document.createTreeWalker(
                blockquote,
                NodeFilter.SHOW_TEXT,
                {
                    acceptNode: function (node) {
                        if (!node.textContent) return NodeFilter.FILTER_REJECT;
                        var trimmed = node.textContent.replace(/^\s+/, '');
                        return CALLOUT_HEADER_RE.test(trimmed)
                            ? NodeFilter.FILTER_ACCEPT
                            : NodeFilter.FILTER_SKIP;
                    }
                }
            );
    
            var node = walker.nextNode();
            if (!node) return null;
    
            var full = node.textContent;
            var trimmed = full.replace(/^\s+/, '');
            var leadingSpaces = full.length - trimmed.length;
            var match = trimmed.match(CALLOUT_HEADER_RE);
    
            return {
                node: node,
                full: full,
                trimmed: trimmed,
                leadingSpaces: leadingSpaces,
                match: match
            };
        }
    
        function transformBlockquote(blockquote) {
            if (!blockquote || !blockquote.parentNode || blockquote._calloutProcessed) return;
    
            var headerInfo = findCalloutHeader(blockquote);
            if (!headerInfo) return;
    
            var match = headerInfo.match;
            var rawType = (match[1] || '').toLowerCase();
            var collapseSign = match[2] || null;
            var titleText = match[3] || '';
    
            var typeInfo = CALLOUT_TYPES[rawType] || {
                icon: '💬',
                label: rawType ? (rawType.charAt(0).toUpperCase() + rawType.slice(1)) : 'Note'
            };
    
            var headerLength = match[0].length;
            var start = headerInfo.leadingSpaces;
            var newText =
                headerInfo.full.slice(0, start) +
                headerInfo.full.slice(start + headerLength);
            headerInfo.node.textContent = newText;
    
            var calloutDiv = document.createElement('div');
            calloutDiv.className = 'callout callout-' + rawType;
            calloutDiv.setAttribute('data-callout', rawType);
            calloutDiv.setAttribute('data-callout-raw', rawType);
    
            var titleDiv = document.createElement('div');
            titleDiv.className = 'callout-title';
    
            var iconSpan = document.createElement('span');
            iconSpan.className = 'callout-icon';
            iconSpan.textContent = typeInfo.icon;
    
            var titleSpan = document.createElement('span');
            titleSpan.className = 'callout-title-text';
            titleSpan.textContent = titleText.trim() || typeInfo.label;
    
            titleDiv.appendChild(iconSpan);
            titleDiv.appendChild(titleSpan);
    
            var contentDiv = document.createElement('div');
            contentDiv.className = 'callout-content';
    
            while (blockquote.firstChild) {
                contentDiv.appendChild(blockquote.firstChild);
            }
    
            calloutDiv.appendChild(titleDiv);
            calloutDiv.appendChild(contentDiv);
    
            blockquote.parentNode.replaceChild(calloutDiv, blockquote);
            calloutDiv._calloutProcessed = true;
        }
    
        function transformAllCallouts(root) {
            var container = root || document;
            var blocks = container.querySelectorAll(
                '.post-content blockquote, .comment-content blockquote'
            );
            blocks.forEach(transformBlockquote);
        }
    
        ready(function () {
            transformAllCallouts();
    
            if (window.MathJax && MathJax.Hub && MathJax.Hub.Register) {
                try {
                    MathJax.Hub.Register.MessageHook('End Process', function () {
                        transformAllCallouts();
                    });
                } catch (e) {}
            }
        });
    })();
    </script>
    JS;
            echo \$js;
        }
    }
    1. xzqbear

      感谢回复,这个方法确实更好,适用于其他的主题。为了评论不被 Mathjax 渲染,我手动转义了美元符号 \$ ,有之后复用的读者直接替换所有转义美元符号 \$ 为正常的美元符号 $ 即可。

添加新评论

(所有评论均需经过站主审核,违反社会道德规范与国家法律法规的评论不予通过)