检测特定开头文本为符合条件的引述块添加自定义 CSS
增加新的样式,只需要做两件事:
-
在 JS 规则数组中添加一行规则:{ class: "warning", regex: /^WARNING[::]?/i },
字段 说明 class将添加到 <blockquote>的 class 名称(用于匹配 CSS)regex用于检测块首文本是否匹配的正则表达式 -
在 CSS 中添加对应样式,可以用 Emoji、图标、颜色、渐变背景等来自定义风格。
示例:
当引述块的首行为时间戳(例如:2025-06-16 13:05:14),通过代码片段将引述块改为时间线样式。
JS:
function applyCustomBlockquoteClasses() {
const rules = [
{ class: "timestamp", regex: /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/ },
];
const blockquotes = document.querySelectorAll('[data-type="NodeBlockquote"]');
blockquotes.forEach((bq) => {
const firstP = bq.querySelector('.p');
if (!firstP) return;
const textDiv = firstP.querySelector('div[contenteditable]');
if (!textDiv) return;
const text = textDiv.textContent.trim();
// 清除旧的 class
rules.forEach((r) => bq.classList.remove(r.class));
// 添加匹配到的 class
for (const rule of rules) {
if (rule.regex.test(text)) {
bq.classList.add(rule.class);
break;
}
}
});
}
// 初始加载 & 动态更新
document.addEventListener("DOMContentLoaded", applyCustomBlockquoteClasses);
new MutationObserver(applyCustomBlockquoteClasses).observe(document.body, {
childList: true,
subtree: true
});
CSS:
.bq.timestamp::before {
content: "";
position: absolute;
left: -12px;
top: 12px;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: #a678f1;
border: 3px solid white;
box-sizing: border-box;
z-index: 1;
}
.bq.timestamp {
border-left: 3px solid #a678f1;
padding-left: 15px;
position: relative;
background-color: hsl(260deg 100% 77% / 10%);
border-radius: 0px 10px 10px 0px;
color: #222222;
}
更多样式

JS:
function applyCustomBlockquoteClasses() {
const rules = [
{ class: "timestamp", regex: /^(?:\d{4}-\d{2}-\d{2}(?: \d{2}:\d{2}:\d{2})?|\d{2}:\d{2}:\d{2})$/ },
{ class: "note", regex: /^NOTE/i },
{ class: "tip", regex: /^TIP/i },
{ class: "bug", regex: /^bug/i },
{ class: "example", regex: /^example/i },
{ class: "question", regex: /^question/i },
{ class: "todo", regex: /^todo/i },
];
const blockquotes = document.querySelectorAll('[data-type="NodeBlockquote"]');
blockquotes.forEach((bq) => {
const firstP = bq.querySelector('.p');
if (!firstP) return;
const textDiv = firstP.querySelector('div[contenteditable]');
if (!textDiv) return;
const text = textDiv.textContent.trim();
// 清除旧的 class
rules.forEach((r) => bq.classList.remove(r.class));
// 添加匹配到的 class
for (const rule of rules) {
if (rule.regex.test(text)) {
bq.classList.add(rule.class);
break;
}
}
});
}
// 配置选项
const TODO_CONFIG = {
countOnlyTopLevel: true, // true: 只计算一级任务, false: 计算所有任务
showSubtaskProgress: true, // 是否在统计中显示子任务信息
defaultStatsMode: 'top' // 默认显示模式: 'top' 或 'all'
};
// 测试点击事件
function testClickEvent() {
// 为所有todo块添加测试点击
const todoBlocks = document.querySelectorAll('.bq.todo');
if (todoBlocks.length === 0) {
setTimeout(testClickEvent, 500);
return;
}
todoBlocks.forEach((block, index) => {
const firstP = block.querySelector('.p:first-child');
if (firstP) {
// todo块已准备就绪,点击事件由主函数处理
}
});
}
// 添加点击切换功能
function addTodoStatsToggle() {
// 移除可能存在的旧事件监听器
if (window._todoClickHandler) {
document.removeEventListener('click', window._todoClickHandler);
}
// 创建新的事件处理器
window._todoClickHandler = (e) => {
const todoBlock = e.target.closest('.bq.todo');
if (!todoBlock) return;
const firstP = todoBlock.querySelector('.p:first-child');
if (!firstP) return;
// 检查点击是否在统计文字区域
const rect = firstP.getBoundingClientRect();
const clickX = e.clientX;
const clickY = e.clientY;
// 获取统计文字的内容
const statsText = firstP.style.getPropertyValue('--task-stats');
if (!statsText) return;
// 创建一个临时元素来计算统计文字的实际大小
const tempSpan = document.createElement('span');
tempSpan.style.cssText = `
position: absolute;
visibility: hidden;
font-size: 0.85em;
font-weight: normal;
white-space: nowrap;
`;
tempSpan.textContent = statsText.replace(/"/g, ''); // 移除引号
document.body.appendChild(tempSpan);
const textRect = tempSpan.getBoundingClientRect();
const textWidth = textRect.width;
const textHeight = textRect.height;
document.body.removeChild(tempSpan);
// 计算统计文字的实际位置
const statsRight = rect.right - 12; // 距离右边缘12px
const statsLeft = statsRight - textWidth - 8; // 文字宽度 + padding
const statsTop = rect.top + (rect.height - textHeight) / 2; // 垂直居中
const statsBottom = statsTop + textHeight;
// 检查点击是否在统计文字区域内
const isStatsText = clickX >= statsLeft && clickX <= statsRight &&
clickY >= statsTop && clickY <= statsBottom;
if (isStatsText) {
const currentMode = firstP.dataset.statsMode || TODO_CONFIG.defaultStatsMode;
const newMode = currentMode === 'top' ? 'all' : 'top';
firstP.dataset.statsMode = newMode;
updateSingleTodoProgress(todoBlock, newMode);
e.stopPropagation();
}
};
// 添加新的事件监听器
document.addEventListener('click', window._todoClickHandler);
}
// 更新单个todo块的进度条
function updateSingleTodoProgress(bq, mode = null) {
const firstP = bq.querySelector('.p:first-child');
if (!firstP) return;
// 查找任务列表
const taskList = bq.querySelector('.list');
if (!taskList) return;
// 确定显示模式
const displayMode = mode || firstP.dataset.statsMode || TODO_CONFIG.defaultStatsMode;
let allTasks, completedTasks, progress, statsText;
if (displayMode === 'top') {
// 模式1:只计算一级任务
allTasks = taskList.querySelectorAll('.li:not(.li .li)');
completedTasks = taskList.querySelectorAll('.li:not(.li .li).protyle-task--done');
if (allTasks.length === 0) return;
progress = (completedTasks.length / allTasks.length) * 100;
statsText = `"${completedTasks.length}/${allTasks.length} (${Math.round(progress)}%) [一级]"`;
} else {
// 模式2:计算所有任务
allTasks = taskList.querySelectorAll('.li');
completedTasks = taskList.querySelectorAll('.li.protyle-task--done');
if (allTasks.length === 0) return;
progress = (completedTasks.length / allTasks.length) * 100;
statsText = `"${completedTasks.length}/${allTasks.length} (${Math.round(progress)}%) [全部]"`;
}
// 检查是否需要更新(避免不必要的DOM操作)
const currentProgress = firstP.style.getPropertyValue('--progress');
const currentStats = firstP.style.getPropertyValue('--task-stats');
if (currentProgress !== `${progress}%` || currentStats !== statsText) {
// 更新CSS变量
firstP.style.setProperty('--progress', `${progress}%`);
firstP.style.setProperty('--task-stats', statsText);
}
}
// 更新todo块的进度条
function updateTodoProgress() {
const todoBlockquotes = document.querySelectorAll('.bq.todo');
todoBlockquotes.forEach((bq, index) => {
updateSingleTodoProgress(bq);
});
}
// 监听任务状态变化
function observeTaskChanges() {
const observer = new MutationObserver((mutations) => {
let shouldUpdate = false;
mutations.forEach((mutation) => {
if (mutation.type === 'attributes' && mutation.attributeName === 'class') {
const target = mutation.target;
if (target.classList.contains('li') &&
(target.classList.contains('protyle-task--done') ||
!target.classList.contains('protyle-task--done'))) {
shouldUpdate = true;
}
}
});
if (shouldUpdate) {
updateTodoProgress();
}
});
observer.observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class']
});
}
// 手动测试函数
function manualTest() {
const todoBlocks = document.querySelectorAll('.bq.todo');
if (todoBlocks.length > 0) {
const firstP = todoBlocks[0].querySelector('.p:first-child');
if (firstP) {
const currentMode = firstP.dataset.statsMode || TODO_CONFIG.defaultStatsMode;
const newMode = currentMode === 'top' ? 'all' : 'top';
firstP.dataset.statsMode = newMode;
updateSingleTodoProgress(todoBlocks[0], newMode);
}
}
}
// 初始加载 & 动态更新
document.addEventListener("DOMContentLoaded", () => {
applyCustomBlockquoteClasses();
updateTodoProgress();
observeTaskChanges();
addTodoStatsToggle();
// 立即执行一次测试
testClickEvent();
// 延迟执行测试函数,确保DOM完全加载
setTimeout(() => {
testClickEvent();
}, 1000);
});
// 也监听window的load事件作为备用
window.addEventListener('load', () => {
setTimeout(() => {
testClickEvent();
// 确保点击事件监听器已绑定
addTodoStatsToggle();
}, 500);
});
// 立即执行一次(如果DOM已经加载)
if (document.readyState === 'loading') {
// DOM还在加载中,等待DOMContentLoaded
} else {
testClickEvent();
addTodoStatsToggle();
}
// 优化MutationObserver,避免无限循环
let isUpdating = false;
let lastUpdateTime = 0;
new MutationObserver((mutations) => {
if (isUpdating) return;
// 防止频繁更新
const now = Date.now();
if (now - lastUpdateTime < 200) return;
// 检查是否有相关的DOM变化
const hasRelevantChanges = mutations.some(mutation => {
// 忽略我们自己的CSS变量变化
if (mutation.type === 'attributes' && mutation.attributeName === 'style') {
return false;
}
return mutation.type === 'childList' ||
(mutation.type === 'attributes' &&
(mutation.target.classList.contains('bq') ||
mutation.target.classList.contains('li')));
});
if (hasRelevantChanges) {
isUpdating = true;
lastUpdateTime = now;
setTimeout(() => {
applyCustomBlockquoteClasses();
updateTodoProgress();
// 重新绑定点击事件
addTodoStatsToggle();
isUpdating = false;
}, 100);
}
}).observe(document.body, {
childList: true,
subtree: true,
attributes: true,
attributeFilter: ['class']
});
CSS:
/* 时间线 ------------------------------------------------- */
.bq.timestamp::before {
content: "";
position: absolute;
left: -12px;
top: 12px;
width: 20px;
height: 20px;
border-radius: 50%;
background-color: #a678f1;
border: 3px solid white;
box-sizing: border-box;
z-index: 1;
}
.bq.timestamp {
border-left: 3px solid #a678f1;
padding-left: 15px;
position: relative;
background-color: hsl(260deg 100% 77% / 10%);
border-radius: 0px 10px 10px 0px;
color: #222222;
}
/* Callout通用样式 ------------------------------------------------- */
.bq.note,
.bq.tip,
.bq.bug,
.bq.question,
.bq.example,
.bq.todo {
border: 2px solid;
border-radius: 10px;
overflow: hidden;
margin: .5em 0;
font-family: sans-serif;
background-color: transparent;
padding: 0;
}
.bq.note::before,
.bq.tip::before,
.bq.bug::before,
.bq.question::before,
.bq.example::before,
.bq.todo::before {
width: 0px;
}
.bq.note > .p:first-child,
.bq.tip > .p:first-child,
.bq.bug > .p:first-child,
.bq.question > .p:first-child,
.bq.example > .p:first-child,
.bq.todo > .p:first-child {
font-weight: bold;
padding: 8px 12px;
border-radius: 0;
margin: 0;
display: flex;
align-items: center;
gap: 6px;
}
.bq.note > .p:first-child::before,
.bq.tip > .p:first-child::before,
.bq.bug > .p:first-child::before,
.bq.question > .p:first-child::before,
.bq.example > .p:first-child::before,
.bq.todo > .p:first-child::before {
font-size: 1em;
}
.bq.note > *:not(:first-child),
.bq.tip > *:not(:first-child),
.bq.bug > *:not(:first-child),
.bq.question > *:not(:first-child),
.bq.example > *:not(:first-child),
.bq.todo > *:not(:first-child) {
color: var(--b3-theme-on-background);
padding: 8px 12px;
margin: 0;
border-radius: 0;
background-color: transparent;
}
/* 个性化样式 ------------------------------------------------- */
/* Note ------------------------------------------------- */
.bq.note {
border-color: #9ddcd6;
}
.bq.note > .p:first-child {
background-color: #d4f5f3;
color: #005f73;
}
.bq.note > .p:first-child::before {
content: "📝";
}
/* Tip ------------------------------------------------- */
.bq.tip {
border-color: #b1daff;
}
.bq.tip > .p:first-child {
background-color: #DEEFFB;
color: #43596C;
}
.bq.tip > .p:first-child::before {
content: "💡";
}
/* Bug ------------------------------------------------- */
.bq.bug {
border-color: #f5a4a4;
}
.bq.bug > .p:first-child {
background-color: #fde6e6;
color: #9c1c1c;
}
.bq.bug > .p:first-child::before {
content: "🐛";
}
/* Question ------------------------------------------------- */
.bq.question {
border-color: #e0c2ff;
}
.bq.question > .p:first-child {
background-color: #f1e7ff;
color: #5e3a8c;
}
.bq.question > .p:first-child::before {
content: "❓";
}
/* Example ------------------------------------------------- */
.bq.example {
border-color: #ffd28a;
}
.bq.example > .p:first-child {
background-color: #fff3dd;
color: #7b4c00;
}
.bq.example > .p:first-child::before {
content: "🌟";
}
/* Todo ------------------------------------------------- */
.bq.todo {
border-color: #ffb366;
}
.bq.todo > .p:first-child {
background-image:
linear-gradient(to right, rgba(255, 179, 102, 0.5) var(--progress), transparent var(--progress));
background-color: #fff2e6;
color: #cc6600;
position: relative;
}
.bq.todo > .p:first-child::before {
content: "✅";
}
.bq.todo > .p:first-child::after {
content: var(--task-stats, "0/0 (0%)");
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
font-size: 0.85em;
font-weight: normal;
opacity: 0.8;
cursor: pointer;
transition: opacity 0.2s ease;
padding: 2px 4px;
border-radius: 3px;
}
.bq.todo > .p:first-child::after:hover {
opacity: 1;
background-color: rgba(255, 179, 102, 0.2);
}

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