488 lines
19 KiB
JavaScript
488 lines
19 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()
|
||
}
|
||
})
|
||
|
||
/* ── 富文本工具条(3.2.24)─────────────────────────────────
|
||
* 评论内容仍是纯文本存储;工具条只在光标/选区处插入受控标签:
|
||
* B → <strong>…</strong> I → <em>…</em>
|
||
* 颜色 → <span style="color:#xxx">…</span>
|
||
* 后端由 theme-comments.php 的 kses 白名单放行这三种标签
|
||
* (style 属性再经 WP 核心 safecss_filter_attr 消毒,只留安全属性)。
|
||
*
|
||
* 选区为空时插入占位文本并**选中**它 —— 用户直接打字就覆盖占位,
|
||
* 这是各家编辑器工具条的通行做法;不选中的话占位文本会留在正文里。
|
||
*/
|
||
function wrapSelection (before, after, placeholder) {
|
||
if (!textarea) return
|
||
textarea.focus()
|
||
var s = textarea.selectionStart || 0
|
||
var e = textarea.selectionEnd || 0
|
||
var v = textarea.value
|
||
var sel = v.slice(s, e)
|
||
var usedPlaceholder = false
|
||
if (sel === '') {
|
||
sel = placeholder || ''
|
||
usedPlaceholder = true
|
||
}
|
||
textarea.value = v.slice(0, s) + before + sel + after + v.slice(e)
|
||
// 选中被包裹的内容(或占位文本),光标落回包裹体内
|
||
var a = s + before.length
|
||
var b = a + sel.length
|
||
try { textarea.setSelectionRange(a, b) } catch (ex) { /* 老 IE 忽略 */ }
|
||
return usedPlaceholder
|
||
}
|
||
|
||
// B / I:按钮上带 data-wrap-before / data-wrap-after,加新按钮不用改 JS
|
||
$$('.comment-tool-btn[data-wrap-before]', form).forEach(function (btn) {
|
||
btn.addEventListener('click', function () {
|
||
wrapSelection(
|
||
btn.getAttribute('data-wrap-before') || '',
|
||
btn.getAttribute('data-wrap-after') || '',
|
||
I18N.boldPlaceholder || '加粗文字'
|
||
)
|
||
})
|
||
})
|
||
|
||
/* 字体颜色面板:开合 + 色板点击包裹选区。
|
||
* 与表情面板同款交互:点按钮开/关,点外面关,选中后关。 */
|
||
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
|
||
wrapSelection('<span style="color:' + c + '">', '</span>', I18N.colorPlaceholder || '彩色文字')
|
||
colorPanel.hidden = true
|
||
colorBtn.setAttribute('aria-expanded', 'false')
|
||
})
|
||
}
|
||
|
||
/* ── 表情面板 ──────────────────────────────────────────────
|
||
* 点表情按钮 → 把对应代码插到光标处(不是简单追加到末尾)。
|
||
* 旧实现是 `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 处理,这里不重复
|
||
|
||
}()) |