/*! * 评论区交互 * Yongquan Yang * * ── 旧版(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('")` * 直接用 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 里插 这类源码:用户看到的 * 是代码不是格式;光标点错位置再插表情,代码就被劈成两半 —— * 3.2.25 用户明确要求改掉。 * * 现在:#yang-editor 是 contentEditable,格式所见即所得; * 提交前由 serializeEditor() 把编辑区内容序列化成**后端白名单 * HTML**(theme-comments.php 的 kses 白名单 + safecss_filter_attr * 消毒保持不变): * strong / em / span[style color] 保留 * 表情图 转回 :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, .comment-code-panel, .comment-code-lang', 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 产出 , * 而不是废弃的 —— 正好对齐后端白名单。 */ 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', ' ' + m[1] + ' ') } else { exec('insertText', ' ' + m[1] + ' ') } smilePanel.hidden = true smileBtn.setAttribute('aria-expanded', 'false') }) } /* ── 3.2.42 代码块面板 ────────────────────────────────── * 与颜色/表情面板同款开合逻辑(点按钮开合、点外面关掉)。 * 选定语言后会在光标处插入: *
这里换成你的代码
* 为什么先插占位文字而不是空块:空的
 在浏览器里高度接近 0,
	 * 光标定位困难、看起来像"没插进去",访客会反复点。
	 *
	 * 🔴 为什么语言必须让访客自己选:
	 *    自动识别在站内实测准确率只有 22%(18 个长块对 4 个),
	 *    让它替访客决定语言等于制造错误标签。
	 * 🔴 插入后必须把光标留在代码块里,否则访客紧接着打字会打到块外面。
	 */
	var codeBtn = $('#yang-code-btn')
	var codePanel = $('#yang-code-panel')
	if (codeBtn && codePanel) {
		codeBtn.addEventListener('click', function (ev) {
			ev.stopPropagation()
			var open = !codePanel.hidden
			codePanel.hidden = open
			codeBtn.setAttribute('aria-expanded', open ? 'false' : 'true')
		})
		document.addEventListener('click', function (ev) {
			if (codePanel.hidden) return
			if (codePanel.contains(ev.target) || codeBtn.contains(ev.target)) return
			codePanel.hidden = true
			codeBtn.setAttribute('aria-expanded', 'false')
		})
		codePanel.addEventListener('click', function (ev) {
			var b = ev.target.closest ? ev.target.closest('.comment-code-lang') : null
			if (!b) return
			ev.preventDefault()
			var lang = b.getAttribute('data-lang') || 'text'
			insertCodeBlock(lang)
			codePanel.hidden = true
			codeBtn.setAttribute('aria-expanded', 'false')
		})
	}

	/* 在光标处插入一个代码块,并把占位文字选中,方便直接覆盖 */
	function insertCodeBlock (lang) {
		if (!editor) return
		editor.focus()
		var cls = lang && lang !== 'text' ? ' class="language-' + lang + '"' : ''
		var html = '
' // 先插空块,再把占位文字塞进 code 里并选中 try { document.execCommand('insertHTML', false, html) } catch (ex) { // 老浏览器兜底:直接插纯文本标记 document.execCommand('insertText', false, '\n
\n\n
\n') return } // 找到刚插入的最后那个 code,放占位文字并全选 var codes = $$('pre:last-of-type code', editor) var target = codes.length ? codes[codes.length - 1] : null if (!target) { var all = $$('pre code', editor) target = all.length ? all[all.length - 1] : null } if (target) { target.textContent = '在这里粘贴或输入代码' var range = document.createRange() range.selectNodeContents(target) var sel = window.getSelection() sel.removeAllRanges() sel.addRange(range) } } /* 编辑区被清空后常残留
/

,压成真空, * 否则 :empty 占位符不显示。 */ editor.addEventListener('input', function () { var h = editor.innerHTML if (h === '
' || h === '

' || h === '


') { 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, '&').replace(//g, '>') } 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) }) /* ── 3.2.42 代码块:必须在 walk() 之前单独序列化 ────────── * 🔴 不能交给 walk() 递归处理,原因是 walk 会给所有文本做 * escHtml()(把 & 变 &、< 变 <)。代码块里的内容 * 出发时是**已转义过的显示文本**(浏览器 textContent 已把 * < 还原成 <),需要重新转义一次才对;但 walk 还可能 * 把代码里的连续空行压掉(收尾的 /\n{3,}/ 替换), * 而代码里连续空行是完全正常的。 * ⇒ 做法:先把每个
 换成唯一占位标记,
		 *    walk 结束后再把标记替换回序列化好的代码块。
		 *    占位符用 \u0000 包裹,escHtml 不会改动它。
		 */
		var codeBlocks = []
		var pres = $$('pre', clone)
		for (var pi = 0; pi < pres.length; pi++) {
			var pre = pres[pi]
			var codeEl = pre.querySelector('code')
			var srcEl = codeEl || pre
			var cls = (codeEl ? codeEl.className : '') + ' ' + (pre.className || '')
			var mm = /(?:language|lang)-([a-z0-9+#.-]+)/i.exec(cls)
			var lang = mm ? mm[1].toLowerCase() : ''
			// textContent 是浏览器还原后的纯文本,重新转义成安全形态再提交
			var raw = srcEl.textContent || ''
			var safe = raw.replace(/&/g, '&')
				.replace(//g, '>')
			var block = '
' + safe + '
' var ph = '\u0000CB' + codeBlocks.length + '\u0000' codeBlocks.push(block) pre.parentNode.replaceChild(document.createTextNode(ph), pre) } 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 作用在斜体/加粗选区时, * 产出的是 / * (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) ? '' + inner + '' : inner if (tag === 'strong' || tag === 'b') { out += '' + colSpan + '' } else if (tag === 'em' || tag === 'i') { out += '' + colSpan + '' } else if (tag === 'span' || tag === 'font') { out += colSpan } else if (tag === 'div' || tag === 'p') { out += inner + '\n' } else { out += inner } } return out } var body = walk(clone) // 空行压缩只作用于代码块之外 —— 先把已存的块换回来,避免误伤 body = body.replace(/\n{3,}/g, '\n\n') // 🔴 占位符还原:必须在压缩空行**之后**做。 // 如果先还原,代码里的连续空行会被上面的 /\n{3,}/ 一起压掉。 for (var ci = 0; ci < codeBlocks.length; ci++) { body = body.split('\u0000CB' + ci + '\u0000').join(codeBlocks[ci]) } return body.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 处理,这里不重复 }())