碎碎念

前几天在twikoo的交流群中,有人提到了这样一个问题:twikoo可以实现段落评论吗?我想了一下,下载了个番茄小说发现,他们都是按照每一行的内容分别进行评论的,Hexo可以实现类似于每一段落一个Url,也就是#[段落名]的格式,但是Twikoo并不能将这些段落分开,而且本来评论就很少了,(这么一分就都看不见了)。所以我想是否可以利用我的说说页面中的,点击评论按钮后后会在评论区添加一个:> + “文本”,从而实现类似引用的功能,那么也就实现了仿段落评论,同时所有的评论都会在评论区显示,避免了因为都在段落评论而导致主评论区没人的尴尬局面。

群聊的聊天记录
哈哈哈
可以支持对文章某一行进行评论吗?
哈哈哈
某一段落
哈哈哈
类似这样
哈哈哈
就是对文章某一处进行评论
哈哈哈
就和小说很像(((
LiuShen
欸?你别说,你还真别说,好像有搞头啊!

番茄小说

Hexo段落链接

说说卡片回复

回复效果

内容简述

  1. 实现亮暗模式适配
  2. 实现高分辨率适配,设置上下阈值,基本确保不会超出屏幕
  3. 动画效果适配
  4. 自动将节选段落放置在评论框中
  5. 解决文本中含有回车导致函数失效的问题
  6. 解决好友imsyy提出的弹窗中再次点击打开弹窗会导致无法关闭的问题:点击跳转
  7. 解决好友imsyy提出的弹窗中点击刷新按钮会退出的问题:点击跳转
  • 欢迎测试:请选中你想评论的段落并右键,点击:“评论选中段落”按钮即可看到

    测试方法

实现功能

添加按钮

要实现回复功能,首先需要有回复按钮呀,我们先考虑一下逻辑,什么情况需要回复按钮?经过设计,我决定将按钮添加在右键菜单中,并且是文章页,且需要选中文字右键才有效果(因为你不选中文字回复什么段落),我们先添加按钮,如果没有进行魔改右键菜单的请按照别人的教程进行魔改,可以参考下面这些链接(以下方法不分先后,都可以使用)

以上均可以实现右键菜单的魔改,推荐百里飞洋的文章,因为有复制功能,本教程也是基于他的进行修改,这里不再详细讲解,我们直接进入添加段落回复功能。

"[root]\themes\butterfly\layout\includes\rightmenu.pug"文件中修改复制按钮部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
#rightMenu
.rightMenu-group.rightMenu-small
.rightMenu-item#menu-backward
i.fa-solid.fa-arrow-left
.rightMenu-item#menu-forward
i.fa-solid.fa-arrow-right
.rightMenu-item#menu-refresh
i.fa-solid.fa-arrow-rotate-right
.rightMenu-item#menu-home
i.fa-solid.fa-house
.rightMenu-group.rightMenu-line.hide#menu-text
+ a.rightMenu-item#copy(href="javascript:rm.copySelect();")
- a.rightMenu-item(href="javascript:rm.copySelect();")
i.fa-solid.fa-copy
span='复制选中文字'
+ a.rightMenu-item#reply(href="javascript:rm.replySelect();")
+ i.fa-regular.fa-comment
+ span='评论选中段落'
.rightMenu-group.rightMenu-line.rightMenuOther
a.rightMenu-item.menu-link(href='/archives/')
i.fa-solid.fa-archive
span='文章时间线'
a.rightMenu-item.menu-link(href='/categories/')
i.fa-solid.fa-folder-open
span='文章分大类'
a.rightMenu-item.menu-link(href='/tags/')
i.fa-solid.fa-tags
span='文章小标签'
.rightMenu-group.rightMenu-line.rightMenuNormal
a.rightMenu-item.menu-link#menu-radompage(href='/comment/')
i.fa-solid.fa-shoe-prints
span='随心留言板'
.rightMenu-item#menu-translate
i.fa-solid.fa-earth-asia
span='繁简模式切换'
.rightMenu-item#menu-darkmode
i.fa-solid.fa-moon
span='切换亮暗模式'
.rightMenu-item#menu-live2dvisibility
i.fa-solid.fa-cat
span='小猫显示隐藏'
.rightMenu-item#menu-print
i.fa-solid.fa-print.fa-fw
span='打印整个页面'
a.rightMenu-item.menu-link#statement(href='/statement/')
i.fa-regular.fa-copyright.fa-fw
span='网站声明'
#rightmenu-mask

如上代码所示,为了更好的自定义每个元素,我们给原本的复制按钮添加了一个"#copy"表示ID,并在复制的下面添加了回复段落功能,样式前面已经添加了,我们这里不需要进行修改了,只添加元素即可,去掉加减号即为正常缩进,rm.replySelect()为我们的执行函数。

下面我们来控制他的显示和隐藏:

在自定义JS文件中,修改"window.oncontextmenu = function (event)"部分代码:

下方代码2024-04-20更新:第九行添加判断,判断页面中是否存在popup元素

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
window.oncontextmenu = function (event) {
+ $('#menu-text #copy').hide();
+ $('#menu-text #reply').hide();
+ //如果有文字选中,则显示 文字选中相关的菜单项
+ if(document.getSelection().toString()){
+ $('#menu-text #copy').show();
+ }
+ const currentPath = window.location.pathname;
+ if (document.getSelection().toString() && currentPath.startsWith('/posts/')) {
+ // 如果页面中没有弹窗元素,则显示按钮并调用函数显示弹窗
+ if (!document.getElementById('popup')) {
+ $('#menu-text #reply').show();
+ }
+ }
- $('.rightMenu-group.hide').hide();
- //如果有文字选中,则显示 文字选中相关的菜单项
- if(document.getSelection().toString()){
- $('#menu-text').show();
- }
if (document.body.clientWidth > 768) {
let pageX = event.clientX + 10;
let pageY = event.clientY;
let $rightMenuNormal = $(".rightMenuNormal");
let $rightMenuOther = $(".rightMenuOther");
let $rightMenuReadmode = $("#menu-readmode");
$rightMenuNormal.show();
$rightMenuOther.show();
rm.reloadrmSize();
if (pageX + rmWidth > window.innerWidth) {
pageX -= rmWidth;
}
if (pageY + rmHeight > window.innerHeight) {
pageY -= rmHeight;
}
rm.showRightMenu(true, pageY, pageX);
$('#rightmenu-mask').attr('style', 'display: flex');
return false;
}
};

此时就可以基本测试出我们的逻辑是否正常了,hexo三联,在文章页选中文字右键才能看见我们的回复段落功能按钮出现,其他方式均不能触发。

非文章页不选中文字时,右键复制及回复均无法显示

非文章页不选中

非文章页选中文字仅会触发复制

非文章页选中

仅仅在文章页且选中文字的情况下才可以触发该动作

文章页选中

实现函数

这里我会咯嗦我的探索过程,请不想看只想实现功能的铁铁直接跳转到第三部分按照教程顺序实现即可。

妥协方案

下面我们需要实现该功能,刚开始我选择的时使用和说说页面类似的效果,当点击评论后,找到评论区输入框,将选中文字放到输入框中,进行类似于回复段落的效果,但是由于我设置的懒加载,当评论区没有滚入到页面视野内时不会自动加载,会导致如下查找输入框语句失败:

1
const commentBox = document.querySelector(".el-textarea__inner");

这样也就无法输入到文本框了,所以我刚开始想了个妥协的方法,就是当没加载评论框时弹出提示说没有加载,需要手动滚到下方进行加载,这也是第一代方案,草草了事了~

不完美实现方案

第二天起床,我想了一下,他没加载评论,那我就自己提前加载一下呗?于是我开始翻看Twikoo的文档,找到了以下文档:

其中内容如下:

Twikoo通过CDN引入

于是我写出了如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
// 加载Twikoo库
function loadTwikooLibrary() {
return new Promise((resolve, reject) => {
var script = document.createElement('script');
script.src = 'https://cdn.staticfile.org/twikoo/1.6.32/twikoo.all.min.js';
script.onload = function() {
console.log('Twikoo库加载成功');
resolve();
};
script.onerror = function() {
reject('Twikoo库加载失败');
};
document.head.appendChild(script);
});
}

// 使用异步/await确保Twikoo库加载完成后执行操作
async function replySelect() {
removeRightMenu();
var selectedText = document.getSelection().toString();
if (selectedText.includes('\n')) {
selectedText = selectedText.split('\n')[0];
}

// 检查评论框元素是否存在
var commentBoxTest = document.querySelector(".el-textarea__inner");
if (!commentBoxTest) {
console.log("加载评论中");
try {
// 等待Twikoo库加载完成
await loadTwikooLibrary();
// 初始化Twikoo
twikoo.init({
envId: 'ddddddddddddd地址',
el: '#post-comment',
});
} catch (error) {
console.error(error);
}
}

var commentBox = document.querySelector(".el-textarea__inner");
commentBox.value = `> ${selectedText}\n\n`;
commentBox.focus();
var replySelectMessage = document.createElement('div');
replySelectMessage.textContent = '不要删除空行显示效果更佳哦!';
replySelectMessage.style.position = 'fixed';
replySelectMessage.style.top = '50%';
replySelectMessage.style.left = '50%';
replySelectMessage.style.transform = 'translate(-50%, -50%)';
replySelectMessage.style.backgroundColor = 'rgba(0, 0, 0, 0.7)';
replySelectMessage.style.color = '#fff';
replySelectMessage.style.padding = '10px 20px';
replySelectMessage.style.borderRadius = '5px';
replySelectMessage.style.zIndex = '9999';
document.body.appendChild(replySelectMessage);

// 2秒后移除提示消息
setTimeout(function(){
document.body.removeChild(replySelectMessage);
}, 2000);
}

// 使用replySelect函数
rm.replySelect = replySelect;

这个方法解决了评论区没有加载的问题,但是因为他的评论区是只引入了一个twikoo,而我的评论区是双评论(其实屁用没有,不过当摆设倒是很高大上),导致他的样式和主题加载出来的有些不同,这里我没有记录截图,现在回退回去也有点麻烦,所以我就放一张正常的,解释一下哪里样式有问题:

红色部分为直接加载缺失的部分

所以这个方法实现的也不是很完美,感觉怪郁闷的,因为本人为中度强迫症患者,只要我能解决的我会去解决,解决不了的我宁愿给他删掉。。。所以,我想出了最后的一种方案:弹窗法。

完美(可能)实现方案

经过了半天的思考,我在想,为什么我会被说说的评价局限住呢?我可以参考一下番茄小说,每段话后面有个按钮,点击后弹窗,那我也可以这么实现吧?再就是,我选中文字回复后,会跳转到页面底部的话,就算完美实现了,读者也需要重新跳过去才能继续阅读文章,这很大的影响了读者阅读体验,那我为什么不能原地弹窗,弹出之后不动页面,让读者评论完成后继续看呢?这样我也不需要考虑多评论了,因为这个没有什么主题自带的模板格式,完全是我自己造的,想怎么来怎么来!于是我开始使用JS实现这些功能,为了更加美观直接好理解,我将每个部分的内容封装成了函数:

JS功能实现

首先,加载twikoo的库:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 加载Twikoo库
async function loadTwikooLibrary() {
return new Promise((resolve, reject) => {
if (window.twikoo) {
// 如果 Twikoo 库已经加载过,则直接返回成功的 Promise
resolve();
return;
}

const script = document.createElement('script');
script.src = 'https://cdn.staticfile.org/twikoo/1.6.31/twikoo.all.min.js';
script.onload = () => {
console.log('Twikoo库加载成功');
resolve();
};
script.onerror = () => {
reject(new Error('Twikoo库加载失败'));
};
document.head.appendChild(script);
});
}

这里我解释一下,这段代码会动态加载Twikoo库。返回一个 Promise,用于处理后续的异步操作。首先,它检查窗口对象 window 上是否已经有 twikoo 属性,即 Twikoo 库是否已经加载过。如果已经加载过,比如你提前已经回复过一次或者已经翻到下面加载成功过主评论区,它直接返回一个成功的 Promise。否则,它创建一个 <script> 元素,并将其 src 属性设置为 Twikoo 库的 URL。这会让网页加载 Twikoo 库文件。当库加载成功后,会在控制台中打印 “Twikoo库加载成功”,并返回Promise;如果加载失败,会 reject Promise 并返回错误信息。最后,将 <script> 元素添加到 <head> 部分中,开始加载 Twikoo 库(听不懂没关系,直接抄代码就行)。

下面我们开始创建弹窗,我想创建一个后面是遮罩层,前台一个框的弹窗,于是先创建遮罩层,再创建弹窗,分别写出以下函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 创建遮罩层
function createOverlay() {
const overlay = document.createElement('div');
overlay.id = 'overlay';
overlay.classList.add('overlay'); // 添加 CSS 类
// 添加点击监听器以关闭弹窗
document.addEventListener('click', handleClickOutsidePopup);
return overlay;
}

// 创建弹窗
function createPopup() {
const popup = document.createElement('div');
popup.id = 'popup';
popup.classList.add('popup'); // 添加 CSS 类
return popup;
}

创建了弹窗,我们还需要关闭弹窗,要不然下次就用不了了,于是我们再写一个关闭遮罩层的代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 关闭弹窗并移除遮罩层
function closePopup(popup, overlay) {
// 隐藏遮罩层和弹窗的动画
overlay.style.opacity = 0;
popup.style.opacity = 0;

// 在动画结束后移除元素
setTimeout(() => {
document.body.removeChild(popup);
document.body.removeChild(overlay);
document.removeEventListener('click', handleClickOutsidePopup);
}, 300); // 动画持续时间与 transition 持续时间一致
}

// 点击弹窗外部关闭弹窗
function handleClickOutsidePopup(event) {
const popup = document.getElementById('popup');
if (popup && !popup.contains(event.target)) {
closePopup(popup, document.getElementById('overlay'));
}
}

这里我创建了一个事件,点击弹窗以外的空白位置关闭弹窗(可能是人之常情?总喜欢点别的地方来关闭它,至少我是这样),本来是有个按钮的,但是嫌弃他太丑了给删掉了,后面看看能不能加上一个更加美观的。

然后我将之前的提示消息弹窗的内容也封装成了函数(反正封了这么多不差这一个),方便其他位置直接调用即可,因为代码量还是不小的,有点占地方。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 通用函数:显示提示消息
function showMessage(message, duration = 2000) {
// 创建提示消息元素
const replySelectMessage = document.createElement('div');
replySelectMessage.innerHTML = message;
replySelectMessage.classList.add('pop-message'); // 添加 CSS 类
document.body.appendChild(replySelectMessage);

// 设置初始透明度为0
replySelectMessage.style.opacity = '0';

// 使用 setTimeout 设置动画显示提示消息
setTimeout(() => {
replySelectMessage.style.opacity = '1';
}, 10);

// 定义移除提示消息的函数
function removeMessage() {
// 将透明度设置为0,使其隐藏
replySelectMessage.style.opacity = '0';

// 等待隐藏动画完成后移除元素
setTimeout(() => {
document.body.removeChild(replySelectMessage);
}, 500); // 动画持续时间
}

// 根据指定的持续时间设置定时器来移除提示消息
setTimeout(removeMessage, duration);
}

这个函数接受两个参数,内容和时间(默认两秒),函数都封装完了,需要开始组装了!把面向对象贯彻到底,继续封装函数!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
// 显示带评论的弹窗
function showPopupWithComments(envId, commentElementId) {
// 创建遮罩层
const overlay = createOverlay();

// 创建弹窗
const popup = createPopup();

// 添加评论部分
const commentSection = document.createElement('div');
commentSection.id = commentElementId;
popup.appendChild(commentSection);

// 将弹窗和遮罩添加到文档中
document.body.appendChild(overlay);
document.body.appendChild(popup);

// 初始化 Twikoo
twikoo.init({ envId, el: `#${commentElementId}` });

// 显示提示消息并在 2 秒后移除
showMessage('点击弹窗外任意部分即可退出');

// 显示遮罩层和弹窗的动画
setTimeout(() => {
overlay.style.opacity = 1;
popup.style.opacity = 1;
}, 0);
}

我的注释应该已经够详细了,所以就不解释了,这个就是我们显示待评论的弹窗,然后我们将其在回复按钮的相应事件中调用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// 使用异步/await确保Twikoo库加载完成后执行操作
async function replySelect() {
removeRightMenu();
var selectedText = document.getSelection().toString().trim();

if (selectedText.includes('\n')) {
selectedText = selectedText.split('\n')[0].trim();
}

try {
// 等待Twikoo库加载完成
await loadTwikooLibrary();
// 显示带评论的弹窗
showPopupWithComments('你的twikoo地址或者按照官方的腾讯环境ID', 'comment-section');
} catch (error) {
console.error(error);
}

const commentBox = document.querySelector("#popup .el-textarea__inner");
commentBox.value = `> ${selectedText}\n\n`;
}

// 将 replySelect 函数绑定到特定事件或对象
rm.replySelect = replySelect;

上面需要改你的Twikoo的地址,在倒数第二行,我修改了获取文本框的CSS路径,防止匹配到主评论区了,最后将将 replySelect 函数绑定到事件上。

CSS添加

到这里还没完,因为我们没有指定任何样式,下面是所有的CSS内容,这个比较简单我就不解释啦!直接复制到你的自定义CSS文件中即可!

下方代码2024-04-20更新:第六十四行,由于刷新按钮为Twikoo官方内部封装,为方便后续升级不想对其修改,于是将其隐藏防止误触

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
/* 设置评论弹窗的一些参数 */
/* 遮罩层样式 */
.overlay {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(255, 255, 255, 0.7);
z-index: 999;
opacity: 0; /* 初始透明度为 0 */
transition: opacity 0.3s; /* 过渡效果,持续时间为 0.3 秒 */
}

[data-theme=dark] .overlay {
background-color: rgba(0, 0, 0, 0.7);
}

/* 弹窗样式 */
.popup {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: rgba(255, 255, 255, 0.7);
padding: 20px;
border: 1px solid #ccc;
z-index: 1000;
max-height: 80%;
overflow-y: auto;
border-radius: 20px;
opacity: 0; /* 初始透明度为 0 */
transition: opacity 0.3s; /* 过渡效果,持续时间为 0.3 秒 */
scrollbar-width: none; /* 隐藏滚动条(适用于 Firefox) */
-ms-overflow-style: none; /* 隐藏滚动条(适用于 IE 和 Edge) */
}

[data-theme=dark] .popup {
background-color: rgba(44, 44, 44, 0.7);
border: 1px solid #666;
}

/* 通用弹窗消息样式 */
.pop-message {
position: fixed;
top: 70px; /* 距离顶部10px */
right: 10px; /* 距离右侧10px */
background-color: rgba(255, 255, 255, 0.7);
color: #000000;
padding: 20px 30px;
border-radius: 10px;
z-index: 9999;
border: 1px solid #000000; /* 添加边框,1px宽度,灰色 */
opacity: 0; /* 初始状态设置为透明 */
transition: opacity 1.0s ease-in-out; /* 添加渐变动画 */
}

[data-theme=dark] .pop-message {
background-color: rgba(44, 44, 44, 0.7);
color: #fff;
border: 1px solid #ffffff;
}

/* 由于twikoo内部函数不宜修改,去掉刷新按钮 */
#popup #twikoo .tk-comments .tk-comments-container .tk-comments-title > span:nth-child(2) > span:nth-child(1) {
display: none;
}

展示效果

最终的效果我也很满意,下面是一些效果图:

白天模式适配

夜间模式适配

其实后面想想,手机也没有右键菜单啊?算了不管了,就当是分辨率适配啦

窄屏模式适配

总结

这次魔改是我最近比较大的一次尝试,作为入场一个月的小白,慢慢成长,我也能感受到我的收获,后面我会继续学习,实现更多的功能!

完结撒花!

每日一图

非原图,清晰度可能不高