418 lines
16 KiB
JavaScript
418 lines
16 KiB
JavaScript
/*!
|
||||
|
|
* 评论区交互
|
|||
|
|
* 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 : '')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── 提交态开关 ────────────────────────────────────────────
|
|||
|
|
* 只改按钮和 textarea 的可交互性,不动表单数据。 */
|
|||
|
|
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 (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 (textarea) {
|
|||
|
|
textarea.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()
|
|||
|
|
}
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
/* ── 表情面板 ──────────────────────────────────────────────
|
|||
|
|
* 点表情按钮 → 把对应代码插到光标处(不是简单追加到末尾)。
|
|||
|
|
* 旧实现是 `myField.value += tag`,光标本在中间也会跑到最后,
|
|||
|
|
* 用户想在某句话后面插表情就得重新点光标。 */
|
|||
|
|
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()
|
|||
|
|
insertAtCursor(textarea, ' ' + m[1] + ' ')
|
|||
|
|
smilePanel.hidden = true
|
|||
|
|
smileBtn.setAttribute('aria-expanded', 'false')
|
|||
|
|
})
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function insertAtCursor (el, text) {
|
|||
|
|
if (!el) return
|
|||
|
|
el.focus()
|
|||
|
|
// 现代浏览器
|
|||
|
|
if (typeof el.setSelectionRange === 'function') {
|
|||
|
|
var s = el.selectionStart || 0
|
|||
|
|
var e = el.selectionEnd || 0
|
|||
|
|
var v = el.value
|
|||
|
|
el.value = v.slice(0, s) + text + v.slice(e)
|
|||
|
|
var pos = s + text.length
|
|||
|
|
el.setSelectionRange(pos, pos)
|
|||
|
|
} else {
|
|||
|
|
el.value += text
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* ── 提交 ──────────────────────────────────────────────────
|
|||
|
|
* 用 fetch + FormData,直接把整个表单发过去(含 nonce、comment_post_ID、
|
|||
|
|
* comment_parent)。这样服务端 wp_handle_comment_submission 拿到的
|
|||
|
|
* 字段与普通 POST 完全一致,不需要为 AJAX 单独走一套。 */
|
|||
|
|
form.addEventListener('submit', function (ev) {
|
|||
|
|
ev.preventDefault()
|
|||
|
|
|
|||
|
|
if (submitting) return // 双击保护
|
|||
|
|
|
|||
|
|
// 本地前置校验:空内容不发请求(省一次往返,也避免后端 500)
|
|||
|
|
if (textarea && textarea.value.trim() === '') {
|
|||
|
|
setStatus(I18N.emptyComment || '评论内容不能为空。', 'err')
|
|||
|
|
textarea.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 (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 处理,这里不重复
|
|||
|
|
|
|||
|
|
}())
|