Files
wordpress-kratos-yang/assets/js/comments.js
T

544 lines
22 KiB
JavaScript
Raw Normal View History

/*!
* 评论区交互
* Yongquan Yang <yangyq.net>
*
* ── 旧版(comments.min.js)的四个真实缺陷,逐条对应修掉的点 ────
*
* 1. **错误提示直接把后端原文弹出来**
* `layer.msg(e.responseText)` —— 后端返回什么就弹什么。
* 旧后端出错时 `echo` 的是 WP_Error 的 message(可能是英文、
* 可能带 HTML、可能是空串)。用户看到的是 "ERROR: please type
* a comment" 或一片空白。→ 现在后端统一返回 { ok, message },
* 前端只读 message,空就填兜底文案。
*
* 2. **不防重复提交**
* `beforeSend` 只弹了「评论正在提交中」,按钮仍可点。
* 网络慢时连点三次就是三条重复评论(后端没做 flood 时)。
* → 现在提交期间禁用按钮并置 is-loading,finally 里恢复。
*
* 3. **新评论插错位置会丢嵌套**
* 旧代码 `e("#comment-"+c).append('<ul class="children">'+t+"</ul>")`
* 直接用 jQuery 包一层。若该父评论**已有** children 容器
* (已经被回复过一次),就会变成 children 里再套一个 children,
* 层级越滚越深。→ 现在先查是否已有 children 容器,有就往里追加。
*
* 4. **提交成功后不更新评论数**
* → 现在更新页面上的评论计数(若存在对应元素)。
*
* ── 设计约束 ────────────────────────────────────────────────
* · 不依赖 jQuery、不依赖 layer。主题虽然都加载了,但这两者版本
* 随主题走,出错时整块功能会一起哑掉。原生 fetch 足够。
* · 不用 fetch 的流式读法:要读响应头和 body,用 Promise 链更直观。
* · 任何异常都必须**看得见**。静默 catch 是这类"点了没反应"问题的根源。
*/
; (function () {
'use strict'
var CFG = window.yangComment || {}
var I18N = CFG.i18n || {}
function $(sel, root) { return (root || document).querySelector(sel) }
function $$(sel, root) { return [].slice.call((root || document).querySelectorAll(sel)) }
var form = $('#commentform')
if (!form) return // 没表单(评论关闭/需登录)就什么都不做
var listEl = $('#yang-comment-list')
var statusEl = $('#yang-comment-status')
var submitBtn = $('#submit', form)
var textarea = $('#comment', form)
var replyHint = $('#yang-reply-hint')
var replyTitle = $('#yang-reply-title-text')
var cancelBtn = $('#yang-cancel-reply')
var moreWrap = $('#yang-comments-more')
var moreBtn = moreWrap ? $('.comments-more-btn', moreWrap) : null
// 当前回复的目标(0 表示顶层评论)
var replyTo = 0
var submitting = false
/* ── 状态提示 ──────────────────────────────────────────────
* 用一段内联文本而不是弹窗。type 决定配色(ok / err)。
* aria-live=polite 已在模板里设好,屏幕阅读器会读出来。 */
function setStatus (msg, type) {
if (!statusEl) return
statusEl.textContent = msg || ''
statusEl.className = 'comment-status' + (type ? ' is-' + type : '')
}
/* ── 提交态开关 ────────────────────────────────────────────
* 只改按钮和编辑区的可交互性,不动表单数据。 */
function setSubmitting (on) {
submitting = on
if (submitBtn) {
submitBtn.disabled = on
submitBtn.classList[on ? 'add' : 'remove']('is-loading')
submitBtn.textContent = on ? (I18N.submitting || '提交中…') : (I18N.submit || '提交评论')
}
if (editor) editor.setAttribute('contenteditable', on ? 'false' : 'true')
if (textarea) textarea.readOnly = on
}
/* ── 回复模式切换 ──────────────────────────────────────────
* 点「回复」→ 把表单搬到该评论下方 + 更新标题 + 显示取消按钮。
* 取消 → 表单搬回原位置 + 清状态。
*
* 为什么要记住"原位置":表单本来在 #respond 里,被搬走后
* #respond 变成空壳。取消时若直接 appendChild 回去,
* 会和评论区里的其它元素顺序错乱 —— 所以留一个占位锚点。 */
var originalAnchor = document.createComment('yang-respond-anchor')
var respondBox = $('#respond')
if (respondBox && respondBox.parentNode) {
respondBox.parentNode.insertBefore(originalAnchor, respondBox.nextSibling)
}
function enterReplyMode (commentId, author) {
replyTo = commentId
var li = document.getElementById('comment-' + commentId)
if (li && respondBox) {
/* 插到该评论的 children 容器里(没有就建一个)。
这样表单在视觉上"长"在被回复评论的内部,
比挪到列表末尾更符合直觉。 */
var children = li.querySelector(':scope > .yang-children')
if (!children) {
children = document.createElement('ol')
children.className = 'yang-children'
li.appendChild(children)
}
children.insertBefore(respondBox, children.firstChild)
}
if (replyTitle) {
replyTitle.textContent = (I18N.replyTo || '回复') + ' @' + (author || '')
}
if (replyHint) {
replyHint.textContent = (I18N.replyTo || '回复') + ' @' + (author || '') + ' '
replyHint.hidden = false
}
if (cancelBtn) cancelBtn.hidden = false
// 隐藏被回复评论上的「回复」按钮,避免重复点击
$$('.yang-comment-reply', li || document).forEach(function (b) {
b.classList.add('is-hidden')
})
var parentInput = $( '#comment_parent', form)
if (parentInput) parentInput.value = String(commentId)
// 聚焦输入框并滚过去
if (editor) {
editor.focus()
try {
var top = respondBox.getBoundingClientRect().top + window.pageYOffset - 90
window.scrollTo({ top: top, behavior: 'smooth' })
} catch (e) { window.scrollTo(0, respondBox.offsetTop - 90) }
}
}
function exitReplyMode () {
replyTo = 0
var parentInput = $('#comment_parent', form)
if (parentInput) parentInput.value = '0'
if (replyTitle) replyTitle.textContent = I18N.submit ? '发表评论' : '发表评论'
if (replyHint) {
replyHint.textContent = ''
replyHint.hidden = true
}
if (cancelBtn) cancelBtn.hidden = true
$$('.yang-comment-reply.is-hidden').forEach(function (b) {
b.classList.remove('is-hidden')
})
// 表单搬回原位
if (respondBox && originalAnchor && originalAnchor.parentNode) {
originalAnchor.parentNode.insertBefore(respondBox, originalAnchor.nextSibling)
}
setStatus('', '')
}
/* ── 事件:回复 / 取消 ─────────────────────────────────── */
document.addEventListener('click', function (ev) {
var btn = ev.target.closest ? ev.target.closest('.yang-comment-reply') : null
if (btn) {
ev.preventDefault()
enterReplyMode(
parseInt(btn.getAttribute('data-comment-id'), 10) || 0,
btn.getAttribute('data-author') || ''
)
return
}
// 点到「取消回复」的另一种入口:评论里的取消链接
if (ev.target.closest && ev.target.closest('#yang-cancel-reply')) {
ev.preventDefault()
exitReplyMode()
}
})
/* ── 所见即所得编辑器(3.2.25)─────────────────────────────
* 3.2.24 的做法是往 textarea 里插 <strong> 这类源码:用户看到的
* 是代码不是格式;光标点错位置再插表情,代码就被劈成两半 ——
* 3.2.25 用户明确要求改掉。
*
* 现在:#yang-editor 是 contentEditable,格式所见即所得;
* 提交前由 serializeEditor() 把编辑区内容序列化成**后端白名单
* HTML**(theme-comments.php 的 kses 白名单 + safecss_filter_attr
* 消毒保持不变):
* strong / em / span[style color] 保留
* 表情图 <img data-code> 转回 :code: 文本(存储不变)
* 其余一切标签 只保留文字(粘贴垃圾进不来)
*/
var editor = $('#yang-editor', form)
/* 工具按钮用 mousedown 抢焦点会弄丢编辑区选区 —— 一律挡掉,
* 这样「选中一段字 → 点 B」的选区在 click 到来时还在。
* ⚠️ 面板内部的元素(色板、表情链接)也要挡:3.2.25 本地探针实测,
* 点色板时焦点被抢走,foreColor 落在塌陷光标上,颜色功能整个失效。 */
$$('.comment-tool-btn, .comment-color-swatch, .comment-color-panel, .comment-smile-panel, .comment-smile-panel a', form)
.forEach(function (b) {
b.addEventListener('mousedown', function (ev) { ev.preventDefault() })
})
function exec (cmd, val) {
if (!editor) return
editor.focus()
try { document.execCommand(cmd, false, val || null) } catch (ex) { /* 忽略 */ }
}
// B / I:浏览器原生 execCommand,Ctrl+B / Ctrl+I 在编辑区内天然可用
var boldBtn = $('#yang-tool-bold', form)
var italicBtn = $('#yang-tool-italic', form)
if (boldBtn) boldBtn.addEventListener('click', function () { exec('bold') })
if (italicBtn) italicBtn.addEventListener('click', function () { exec('italic') })
/* 字体颜色面板:开合逻辑与表情面板同款(点按钮开/关,点外面关)。
* styleWithCSS(true) 让 foreColor 产出 <span style="color">,
* 而不是废弃的 <font color> —— 正好对齐后端白名单。 */
var colorBtn = $('#yang-tool-color', form)
var colorPanel = $('#yang-color-panel', form)
if (colorBtn && colorPanel) {
colorBtn.addEventListener('click', function (ev) {
ev.stopPropagation()
var open = !colorPanel.hidden
colorPanel.hidden = open
colorBtn.setAttribute('aria-expanded', open ? 'false' : 'true')
})
document.addEventListener('click', function (ev) {
if (colorPanel.hidden) return
if (colorPanel.contains(ev.target) || colorBtn.contains(ev.target)) return
colorPanel.hidden = true
colorBtn.setAttribute('aria-expanded', 'false')
})
colorPanel.addEventListener('click', function (ev) {
var sw = ev.target.closest ? ev.target.closest('.comment-color-swatch') : null
if (!sw) return
var c = sw.getAttribute('data-color') || '#000000'
// 色值只允许 #rrggbb,面板里写死的,这里再挡一道
if (!/^#[0-9a-fA-F]{6}$/.test(c)) return
exec('styleWithCSS', true)
exec('foreColor', c)
colorPanel.hidden = true
colorBtn.setAttribute('aria-expanded', 'false')
})
}
/* ── 表情面板 ──────────────────────────────────────────────
* 插入**可见的表情图片**(img 带 data-code 记住代码),
* 不再插 :smile: 这类源码 —— 那正是 3.2.25 要改掉的行为。
* 提交时 serializeEditor() 把图片转回代码,存储格式与旧评论兼容。 */
var smileBtn = $('#yang-smile-btn')
var smilePanel = $('#yang-smile-panel')
if (smileBtn && smilePanel) {
smileBtn.addEventListener('click', function (ev) {
ev.stopPropagation()
var open = !smilePanel.hidden
smilePanel.hidden = open
smileBtn.setAttribute('aria-expanded', open ? 'false' : 'true')
})
document.addEventListener('click', function (ev) {
if (smilePanel.hidden) return
if (smilePanel.contains(ev.target) || smileBtn.contains(ev.target)) return
smilePanel.hidden = true
smileBtn.setAttribute('aria-expanded', 'false')
})
// 面板里的表情链接是 javascript:grin('...'),这里拦下来自己插
smilePanel.addEventListener('click', function (ev) {
var a = ev.target.closest ? ev.target.closest('a') : null
if (!a) return
var href = a.getAttribute('href') || ''
var m = href.match(/grin\('([^']+)'\)/)
if (!m) return
ev.preventDefault()
var img = a.querySelector('img')
var src = img ? (img.getAttribute('src') || '') : ''
if (src) {
exec('insertHTML', ' <img class="yang-editor-emoji" src="' + src +
'" data-code="' + m[1] + '" alt="' + m[1] + '"> ')
} else {
exec('insertText', ' ' + m[1] + ' ')
}
smilePanel.hidden = true
smileBtn.setAttribute('aria-expanded', 'false')
})
}
/* 编辑区被清空后常残留 <br>/<div><br></div>,压成真空,
* 否则 :empty 占位符不显示。 */
editor.addEventListener('input', function () {
var h = editor.innerHTML
if (h === '<br>' || h === '<div><br></div>' || h === '<div><br></div><br>') {
editor.innerHTML = ''
}
})
/* 粘贴只收纯文本:富文本粘贴会把整篇网页的样式标签搬进来,
* 序列化时虽然只剩文字,但先按纯文本插入可以少绕一圈。 */
editor.addEventListener('paste', function (ev) {
ev.preventDefault()
var t = (ev.clipboardData || window.clipboardData).getData('text/plain') || ''
exec('insertText', t)
})
/* 序列化:编辑区 DOM → 后端白名单 HTML。
* 白名单在这里再收一次(不只依赖 kses):提交的是我们生成的串,
* 自己保证干净,比"交上去让后端删"更稳。 */
function escHtml (s) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
function serializeEditor () {
if (!editor) return ''
var clone = editor.cloneNode(true)
// 表情图 → :code: 文本(前后带空格,和旧版纯文本行为一致)
$$('img[data-code]', clone).forEach(function (im) {
im.parentNode.replaceChild(
document.createTextNode(' ' + (im.getAttribute('data-code') || '') + ' '),
im)
})
function walk (n) {
var out = ''
for (var i = 0; i < n.childNodes.length; i++) {
var c = n.childNodes[i]
if (c.nodeType === 3) { out += escHtml(c.nodeValue); continue }
if (c.nodeType !== 1) continue
var tag = c.tagName.toLowerCase()
if (tag === 'br') { out += '\n'; continue }
var inner = walk(c)
/* 颜色不一定长在 span 上:foreColor 作用在斜体/加粗选区时,
* 产出的是 <i style="color:..."> / <b style="color:...">
* (3.2.25 本地探针实测)。所以任何允许标签都检查自身 color,
* 有就包一层 span 提交 —— 后端白名单只认 span[style]。 */
var col = ((c.style && c.style.color) || (c.getAttribute && c.getAttribute('color')) || '').trim()
var colSpan = /^(#[0-9a-fA-F]{3}|#[0-9a-fA-F]{6}|rgb\()/.test(col)
? '<span style="color:' + col + '">' + inner + '</span>'
: inner
if (tag === 'strong' || tag === 'b') { out += '<strong>' + colSpan + '</strong>' }
else if (tag === 'em' || tag === 'i') { out += '<em>' + colSpan + '</em>' }
else if (tag === 'span' || tag === 'font') { out += colSpan }
else if (tag === 'div' || tag === 'p') { out += inner + '\n' }
else { out += inner }
}
return out
}
return walk(clone).replace(/\n{3,}/g, '\n\n').trim()
}
/* ── 提交 ──────────────────────────────────────────────────
* 用 fetch + FormData,直接把整个表单发过去(含 nonce、comment_post_ID、
* comment_parent)。这样服务端 wp_handle_comment_submission 拿到的
* 字段与普通 POST 完全一致,不需要为 AJAX 单独走一套。 */
form.addEventListener('submit', function (ev) {
ev.preventDefault()
if (submitting) return // 双击保护
// 编辑区内容序列化进真实提交字段(隐藏的 #comment)
var content = serializeEditor()
if (textarea) textarea.value = content
// 本地前置校验:空内容不发请求(省一次往返,也避免后端 500)。
// 「空」= 没有文字也没有表情图(只点了个表情也允许提交)。
var hasEmoji = editor ? !!editor.querySelector('img[data-code]') : false
if (content === '' && !hasEmoji) {
setStatus(I18N.emptyComment || '评论内容不能为空。', 'err')
if (editor) editor.focus()
return
}
// honeypot:填了就是机器人,直接假装成功,不给它反馈
var hp = $('#yang_hp', form)
if (hp && hp.value !== '') {
setStatus('', '')
return
}
var fd = new FormData(form)
fd.append('action', 'ajax_comment')
setSubmitting(true)
setStatus(I18N.submitting || '提交中…', '')
fetch(CFG.ajaxUrl, {
method: 'POST',
body: fd,
credentials: 'same-origin'
// 注意:不要设 Content-Type,让浏览器自己带 multipart 边界
}).then(function (res) {
/* 关键:即使 HTTP 状态是 4xx/5xx,后端也会返回 JSON。
所以先尝试解析 JSON,解析失败才当网络/服务端异常处理。
旧实现用 jQuery 的 error 回调,拿不到结构化数据,
只能把 responseText 原文弹出来。 */
return res.json().catch(function () {
return { ok: false, message: I18N.networkErr || '网络异常,请稍后重试。' }
})
}).then(function (data) {
if (!data || !data.ok) {
var msg = (data && data.message) ? data.message : (I18N.networkErr || '提交失败,请稍后重试。')
setStatus(msg, 'err')
// 后端指明字段就聚焦过去
if (data && data.field) {
var f = document.getElementById(data.field)
if (f) f.focus()
}
return
}
insertComment(data)
// 清空输入(保留昵称邮箱)
if (editor) editor.innerHTML = ''
if (textarea) textarea.value = ''
/* 顺序很重要:exitReplyMode() 内部最后会 setStatus('','')
清空状态条。若先设成功提示再退出回复模式,提示会被立刻
擦掉 —— 用户提交成功却看不到任何反馈,正是"点了没反应"
那种体验(真实浏览器里量出来的,单看代码不容易发现)。
所以必须先退出、后设提示。 */
exitReplyMode()
setStatus(data.message || '评论发表成功。', 'ok')
}).catch(function () {
setStatus(I18N.networkErr || '网络异常,请稍后重试。', 'err')
}).then(function () {
setSubmitting(false)
})
})
/* ── 插入新评论 ────────────────────────────────────────────
* 四个分支,对应四种真实情况:
* a. 回复某条评论,且该评论已有 children 容器 → 追加进去
* b. 回复某条评论,还没有 children 容器 → 新建一个
* c. 顶层评论,站点排序 asc → 追加到列表末尾
* d. 顶层评论,站点排序 desc → 插到列表最前
*
* 旧实现只有 a 的一个错误版本(无条件新建 children,导致套娃)
* 和 c/d。b 的情况是它没考虑到的(第一次回复时)。 */
function insertComment (data) {
if (!listEl) return
var tpl = document.createElement('template')
tpl.innerHTML = (data.html || '').trim()
var node = tpl.content.firstElementChild
if (!node) return
var parentId = parseInt(data.parent_id, 10) || 0
if (parentId > 0) {
var parentLi = document.getElementById('comment-' + parentId)
if (parentLi) {
var children = parentLi.querySelector(':scope > .yang-children')
if (!children) {
children = document.createElement('ol')
children.className = 'yang-children'
parentLi.appendChild(children)
}
children.appendChild(node)
} else {
// 父评论不在页面上(分页/已删除)→ 退化成顶层追加,别丢评论
listEl.appendChild(node)
}
} else {
var order = (CFG.order || 'asc').toLowerCase()
if (order === 'desc') {
listEl.insertBefore(node, listEl.firstChild)
} else {
listEl.appendChild(node)
}
}
// 高亮一下,让用户看到自己的评论在哪
node.classList.add('is-new')
setTimeout(function () { node.classList.remove('is-new') }, 2400)
// 更新评论计数(页面上出现「N 条评论」的地方都更新)
updateCount(data.count, data.count_text)
}
function updateCount (n, text) {
if (typeof n !== 'number') return
var label = text || (n + ' 条评论')
// single.php 里的 meta 区
$$('.article .header .meta span').forEach(function (sp) {
if (/条评论$/.test(sp.textContent.trim()) || /^\d+$/.test(sp.textContent.trim())) {
if (/条评论/.test(sp.textContent) || sp.textContent.trim() === '0') {
sp.textContent = label
}
}
})
// 评论区标题
var h = $('.comments-head .title')
if (h) h.textContent = '评论(' + n + ')'
}
/* ── 加载更多 ────────────────────────────────────────────── */
if (moreBtn) {
moreBtn.addEventListener('click', function () {
if (moreBtn.disabled) return
moreBtn.disabled = true
var orig = moreBtn.textContent
moreBtn.textContent = I18N.loading || '加载中…'
var page = parseInt(moreBtn.getAttribute('data-page'), 10) || 2
var fd = new FormData()
fd.append('action', 'yang_load_comments')
fd.append('post_id', String(CFG.postId || 0))
fd.append('page', String(page))
fetch(CFG.ajaxUrl, { method: 'POST', body: fd, credentials: 'same-origin' })
.then(function (r) { return r.json() })
.then(function (data) {
if (!data || !data.ok) {
moreBtn.textContent = orig
moreBtn.disabled = false
setStatus((data && data.message) || (I18N.networkErr || '加载失败。'), 'err')
return
}
if (data.html && listEl) {
var tpl = document.createElement('template')
tpl.innerHTML = data.html
while (tpl.content.firstChild) {
listEl.appendChild(tpl.content.firstChild)
}
}
if (data.has_more) {
moreBtn.setAttribute('data-page', String(data.next_page))
moreBtn.textContent = orig
moreBtn.disabled = false
} else {
if (moreWrap) moreWrap.hidden = true
setStatus(I18N.noMore || '没有更多评论了', '')
}
})
.catch(function () {
moreBtn.textContent = orig
moreBtn.disabled = false
setStatus(I18N.networkErr || '网络异常,请稍后重试。', 'err')
})
})
}
/* ── 新评论进入视口时轻微高亮 ──────────────────────────── */
// 已由 CSS 的 .is-new 处理,这里不重复
}())