Typecho 对 Obsidian Callout 的支持 —— CSS 和 Javascript 实现(默认主题)
Obsidian Callout
如果有使用 Obsidian 的读者,相信一定会知道有一个非常有意义的语法就是 callout ,不熟悉的朋友也可以参考官方链接 1 ,它就是用形如
> [!note] Note
> 方框当中的内容来实现一个方框的效果,相比引用 (blockquote) 语法,它有更 fancy 的外观,可以根据内容的实际意图有不同的外观. 而在 Typecho 当中,实际上也有方法实现这类内容,用上面的代码,可以渲染得到如下的内容:
[!note] Note
方框当中的内容
利用这种格式,读者可以尝试写一些更规整的文档,例如对于数学定理:
[!note] 定理:选择公理 (Axiom of Choice)
任何非空集合均有选择函数。
我的实现主要目的为以下几种:
- 对 Obsidian 当中常用的 Note、Warning、Danger、Tip 等重要类别实现对应的类似方框;
- 题头标粗,和 Obsidian 的表现一致;
- 高度自定义,可以任意替换 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>。如果你已经有自定义样式/脚本,也可以合并到一起。
- 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(...) 里的色值。
- 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 也就此解决了。