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

488 lines
19 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*!
* 评论区交互
* 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 处理,这里不重复
}())