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

544 lines
22 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 : '')
}
/* ── 提交态开关 ────────────────────────────────────────────
* 只改按钮和编辑区的可交互性,不动表单数据。 */
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 处理,这里不重复
}())