标签: 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 也就此解决了。

添加新评论

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