feat(comments): 匿名评论 + 表情最近使用;fix: 自动填充聚焦提示 & 邮件模板回退原版 & 返回顶部改 CSS 动画
- 匿名评论:comments.is_anonymous 列;对外昵称/头像/网址/徽标/等级/IP归属全部脱敏, 真实身份留 users 表(回复通知照发、autofill 不受污染);邮件昵称也用「匿名」防泄露 - 表情包最近使用:面板顶部「最近」栏,localStorage 记录 8 个,零侵入复用 Artalk 插入逻辑 - 匿名开关插在 textarea 与底栏之间独立一行,不挤移动端底栏 - 自动填充:提示改 focusin 触发(原 input+sessionStorage 一次性门导致永不触发); 已填昵称/网址时不覆盖并弹提示 - 邮件模板 UI 回退原版深色气泡设计,保留表情内联尺寸/主题文案等逻辑修复 - 返回顶部:rAF 自绘改 CSS scroll-behavior(滚动期临时挂类),修移动端掉帧
This commit is contained in:
1 parent
090262d278
commit
215929a67f
11 files changed
+587
-207
No files matched your search
@@ -62,6 +62,7 @@ CREATE TABLE IF NOT EXISTS comments (
|
||||
is_pending INTEGER NOT NULL DEFAULT 0,
|
||||
is_pinned INTEGER NOT NULL DEFAULT 0,
|
||||
is_verified INTEGER NOT NULL DEFAULT 0,
|
||||
is_anonymous INTEGER NOT NULL DEFAULT 0, -- 匿名评论:对外显示「匿名」,真实身份只留后台
|
||||
vote_up INTEGER NOT NULL DEFAULT 0,
|
||||
vote_down INTEGER NOT NULL DEFAULT 0,
|
||||
ua TEXT NOT NULL DEFAULT '',
|
||||
|
||||
+23
-15
@@ -215,15 +215,24 @@ export async function cookComments(
|
||||
const user = users.get(c.user_id);
|
||||
const siteUrl = siteUrlMap.get(c.site_name) || '';
|
||||
const page = pageMap.get(`${c.site_name}\u0000${c.page_key}`);
|
||||
|
||||
// ── 匿名评论 ─────────────────────────────────────────────────────────
|
||||
// 真实身份(昵称/邮箱/网址)只留在 users 表里:回复邮件通知照发、
|
||||
// 老友自动填充不受污染;但**对外**一切能对上号的东西都抹掉 ——
|
||||
// 昵称、头像(换通用图)、网址、徽标、等级(评论数会暴露是谁)、IP 归属。
|
||||
const anon = !!c.is_anonymous;
|
||||
const nick = anon ? '匿名' : user?.name ?? '';
|
||||
const avatarHash = anon ? md5Lower('anonymous@comment.local') : md5Lower(user?.email ?? '');
|
||||
|
||||
const cook: CookedComment = {
|
||||
id: c.id,
|
||||
content: c.content,
|
||||
content_marked: opts.includeMarked ? renderMarkdown(c.content) : '',
|
||||
user_id: c.user_id,
|
||||
nick: user?.name ?? '',
|
||||
email_encrypted: md5Lower(user?.email ?? ''),
|
||||
link: user?.link ?? '',
|
||||
ua: c.ua || '',
|
||||
nick,
|
||||
email_encrypted: avatarHash,
|
||||
link: anon ? '' : user?.link ?? '',
|
||||
ua: anon ? '' : c.ua || '',
|
||||
date: formatDateCN(c.created_at),
|
||||
is_collapsed: !!c.is_collapsed,
|
||||
is_pending: !!c.is_pending,
|
||||
@@ -231,9 +240,9 @@ export async function cookComments(
|
||||
is_allow_reply: !c.is_collapsed && !c.is_pending,
|
||||
is_verified: user?.is_admin ? true : !!c.is_verified,
|
||||
rid: c.rid,
|
||||
badge_name: user?.badge_name ?? '',
|
||||
badge_color: user?.badge_color ?? '',
|
||||
title_name: user?.title_name || '',
|
||||
badge_name: anon ? '' : user?.badge_name ?? '',
|
||||
badge_color: anon ? '' : user?.badge_color ?? '',
|
||||
title_name: anon ? '' : user?.title_name || '',
|
||||
visible: opts.visibility?.get(c.id) ?? true,
|
||||
vote_up: c.vote_up || 0,
|
||||
vote_down: c.vote_down || 0,
|
||||
@@ -242,15 +251,14 @@ export async function cookComments(
|
||||
site_name: c.site_name,
|
||||
};
|
||||
if (opts.ipRegion && c.ip_region) cook.ip_region = c.ip_region;
|
||||
// 官职徽标独立字段:不占用官方 badge 槽(博主的「博主」徽章独一无二,
|
||||
// 官职接在它后面由主题渲染),配色沿用主题原版(彩字 + 半透明底)
|
||||
const rank = getRank(commentCounts.get(c.user_id) ?? 0);
|
||||
const myCount = commentCounts.get(c.user_id) ?? 0;
|
||||
cook.rank_name = rank.short;
|
||||
cook.rank_title = rank.title;
|
||||
// 匿名评论不显示等级(等级 = 真实评论数,能对上号),也不显示 IP 归属
|
||||
const rank = anon ? null : getRank(commentCounts.get(c.user_id) ?? 0);
|
||||
const myCount = anon ? 0 : commentCounts.get(c.user_id) ?? 0;
|
||||
cook.rank_name = anon ? '' : rank!.short;
|
||||
cook.rank_title = anon ? '' : rank!.title;
|
||||
cook.rank_count = myCount;
|
||||
cook.rank_color = rank.color;
|
||||
cook.rank_bg = rank.bg;
|
||||
cook.rank_color = anon ? '' : rank!.color;
|
||||
cook.rank_bg = anon ? '' : rank!.bg;
|
||||
return cook;
|
||||
});
|
||||
}
|
||||
+129
-150
@@ -107,162 +107,20 @@ export async function mailQuotaOk(env: Env, limit = 90): Promise<boolean> {
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 模板 —— 微信聊天式:灰底 #EDEDED、自己的评论绿色气泡(#95EC69)靠右、
|
||||
// 对方的白色气泡靠左、圆角方头像、时间居中 —— 用大家天天在看的聊天直觉
|
||||
// 来传达「谁说了什么」,一眼就懂,不需要解释。
|
||||
// 模板 —— 以站点的原版模板(notice.html / reply.html)为底,UI 完全保持
|
||||
// 原设计:#f4f5f8 灰底卡片、居中标题、深色聊天气泡 #373737 + 三角箭头、
|
||||
// 35px 圆头像(weavatar,邮箱 md5,明文不出现在邮件里)、点气泡跳转评论页。
|
||||
//
|
||||
// 以站点的老模板(notice.html / reply.html)为底改:头像(weavatar,邮箱
|
||||
// md5,明文不出现在邮件里)、点气泡跳转评论页这些老设计都保留。
|
||||
//
|
||||
// 相比老模板的两处实质修复:
|
||||
// 1. 表情包尺寸内联到位:老模板靠 head 里 `.comment_img img{width:100px}`
|
||||
// 相比原版只动逻辑层,UI 一像素不差之外的三处细节打磨:
|
||||
// 1. 表情包尺寸内联到位:原版靠 head 里 `.comment_img img{width:100px}`
|
||||
// 控制大小,而不少邮件客户端会把 head 的 <style> 整段丢掉;一丢,
|
||||
// 表情图就按**原始像素**铺开(站里有几套是 400px+ 的大图),气泡被
|
||||
// 撑爆 —— 这就是「表情太大看着奇怪」的真因。现在每个 <img> 都带
|
||||
// 内联尺寸:表情 max-height 28px(微信小表情的体感),配图限宽 100%。
|
||||
// 2. 布局全用嵌套 <table>(气泡本身就是一个表格,宽度随内容收缩、
|
||||
// 上限 360px),不依赖 flex / float / inline-block —— Outlook 与
|
||||
// 各家 webmail 对表格的支持是最稳的,图片限宽也不会算错。
|
||||
//
|
||||
// 另外管理员邮件主题由「新的评论待审」改成一句人话(见 ADMIN_SUBJECT)。
|
||||
// 内联尺寸:表情 max-height 28px,配图限宽 100%(不撑破气泡)。
|
||||
// 2. 邮件主题改成一句人话(见 ADMIN_SUBJECT / REPLY_SUBJECT)。
|
||||
// 3. 字体栈、头像 alt 等无障碍小细节。
|
||||
// ============================================================================
|
||||
|
||||
/** 取首字(码点安全,emoji/生僻字不会被截断) */
|
||||
const firstChar = (v: unknown): string => {
|
||||
const s = String(v ?? '').trim();
|
||||
return [...s][0] || '?';
|
||||
};
|
||||
|
||||
/** 气泡里的正文块(保留老模板的 .comment_img 类名,供 head 样式兜底) */
|
||||
const BODY_OPEN = '<div class="comment_img">';
|
||||
const BODY_CLOSE = '</div>';
|
||||
|
||||
/** 老模板 head 里的那点样式:整体 reset + 表情尺寸兜底(内联样式为主,这只是保险) */
|
||||
const HEAD_STYLE = `<style>
|
||||
*{margin:0;padding:0;box-sizing:border-box}
|
||||
img[atk-emoticon]{max-height:28px;width:auto;vertical-align:middle}
|
||||
</style>`;
|
||||
|
||||
/** 头像:40px 圆角方(微信样式),weavatar 按 Gravatar 规则用邮箱 md5 取图 */
|
||||
const AVATAR = (prefix: string, nick: string) =>
|
||||
`<img src="https://weavatar.com/avatar/{{${prefix}.email_encrypted}}" width="40" height="40" alt="${nick}" style="width:40px;height:40px;border-radius:6px;display:block;border:0">`;
|
||||
|
||||
/**
|
||||
* 气泡:bg 为 #95ec69(自己)/ #ffffff(对方)。
|
||||
* 气泡本身是一个嵌套 <table>(宽度随内容收缩、上限 360px),比 inline-block
|
||||
* 在邮件客户端里稳得多 —— 图片的 max-width 百分比也能正确解析。
|
||||
* align="right" 时气泡贴右(微信里自己说的话)。
|
||||
*/
|
||||
const BUBBLE = (bg: string, color: string, inner: string, align?: 'right') =>
|
||||
`<table role="presentation" cellpadding="0" cellspacing="0" border="0"${align ? ` align="${align}"` : ''} style="max-width:360px">
|
||||
<tr><td bgcolor="${bg}" style="background-color:${bg};border-radius:8px;padding:10px 12px;font-size:14px;color:${color};line-height:1.7;word-break:break-word;text-align:left">${inner}</td></tr>
|
||||
</table>`;
|
||||
|
||||
/** 回复提醒:自己的评论(绿泡靠右)+ 对方的新回复(白泡靠左),微信式上下两条 */
|
||||
const REPLY_TPL = `<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title></title>
|
||||
${HEAD_STYLE}
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#ededed">
|
||||
<div style="width:100%;max-width:560px;margin:0 auto;background-color:#ededed;padding:20px 16px;box-sizing:border-box;font-family:'PingFang SC','Hiragino Sans GB','Microsoft YaHei',-apple-system,'Segoe UI',sans-serif">
|
||||
|
||||
<div style="text-align:center;font-size:15px;font-weight:600;color:#1a1a1a;line-height:1.6">你在<a href="{{link_to_reply}}" target="_blank" rel="noopener" style="color:#07c160;text-decoration:none">《{{page_title}}》</a>的评论有回复</div>
|
||||
<div style="text-align:center;font-size:12px;color:#b2b2b2;padding:12px 0 20px">{{parent_comment.datetime}}</div>
|
||||
|
||||
<div style="margin-bottom:18px">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td valign="top" align="right" style="text-align:right;padding-right:10px">
|
||||
<div style="font-size:12px;color:#9b9b9b;margin-bottom:5px">{{parent_comment.nick}}</div>
|
||||
${BUBBLE('#95ec69', '#111111', `<a href="{{link_to_reply}}" target="_blank" rel="noopener" style="text-decoration:none;color:#111111">${BODY_OPEN}{{parent_comment.content}}${BODY_CLOSE}</a>`, 'right')}
|
||||
</td>
|
||||
<td width="44" valign="top" style="width:44px">${AVATAR('parent_comment', '{{parent_comment.nick}}')}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div style="text-align:center;font-size:12px;color:#b2b2b2;padding:2px 0 14px">{{comment.datetime}}</div>
|
||||
|
||||
<div style="margin-bottom:8px">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td width="44" valign="top" style="width:44px">${AVATAR('comment', '{{comment.nick}}')}</td>
|
||||
<td valign="top" style="padding-left:10px">
|
||||
<div style="font-size:12px;color:#9b9b9b;margin-bottom:5px">{{comment.nick}}</div>
|
||||
${BUBBLE('#ffffff', '#111111', `<a href="{{link_to_reply}}" target="_blank" rel="noopener" style="text-decoration:none;color:#111111">${BODY_OPEN}{{comment.content}}${BODY_CLOSE}</a>`)}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td align="center" style="padding-top:26px">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td bgcolor="#07c160" style="background-color:#07c160;border-radius:8px">
|
||||
<a href="{{link_to_reply}}" target="_blank" rel="noopener" style="display:inline-block;padding:11px 34px;color:#ffffff;font-size:14px;font-weight:500;text-decoration:none">查看并回复</a>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
<div style="text-align:center;font-size:12px;color:#9b9b9b;margin-top:26px;line-height:1.9">
|
||||
此邮件来自<a href="{{site_url}}" target="_blank" rel="noopener" style="color:#07c160;text-decoration:none">{{site_name}}</a>,请勿直接回复<br>
|
||||
不想再收到这类提醒,可以在评论时取消勾选「接收邮件通知」
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
/** 管理员通知:一条新评论(白泡靠左),微信式 */
|
||||
const NOTICE_TPL = `<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title></title>
|
||||
${HEAD_STYLE}
|
||||
</head>
|
||||
<body style="margin:0;padding:0;background-color:#ededed">
|
||||
<div style="width:100%;max-width:560px;margin:0 auto;background-color:#ededed;padding:20px 16px;box-sizing:border-box;font-family:'PingFang SC','Hiragino Sans GB','Microsoft YaHei',-apple-system,'Segoe UI',sans-serif">
|
||||
|
||||
<div style="text-align:center;font-size:15px;font-weight:600;color:#1a1a1a;line-height:1.6"><a href="{{link_to_reply}}" target="_blank" rel="noopener" style="color:#07c160;text-decoration:none">《{{page_title}}》</a>有了新的评论</div>
|
||||
<div style="text-align:center;font-size:12px;color:#b2b2b2;padding:12px 0 20px">{{comment.datetime}}</div>
|
||||
|
||||
<div style="margin-bottom:8px">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td width="44" valign="top" style="width:44px">${AVATAR('comment', '{{comment.nick}}')}</td>
|
||||
<td valign="top" style="padding-left:10px">
|
||||
<div style="font-size:12px;color:#9b9b9b;margin-bottom:5px">{{comment.nick}}</div>
|
||||
${BUBBLE('#ffffff', '#111111', `<a href="{{link_to_reply}}" target="_blank" rel="noopener" style="text-decoration:none;color:#111111">${BODY_OPEN}{{comment.content}}${BODY_CLOSE}</a>`)}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td align="center" style="padding-top:26px">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr><td bgcolor="#07c160" style="background-color:#07c160;border-radius:8px">
|
||||
<a href="{{link_to_reply}}" target="_blank" rel="noopener" style="display:inline-block;padding:11px 34px;color:#ffffff;font-size:14px;font-weight:500;text-decoration:none">前往查看</a>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
|
||||
<div style="text-align:center;font-size:12px;color:#9b9b9b;margin-top:26px;line-height:1.9">
|
||||
此邮件来自<a href="{{site_url}}" target="_blank" rel="noopener" style="color:#07c160;text-decoration:none">{{site_name}}</a>博客,请勿直接回复
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
/**
|
||||
* 邮件主题。
|
||||
* 回复:沿用老配置「新的评论回复」。
|
||||
@@ -271,6 +129,127 @@ ${HEAD_STYLE}
|
||||
export const REPLY_SUBJECT = '{{site_name}} · 您的评论收到了新回复';
|
||||
export const ADMIN_SUBJECT = '{{site_name}} · 您有一个新的评论';
|
||||
|
||||
// ------------------------------------------------------------------ 模板本体
|
||||
|
||||
/** 取首字(码点安全,emoji/生僻字不会被截断) */
|
||||
const firstChar = (v: unknown): string => {
|
||||
const s = String(v ?? '').trim();
|
||||
return [...s][0] || '?';
|
||||
};
|
||||
|
||||
/** 原版 head 里的样式:整体 reset + 表情尺寸兜底(客户端丢 head 时由内联样式接管) */
|
||||
const HEAD_STYLE = `<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box
|
||||
}
|
||||
.comment_img img{
|
||||
width: 100px;
|
||||
}
|
||||
</style>`;
|
||||
|
||||
/** 原版卡片外框(550px 灰底卡片),font-family 属于细节打磨,不影响版式 */
|
||||
const WRAP_STYLE =
|
||||
'width: 100%;max-width: 550px;margin:1rem auto;background-color: #f4f5f8;padding: 1rem;box-sizing: border-box;' +
|
||||
"font-family:-apple-system,'PingFang SC','Hiragino Sans GB','Microsoft YaHei',sans-serif";
|
||||
|
||||
/** 原版 35px 圆头像(weavatar 按 Gravatar 规则用邮箱 md5 取图,明文不出现在邮件里) */
|
||||
const AVATAR = (prefix: string, nick: string) =>
|
||||
`<img src="https://weavatar.com/avatar/{{${prefix}.email_encrypted}}" alt="${nick}" style="width: 35px;height: 35px;border-radius: 50%;">`;
|
||||
|
||||
/**
|
||||
* 原版深色气泡 #373737 + 三角箭头,正文 13px 白字,点气泡跳转评论页。
|
||||
* dir=left:箭头在左(对方说的话,头像在左);dir=right:镜像,头像在右。
|
||||
*/
|
||||
const BUBBLE_ROW = (dir: 'left' | 'right', prefix: string, nick: string) => {
|
||||
const right = dir === 'right';
|
||||
const rowStyle = right ? 'display: flex;justify-content: flex-end;margin-bottom: 5px' : 'display: flex;margin-bottom: 5px';
|
||||
const avatarCell = right ? 'width: auto;flex: none;order: 2' : 'width: auto;flex: none';
|
||||
const bubbleCell = right ? 'position: relative;margin-right: 20px' : 'position: relative;margin-left: 20px';
|
||||
const arrow = right
|
||||
? 'width: 0;height: 0;border-top:8px solid transparent;border-bottom:8px solid transparent;border-left:8px solid;border-left-color:#373737;border-right:0;border-right-color:transparent;right: -8px;left: auto;top: 12px;position: absolute'
|
||||
: 'width: 0;height: 0;border-top: 8px solid transparent;border-bottom: 8px solid transparent;border-right: 8px solid;border-right-color: #373737;left: -8px;right: auto;top: 12px;position: absolute';
|
||||
const nickStyle = right
|
||||
? 'text-align: right;line-height: 2rem;font-size: 13px;font-weight: bold;margin-right: 5px;'
|
||||
: 'line-height: 2rem;font-size: 13px;font-weight: bold;margin-left: 5px;';
|
||||
return ` <div style="${nickStyle}">{{${prefix}.nick}}</div>
|
||||
<div style="${rowStyle}">
|
||||
<div style="${avatarCell}">${AVATAR(prefix, nick)}</div>
|
||||
<div style="${bubbleCell}"><span
|
||||
style="${arrow}"></span>
|
||||
<div
|
||||
style="background-color: #373737;padding: 10px;border-radius: 9px;margin-bottom: 3px;font-size: 13px;color: #ffffff;word-break: break-all;"><a href="{{link_to_reply}}" target="_blank" rel="noopener"
|
||||
style="text-decoration: none;color:#ffffff;"><div class="comment_img">{{${prefix}.content}}</div></a></div>
|
||||
</div>
|
||||
</div>`;
|
||||
};
|
||||
|
||||
/** 原版结尾:「温馨提示」+ 来源声明 */
|
||||
const FOOTER = ` <div style="text-align: center;padding: 3rem 0;font-size: 12px;"></div>
|
||||
<div style="text-align: center;color: #555555;font-size: 12px;">温馨提示:点击聊天气泡跳转评论页</div>
|
||||
<div style="text-align: center;color: #555555;font-size: 12px;">此邮件来自<a href="{{site_url}}" target="_blank" rel="noopener" style="text-decoration: none;color:#373737;">{{site_name}}</a>博客,请勿直接回复,谢谢合作!</div>`;
|
||||
|
||||
/** 回复提醒:原版结构 —— 收信人自己的评论(气泡靠右)在上,新回复(靠左)在下 */
|
||||
const REPLY_TPL = `<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title></title>
|
||||
${HEAD_STYLE}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div style="${WRAP_STYLE}">
|
||||
<div style="font-size: 16px;line-height: 1.5;font-weight: bold;">
|
||||
<div style="text-align: center;">你在<a href="{{link_to_reply}}" target="_blank" rel="noopener"
|
||||
style="text-decoration: none;color:#373737;">《{{page_title}}》</a>的评论有回复</div>
|
||||
</div><!--!!!-->
|
||||
<div style="text-align: center;font-size: 12px;color: #c8c8c8;padding: 1rem 0;">{{parent_comment.datetime}}</div>
|
||||
<div style="display: flex;flex-direction:column;margin-bottom: 3rem;">
|
||||
${BUBBLE_ROW('right', 'parent_comment', '{{parent_comment.nick}}')}
|
||||
</div><!--!!!--><!--!!!-->
|
||||
<div style="text-align: center;font-size: 12px;color: #c8c8c8;padding: 1rem 0;">{{comment.datetime}}</div>
|
||||
<div style="display: flex;flex-direction:column;">
|
||||
${BUBBLE_ROW('left', 'comment', '{{comment.nick}}')}
|
||||
</div><!--!!!-->
|
||||
${FOOTER}
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>`;
|
||||
|
||||
/** 管理员通知:原版结构 —— 一条新评论(气泡靠左) */
|
||||
const NOTICE_TPL = `<!DOCTYPE html>
|
||||
<html lang="zh">
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta http-equiv="X-UA-Compatible" content="ie=edge">
|
||||
<title></title>
|
||||
${HEAD_STYLE}
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div style="${WRAP_STYLE}">
|
||||
<div style="font-size: 16px;line-height: 1.5;font-weight: bold;">
|
||||
<div style="text-align: center;"><a href="{{link_to_reply}}" target="_blank" rel="noopener"
|
||||
style="text-decoration: none;color:#373737;">《{{page_title}}》</a>页面有了新的评论</div>
|
||||
</div><!--!!!-->
|
||||
<div style="text-align: center;font-size: 12px;color: #c8c8c8;padding: 1rem 0;">{{comment.datetime}}</div>
|
||||
<div style="display: flex;flex-direction:column;">
|
||||
${BUBBLE_ROW('left', 'comment', '{{comment.nick}}')}
|
||||
</div><!--!!!-->
|
||||
${FOOTER}
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>`;
|
||||
|
||||
// ------------------------------------------------------------------ 渲染
|
||||
|
||||
const esc = (v: unknown): string =>
|
||||
|
||||
@@ -428,6 +428,11 @@ export async function createComment(ctx: Ctx): Promise<Response> {
|
||||
return fail(403, 'Admin access required', { need_login: true });
|
||||
}
|
||||
|
||||
// 匿名评论:真实身份只留在 users 表(回复通知照发、自动填充不受污染),
|
||||
// 对外一律显示「匿名」。管理员身份本来就不是匿名的,忽略标志。
|
||||
const anonymous =
|
||||
!admin && (body.anonymous === true || body.anonymous === 1 || body.anonymous === '1' || body.anonymous === 'true');
|
||||
|
||||
// 匿名用户:按 name+email 找或建
|
||||
let author = user;
|
||||
let isVerified = true;
|
||||
@@ -455,8 +460,8 @@ export async function createComment(ctx: Ctx): Promise<Response> {
|
||||
const t = now();
|
||||
const res = await env.DB.prepare(
|
||||
`INSERT INTO comments
|
||||
(content, page_key, site_name, user_id, rid, root_id, is_pending, is_verified, ua, ip, ip_region, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
(content, page_key, site_name, user_id, rid, root_id, is_pending, is_verified, is_anonymous, ua, ip, ip_region, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
content,
|
||||
@@ -467,6 +472,7 @@ export async function createComment(ctx: Ctx): Promise<Response> {
|
||||
rootId,
|
||||
pending ? 1 : 0,
|
||||
isVerified ? 1 : 0,
|
||||
anonymous ? 1 : 0,
|
||||
ua,
|
||||
ip,
|
||||
ipRegion,
|
||||
@@ -504,7 +510,9 @@ export async function createComment(ctx: Ctx): Promise<Response> {
|
||||
const siteUrl = await siteFirstUrl(env, siteName);
|
||||
await notifyByEmail(env, {
|
||||
newComment: row,
|
||||
author,
|
||||
// 匿名评论:邮件里的昵称也用「匿名」——收件人可能是任何第三方,
|
||||
// 真实昵称不能从邮件漏出去(邮箱本来就是只进不出)
|
||||
author: anonymous ? { ...author, name: '匿名' } : author,
|
||||
parent,
|
||||
parentAuthor,
|
||||
siteName,
|
||||
|
||||
@@ -112,6 +112,7 @@ export interface CommentRow {
|
||||
is_pending: number;
|
||||
is_pinned: number;
|
||||
is_verified: number;
|
||||
is_anonymous: number;
|
||||
vote_up: number;
|
||||
vote_down: number;
|
||||
ua: string;
|
||||
|
||||
@@ -4201,6 +4201,100 @@ input[type=submit] {
|
||||
}
|
||||
|
||||
/* Back to Top Button */
|
||||
/* 匿名评论开关:插在 textarea 与底栏之间的独立一行(右对齐),不挤底栏 */
|
||||
.atk-anon-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 5px;
|
||||
padding: 0 10px 6px;
|
||||
font-size: 12px;
|
||||
color: #9b9b9b;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
.atk-anon-toggle input {
|
||||
margin: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
cursor: pointer;
|
||||
accent-color: #373737;
|
||||
}
|
||||
.atk-anon-toggle:hover {
|
||||
color: #555;
|
||||
}
|
||||
[data-theme="dark"] .atk-anon-toggle {
|
||||
color: #8a8f98;
|
||||
}
|
||||
[data-theme="dark"] .atk-anon-toggle:hover {
|
||||
color: #c3c7cd;
|
||||
}
|
||||
[data-theme="dark"] .atk-anon-toggle input {
|
||||
accent-color: #d1d5db;
|
||||
}
|
||||
|
||||
/* 表情面板「最近使用」栏:面板顶部横向一行,移动端可横滑 */
|
||||
.atk-recent-emoticons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 12px;
|
||||
margin-bottom: 2px;
|
||||
border-bottom: 1px dashed rgba(128, 128, 128, 0.28);
|
||||
overflow-x: auto;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
.atk-recent-emoticons .atk-recent-label {
|
||||
flex: none;
|
||||
font-size: 12px;
|
||||
color: #9b9b9b;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.atk-recent-emoticons ul {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
.atk-recent-emoticons li {
|
||||
flex: none;
|
||||
display: flex;
|
||||
}
|
||||
.atk-recent-emoticons img {
|
||||
max-height: 32px;
|
||||
width: auto;
|
||||
vertical-align: middle;
|
||||
cursor: pointer;
|
||||
padding: 2px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.atk-recent-emoticons img:hover {
|
||||
background: rgba(128, 128, 128, 0.16);
|
||||
}
|
||||
[data-theme="dark"] .atk-recent-emoticons {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
[data-theme="dark"] .atk-recent-emoticons .atk-recent-label {
|
||||
color: #8a8f98;
|
||||
}
|
||||
[data-theme="dark"] .atk-recent-emoticons img:hover {
|
||||
background: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
|
||||
/* CSS 平滑滚动:只在程序触发的滚动期间由 JS 挂到 <html> 上,滚完即摘。
|
||||
滚动交给浏览器合成器处理,不占 JS 主线程 —— 移动端不再像 rAF 自绘那样掉帧。 */
|
||||
html.css-smooth-scroll {
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html.css-smooth-scroll {
|
||||
scroll-behavior: auto;
|
||||
}
|
||||
}
|
||||
|
||||
#back-to-top {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* comment-anonymous —— 「匿名评论」开关
|
||||
*
|
||||
* 场景:有人想说话但不想暴露身份(昵称/头像/网址都能对上号)。
|
||||
* 勾选后提交的评论对外显示为「匿名」:头像换通用图、网址/徽标/等级/IP归属
|
||||
* 全部隐藏。真实昵称和邮箱仍存在后端 users 表里 —— 有人回复时通知照发、
|
||||
* 下次来「填邮箱自动带出昵称」不受污染。博主后台能看到真实身份(审核需要)。
|
||||
*
|
||||
* 实现要点:
|
||||
* - 开关插在 textarea 和底栏之间**独立一行**(右对齐小开关),不挤底栏 ——
|
||||
* 移动端底栏本来就塞了表情/图片/发送按钮,再塞一个就挤爆了。
|
||||
* - Artalk 是 editorTravel 单实例游走,全站只有一个活的 .atk-main-editor,
|
||||
* fetch 拦截读它的开关状态即可。
|
||||
* - 借已有的 fetch 拦截链给 POST /api/v2/comments 的 body 注入 anonymous:true。
|
||||
*/
|
||||
(function () {
|
||||
if (window.__atkAnonReady) return; // PJAX 重复执行防抖
|
||||
window.__atkAnonReady = true;
|
||||
|
||||
var TIP = '对外显示为「匿名」,头像和网址会隐藏;邮箱只用于接收回复通知,对外不可见';
|
||||
|
||||
// 取「当前活着的」编辑器里的开关:优先可见的(editorTravel 游走的那个)
|
||||
function currentToggle() {
|
||||
var eds = document.querySelectorAll('.atk-main-editor');
|
||||
var fallback = null;
|
||||
for (var i = 0; i < eds.length; i++) {
|
||||
var cb = eds[i].querySelector('.atk-anon-toggle input');
|
||||
if (!cb) continue;
|
||||
if (eds[i].offsetParent !== null) return cb; // 可见编辑器优先
|
||||
if (!fallback) fallback = cb;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function inject(editor) {
|
||||
if (!editor || !editor.querySelector) return;
|
||||
if (editor.querySelector('.atk-anon-toggle')) return; // 已注入
|
||||
var bottom = editor.querySelector('.atk-bottom');
|
||||
if (!bottom) return; // 结构没就绪,等下轮扫描
|
||||
|
||||
var label = document.createElement('label');
|
||||
label.className = 'atk-anon-toggle';
|
||||
label.title = TIP;
|
||||
var cb = document.createElement('input');
|
||||
cb.type = 'checkbox';
|
||||
var txt = document.createElement('span');
|
||||
txt.textContent = '匿名评论';
|
||||
label.appendChild(cb);
|
||||
label.appendChild(txt);
|
||||
bottom.parentNode.insertBefore(label, bottom);
|
||||
}
|
||||
|
||||
var scanTimer = null;
|
||||
function scheduleScan() {
|
||||
if (scanTimer) return;
|
||||
scanTimer = setTimeout(function () {
|
||||
scanTimer = null;
|
||||
try {
|
||||
var eds = document.querySelectorAll('.atk-main-editor');
|
||||
for (var i = 0; i < eds.length; i++) inject(eds[i]);
|
||||
} catch (e) {}
|
||||
}, 120);
|
||||
}
|
||||
|
||||
try {
|
||||
// 编辑器由 Artalk 动态创建(PJAX / 点回复时游走重建位置),观察兜底
|
||||
new MutationObserver(scheduleScan).observe(document.body, { childList: true, subtree: true });
|
||||
} catch (e) {}
|
||||
// MutationObserver 不监听「隐藏节点变可见」,用点击/聚焦兜底扫一遍
|
||||
document.addEventListener('click', scheduleScan, true);
|
||||
document.addEventListener('focusin', scheduleScan, true);
|
||||
scheduleScan();
|
||||
|
||||
// ── fetch 拦截:给新建评论请求注入 anonymous 标志 ──────────────────────
|
||||
// Artalk 的提交走 fetch + JSON body;链上已有别的拦截器(提交反馈/失败
|
||||
// 翻译),这里只改 init.body,然后原样放行,互不干扰。
|
||||
var origFetch = window.fetch;
|
||||
if (typeof origFetch === 'function') {
|
||||
window.fetch = function (input, init) {
|
||||
try {
|
||||
var url = String(typeof input === 'string' ? input : (input && input.url) || '');
|
||||
var method = String((init && init.method) || (input && input.method) || 'GET').toUpperCase();
|
||||
if (method === 'POST' && /\/api\/v2\/comments(\?|$)/.test(url) &&
|
||||
init && typeof init.body === 'string') {
|
||||
var cb = currentToggle();
|
||||
if (cb && cb.checked) {
|
||||
var data = JSON.parse(init.body);
|
||||
if (data && typeof data === 'object' && !data.anonymous) {
|
||||
data.anonymous = true;
|
||||
init = Object.assign({}, init, { body: JSON.stringify(data) });
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {} // 解析失败就按原样发,绝不能因为匿名开关挂掉评论
|
||||
return origFetch.apply(this, [input, init]);
|
||||
};
|
||||
}
|
||||
})();
|
||||
@@ -3,9 +3,11 @@
|
||||
*
|
||||
* 场景:很多人隔几个月来评论一次,早忘了上次填的昵称/网址,每次都重新想。
|
||||
* 这个模块做两件事:
|
||||
* 1. 访客开始在昵称框打字时,弹一次 toast 提示「填邮箱可以自动带回来」;
|
||||
* 1. 昵称框一获得焦点就弹 toast 提示「填邮箱可以自动带回来」(90 秒冷却,
|
||||
* 防止反复点进点出刷屏);
|
||||
* 2. 邮箱填完(失焦或格式完整)就问后端:这个邮箱上次评论用的什么昵称/网址,
|
||||
* 拿到后回填到空着的输入框,让老朋友少打几个字。
|
||||
* 拿到后回填到空着的输入框;如果框里已经有内容就不覆盖,也会弹一条
|
||||
* 「欢迎回来」让 TA 知道系统认出了 TA。
|
||||
*
|
||||
* 隐私红线:后端 /comments/lookup **只回昵称和网址,永远不回邮箱**;
|
||||
* 邮箱只作为查询参数发给评论后端(跟提交评论本身一样),不在本地落任何存储,
|
||||
@@ -18,7 +20,7 @@
|
||||
if (window.__atkAutofillReady) return; // PJAX 重复执行防抖
|
||||
window.__atkAutofillReady = true;
|
||||
|
||||
var HINT_KEY = 'atk_friend_hint'; // sessionStorage:提示只弹一次
|
||||
var HINT_COOLDOWN = 90000; // 同一编辑器 90 秒内不重复弹提示
|
||||
var asked = {}; // 查过的邮箱 → 结果,避免重复请求
|
||||
var timer = null;
|
||||
|
||||
@@ -52,12 +54,16 @@
|
||||
}
|
||||
|
||||
function showHint(editor) {
|
||||
try {
|
||||
if (sessionStorage.getItem(HINT_KEY)) return;
|
||||
sessionStorage.setItem(HINT_KEY, '1');
|
||||
} catch (e) { return; } // 存储被禁用就别弹了,宁缺毋滥
|
||||
// 邮箱已经填了就不用提示(提示了也没东西可带出)
|
||||
var email = fieldIn(editor, 'email');
|
||||
if (email && email.value.trim()) return; // 已经填了邮箱,不用提示
|
||||
if (email && email.value.trim()) return;
|
||||
// 冷却门:同一编辑器 90 秒只弹一次,反复点进点出不刷屏。
|
||||
// (曾经用 sessionStorage 做「整个会话只弹一次」,结果很多人再也见不到——弃用)
|
||||
try {
|
||||
var last = editor.__atkHintAt || 0;
|
||||
if (Date.now() - last < HINT_COOLDOWN) return;
|
||||
editor.__atkHintAt = Date.now();
|
||||
} catch (e) { return; }
|
||||
toast('老朋友?填一下邮箱,昵称和网址会自动帮你带回来 ✨', 'info', 4500);
|
||||
}
|
||||
|
||||
@@ -70,20 +76,28 @@
|
||||
if (!name) return;
|
||||
|
||||
var filled = [];
|
||||
var kept = []; // 查到了,但框里已经有 TA 自己填的内容 → 不抢,口头告知
|
||||
// 昵称:空着,或者只打了历史昵称的开头(说明 TA 正在凭印象敲同一个名字)
|
||||
// 才替 TA 补全 —— 已经敲了一个完全不同的名字就别抢,那是故意的。
|
||||
// 才替 TA 补全 —— 已经敲了一个完全不同的名字就别动,那是故意的。
|
||||
if (nick) {
|
||||
var cur = nick.value.trim();
|
||||
var lower = cur.toLowerCase();
|
||||
var lowerName = name.toLowerCase();
|
||||
if (!cur || lowerName.indexOf(lower) === 0) {
|
||||
if (cur !== name) { setVal(nick, name); filled.push('昵称'); }
|
||||
} else {
|
||||
kept.push('昵称');
|
||||
}
|
||||
}
|
||||
if (link && !link.value.trim() && url) { setVal(link, url); filled.push('网址'); }
|
||||
if (url) {
|
||||
if (link && !link.value.trim()) { setVal(link, url); filled.push('网址'); }
|
||||
else if (link && link.value.trim() && link.value.trim() !== url) kept.push('网址');
|
||||
}
|
||||
|
||||
if (filled.length) {
|
||||
toast('欢迎回来,' + name + '!已帮你填好' + filled.join('和') + ' 👋', 'success', 4000);
|
||||
} else if (kept.length) {
|
||||
toast('欢迎回来,' + name + '!你已填好' + kept.join('和') + ',就不帮你改动啦', 'info', 4000);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -112,16 +126,22 @@
|
||||
}
|
||||
|
||||
// ── 事件委托(capture:Artalk 内部可能 stopPropagation)──────────────
|
||||
|
||||
// 昵称框获得焦点就提示(focusin 比 focus 冒泡,靠委托才能一把抓住反复重建的编辑器)
|
||||
document.addEventListener('focusin', function (e) {
|
||||
var el = e.target;
|
||||
if (!el || el.tagName !== 'INPUT' || el.name !== 'nick') return;
|
||||
var editor = editorOf(el);
|
||||
if (!editor) return; // 只管 Artalk 的评论框
|
||||
showHint(editor);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('input', function (e) {
|
||||
var el = e.target;
|
||||
if (!el || el.tagName !== 'INPUT' || !el.name) return;
|
||||
var editor = editorOf(el);
|
||||
if (!editor) return; // 只管 Artalk 的评论框
|
||||
|
||||
if (el.name === 'nick') {
|
||||
if (el.value.trim()) showHint(editor); // 打了第一个字就开始提示
|
||||
return;
|
||||
}
|
||||
if (el.name === 'email') {
|
||||
var v = el.value.trim();
|
||||
if (isEmail(v)) {
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* emoticon-recent —— 表情包「最近使用」
|
||||
*
|
||||
* 场景:表情面板几十套图翻页找常用的太累,用过的排最前,一次点击直达。
|
||||
*
|
||||
* 零侵入实现(不碰 Artalk 源码,靠 DOM 契约):
|
||||
* - Artalk 表情面板里每张原生图是 `<img src alt=key>`,点击后插入 `:[key]` 令牌;
|
||||
* - 这里用事件委托记录点击(localStorage,最多 8 个,去重置顶);
|
||||
* - 面板顶部注入「最近」一栏;点最近里的图 = 找到面板里原生的同一张
|
||||
* (按 alt 匹配)然后 programmatic click(),完全复用 Artalk 自己的插入逻辑。
|
||||
* - 面板由 Artalk 动态创建/重建(PJAX、点回复),MutationObserver 兜底重注入。
|
||||
*/
|
||||
(function () {
|
||||
if (window.__atkEmoticonRecentReady) return; // PJAX 重复执行防抖
|
||||
window.__atkEmoticonRecentReady = true;
|
||||
|
||||
var STORE_KEY = 'atk_recent_emoticons';
|
||||
var MAX = 8;
|
||||
|
||||
function load() {
|
||||
try {
|
||||
var arr = JSON.parse(localStorage.getItem(STORE_KEY) || '[]');
|
||||
if (!Array.isArray(arr)) return [];
|
||||
return arr.filter(function (it) { return it && it.k && it.s; });
|
||||
} catch (e) { return []; }
|
||||
}
|
||||
|
||||
function save(list) {
|
||||
try { localStorage.setItem(STORE_KEY, JSON.stringify(list.slice(0, MAX))); } catch (e) {}
|
||||
}
|
||||
|
||||
function record(key, src) {
|
||||
if (!key || !src) return;
|
||||
var list = load().filter(function (it) { return it.k !== key; });
|
||||
list.unshift({ k: key, s: src });
|
||||
save(list);
|
||||
}
|
||||
|
||||
// 最近栏当前展示的键序(用于和存储比对,判断要不要重建)
|
||||
function blockKeys(panel) {
|
||||
var imgs = panel.querySelectorAll('.atk-recent-emoticons img');
|
||||
var keys = [];
|
||||
for (var i = 0; i < imgs.length; i++) keys.push(imgs[i].getAttribute('alt'));
|
||||
return keys;
|
||||
}
|
||||
|
||||
// ── 记录:点的是面板里的原生表情图(capture,先于 Artalk 的 onclick)────
|
||||
document.addEventListener('click', function (e) {
|
||||
var el = e.target;
|
||||
if (!el || el.tagName !== 'IMG' || !el.closest) return;
|
||||
if (!el.closest('.atk-editor-plug-emoticons')) return;
|
||||
if (el.closest('.atk-recent-emoticons')) return; // 最近栏自己的点击单独处理
|
||||
var key = el.getAttribute('alt');
|
||||
if (key) {
|
||||
record(key, el.getAttribute('src') || '');
|
||||
scheduleScan(); // 顺序变了,刷新最近栏
|
||||
}
|
||||
}, true);
|
||||
|
||||
// ── 在面板里找原生表情图(排除最近栏自己的),按 alt=key 匹配 ──────────
|
||||
function findNativeImg(panel, key) {
|
||||
var imgs = panel.querySelectorAll('img');
|
||||
for (var i = 0; i < imgs.length; i++) {
|
||||
var im = imgs[i];
|
||||
if (im.closest('.atk-recent-emoticons')) continue;
|
||||
if (im.getAttribute('alt') === key) return im;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildBlock() {
|
||||
var list = load();
|
||||
if (!list.length) return null;
|
||||
var wrap = document.createElement('div');
|
||||
wrap.className = 'atk-recent-emoticons';
|
||||
|
||||
var label = document.createElement('span');
|
||||
label.className = 'atk-recent-label';
|
||||
label.textContent = '最近';
|
||||
wrap.appendChild(label);
|
||||
|
||||
var ul = document.createElement('ul');
|
||||
list.forEach(function (it) {
|
||||
var li = document.createElement('li');
|
||||
var img = document.createElement('img');
|
||||
img.src = it.s;
|
||||
img.alt = it.k;
|
||||
img.title = it.k;
|
||||
img.loading = 'lazy';
|
||||
img.onclick = function () {
|
||||
var panel = img.closest('.atk-editor-plug-emoticons');
|
||||
var orig = panel ? findNativeImg(panel, it.k) : null;
|
||||
if (orig) {
|
||||
orig.click(); // 复用 Artalk 的插入逻辑(插 :[key] 令牌)
|
||||
record(it.k, it.s); // 再点一次最近栏的图,顺序也要更新
|
||||
scheduleScan();
|
||||
} else {
|
||||
// 表情包配置变了,这张已经不存在 → 从最近记录里淘汰
|
||||
save(load().filter(function (x) { return x.k !== it.k; }));
|
||||
scheduleScan();
|
||||
}
|
||||
};
|
||||
li.appendChild(img);
|
||||
ul.appendChild(li);
|
||||
});
|
||||
wrap.appendChild(ul);
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function inject(panel) {
|
||||
if (!panel || !panel.querySelector) return;
|
||||
var existing = panel.querySelector('.atk-recent-emoticons');
|
||||
var want = load();
|
||||
if (existing) {
|
||||
// 内容比对:键序和存储一致就不动(避免无谓重建打断 hover),
|
||||
// 不一致(点了新表情 / 记录被淘汰)就整块重建
|
||||
var have = blockKeys(panel);
|
||||
var same = have.length === want.length;
|
||||
if (same) {
|
||||
for (var i = 0; i < want.length; i++) {
|
||||
if (want[i].k !== have[i]) { same = false; break; }
|
||||
}
|
||||
}
|
||||
if (same) return;
|
||||
var fresh = buildBlock();
|
||||
if (fresh) panel.replaceChild(fresh, existing);
|
||||
else existing.parentNode.removeChild(existing);
|
||||
return;
|
||||
}
|
||||
// 原生分组还没渲染出来时先不注入(找原生图会找不到),等下轮扫描
|
||||
if (!panel.querySelector('img')) return;
|
||||
var block = buildBlock();
|
||||
if (block) panel.insertBefore(block, panel.firstChild);
|
||||
}
|
||||
|
||||
var scanTimer = null;
|
||||
function scheduleScan() {
|
||||
if (scanTimer) return;
|
||||
scanTimer = setTimeout(function () {
|
||||
scanTimer = null;
|
||||
try {
|
||||
var panels = document.querySelectorAll('.atk-editor-plug-emoticons');
|
||||
for (var i = 0; i < panels.length; i++) inject(panels[i]);
|
||||
} catch (e) {}
|
||||
}, 120);
|
||||
}
|
||||
|
||||
try {
|
||||
new MutationObserver(scheduleScan).observe(document.body, { childList: true, subtree: true });
|
||||
} catch (e) {}
|
||||
document.addEventListener('focusin', scheduleScan, true);
|
||||
scheduleScan();
|
||||
})();
|
||||
@@ -1,27 +1,40 @@
|
||||
// 自绘平滑滚动:浏览器原生 behavior:'smooth' 又快又硬(约 300ms 且不可调),
|
||||
// 还会被系统的「减少动态效果」降级成瞬移。用 rAF + easeInOutCubic 自绘,
|
||||
// 时长随距离自适应,滚动一旦被用户滚轮/触摸打断立即让位。
|
||||
window.smoothScrollTo = function(targetY, duration) {
|
||||
var startY = window.scrollY;
|
||||
var diff = targetY - startY;
|
||||
if (Math.abs(diff) < 2) return;
|
||||
// 短距离快、长距离慢一些,但都 clamp 在 420~950ms,保证观感一致
|
||||
var dur = duration || Math.min(950, Math.max(420, Math.abs(diff) * 0.38));
|
||||
if (window.__smoothScrollRAF) cancelAnimationFrame(window.__smoothScrollRAF);
|
||||
var t0 = null, canceled = false;
|
||||
var cancel = function() { canceled = true; };
|
||||
window.addEventListener('wheel', cancel, { once: true, passive: true });
|
||||
window.addEventListener('touchstart', cancel, { once: true, passive: true });
|
||||
var ease = function(t) { return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; };
|
||||
var step = function(ts) {
|
||||
if (canceled) return;
|
||||
if (t0 === null) t0 = ts;
|
||||
var p = Math.min(1, (ts - t0) / dur);
|
||||
window.scrollTo(0, startY + diff * ease(p));
|
||||
if (p < 1) window.__smoothScrollRAF = requestAnimationFrame(step);
|
||||
else window.__smoothScrollRAF = null;
|
||||
// 平滑滚动 —— 纯 CSS 实现(scroll-behavior: smooth)。
|
||||
// 之前用 rAF + window.scrollTo 自绘动画:每一帧都在主线程上强制重排重绘,
|
||||
// 移动端长页面(尤其带 backdrop-filter / 大图的)掉帧明显,非常卡。
|
||||
// CSS 平滑滚动由浏览器合成器接管,不占 JS 主线程,移动端丝滑得多。
|
||||
// 做法:只在滚动期间临时给 <html> 挂 .css-smooth-scroll 类,滚完就摘 ——
|
||||
// 避免全局常开平滑滚动干扰 PJAX 切页回顶、锚点跳转这些场景。
|
||||
window.smoothScrollTo = function(targetY) {
|
||||
targetY = Math.max(0, Math.floor(targetY || 0));
|
||||
if (Math.abs(window.scrollY - targetY) < 2) return;
|
||||
|
||||
var root = document.documentElement;
|
||||
// 上一次滚动还没收尾就直接再来一次:先摘干净旧状态
|
||||
if (window.__smoothScrollCleanup) window.__smoothScrollCleanup();
|
||||
|
||||
var reduced = false;
|
||||
try { reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; } catch (e) {}
|
||||
if (reduced) { window.scrollTo(0, targetY); return; } // 用户要求减少动态效果:瞬移
|
||||
|
||||
root.classList.add('css-smooth-scroll');
|
||||
window.scrollTo(0, targetY); // 类在位,浏览器自动走平滑滚动
|
||||
|
||||
var done = function () {
|
||||
if (window.__smoothScrollTimer) { clearTimeout(window.__smoothScrollTimer); window.__smoothScrollTimer = null; }
|
||||
window.removeEventListener('scrollend', done);
|
||||
window.removeEventListener('wheel', done, { passive: true } );
|
||||
window.removeEventListener('touchstart', done, { passive: true });
|
||||
root.classList.remove('css-smooth-scroll');
|
||||
window.__smoothScrollCleanup = null;
|
||||
};
|
||||
window.__smoothScrollRAF = requestAnimationFrame(step);
|
||||
window.__smoothScrollCleanup = done;
|
||||
// scrollend(Chrome 114+/FF 109+)在滚动真正停下的瞬间触发;
|
||||
// 不支持的浏览器用超时兜底摘类。滚轮/触摸会打断 CSS 平滑滚动,
|
||||
// 提前收尾把类摘掉,不影响用户接着手动滚。
|
||||
window.addEventListener('scrollend', done, { once: true });
|
||||
window.addEventListener('wheel', done, { once: true, passive: true });
|
||||
window.addEventListener('touchstart', done, { once: true, passive: true });
|
||||
window.__smoothScrollTimer = setTimeout(done, 1000);
|
||||
};
|
||||
|
||||
window.refreshFloatingTools = function() {
|
||||
|
||||
@@ -143,8 +143,11 @@
|
||||
{{ $reward := resources.Get "js/modules/reward.js" }}
|
||||
{{/* 老朋友自动填充:填邮箱带出上次的昵称/网址 */}}
|
||||
{{ $autofill := resources.Get "js/modules/comment-autofill.js" }}
|
||||
{{/* 匿名评论开关 + 表情包最近使用 */}}
|
||||
{{ $anon := resources.Get "js/modules/comment-anonymous.js" }}
|
||||
{{ $emoRecent := resources.Get "js/modules/emoticon-recent.js" }}
|
||||
|
||||
{{ $pageScripts := slice $friendlink $autofill $artalkModule $human $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }}
|
||||
{{ $pageScripts := slice $friendlink $autofill $anon $emoRecent $artalkModule $human $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }}
|
||||
|
||||
<script defer src="{{ $pageScripts.RelPermalink }}"></script>
|
||||
<script>window._pageOnlyScriptUrl = '{{ $pageScripts.RelPermalink }}';</script>
|
||||
@@ -157,8 +160,10 @@
|
||||
{{ $paragraphComments := resources.Get "js/modules/paragraph-comments.js" }}
|
||||
{{ $reward := resources.Get "js/modules/reward.js" }}
|
||||
{{ $autofill := resources.Get "js/modules/comment-autofill.js" }}
|
||||
{{ $anon := resources.Get "js/modules/comment-anonymous.js" }}
|
||||
{{ $emoRecent := resources.Get "js/modules/emoticon-recent.js" }}
|
||||
|
||||
{{ $pageScripts := slice $friendlink $autofill $artalkModule $human $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }}
|
||||
{{ $pageScripts := slice $friendlink $autofill $anon $emoRecent $artalkModule $human $paragraphComments $reward | resources.Concat "js/page-only.js" | resources.Minify | resources.Fingerprint }}
|
||||
|
||||
<script>window._pageOnlyScriptUrl = '{{ $pageScripts.RelPermalink }}';</script>
|
||||
{{ end }}
|
||||
|
||||
Reference in new issue
Block a user