3.2.25: 评论框改为所见即所得编辑器(B/I/颜色/表情可视化+序列化白名单+纯文本粘贴);后台资源 enqueue 版本号改跟 THEME_VERSION(修设置页重排被浏览器缓存挡住的问题)

This commit is contained in:
2026-10-24 22:38:10 +08:00
parent fd27b4d218
commit fb12d58067
5 changed files with 174 additions and 69 deletions
+39
View File
@@ -925,6 +925,45 @@
background: none; background: none;
} }
/* 3.2.25 所见即所得编辑区:顶替原 textarea 的可视位置。
* 边框 / 底色 / 聚焦态都挂在 .comment-textarea 容器上(focus-within),
* 编辑区本体只管排版 —— 整框仍是"一个编辑器"的观感。 */
.k-main .details .comments .comment-respond .comment-textarea .comment-editor,
.comment-textarea .comment-editor {
min-height: 136px;
max-height: 420px;
overflow-y: auto;
padding: 12px 14px;
border: 0;
background: none;
color: #2b3038;
font-family: inherit;
font-size: 14px;
line-height: 1.5;
word-break: break-word;
outline: none;
cursor: text;
-webkit-appearance: none;
}
.comment-editor:focus {
outline: none;
}
/* 占位符:编辑区为空时显示(:empty 由 input 监听保真空) */
.comment-editor:empty::before {
content: attr(data-placeholder);
color: var(--yq-text-3, #6b7280);
pointer-events: none;
}
/* 编辑区里的表情图:跟正文字号走,基线对齐 */
.comment-editor .yang-editor-emoji {
height: 1.25em;
vertical-align: -0.25em;
margin: 0 1px;
}
/* 工具条:加粗 / 斜体 / 字体颜色 / 表情。 /* 工具条:加粗 / 斜体 / 字体颜色 / 表情。
* *
* 位于输入框上方,与 textarea 同在 .comment-textarea 这个带边框的 * 位于输入框上方,与 textarea 同在 .comment-textarea 这个带边框的
+118 -62
View File
@@ -67,7 +67,7 @@
} }
/* ── 提交态开关 ──────────────────────────────────────────── /* ── 提交态开关 ────────────────────────────────────────────
* 只改按钮和 textarea 的可交互性,不动表单数据。 */ * 只改按钮和编辑区的可交互性,不动表单数据。 */
function setSubmitting (on) { function setSubmitting (on) {
submitting = on submitting = on
if (submitBtn) { if (submitBtn) {
@@ -75,6 +75,7 @@
submitBtn.classList[on ? 'add' : 'remove']('is-loading') submitBtn.classList[on ? 'add' : 'remove']('is-loading')
submitBtn.textContent = on ? (I18N.submitting || '提交中…') : (I18N.submit || '提交评论') submitBtn.textContent = on ? (I18N.submitting || '提交中…') : (I18N.submit || '提交评论')
} }
if (editor) editor.setAttribute('contenteditable', on ? 'false' : 'true')
if (textarea) textarea.readOnly = on if (textarea) textarea.readOnly = on
} }
@@ -126,8 +127,8 @@
if (parentInput) parentInput.value = String(commentId) if (parentInput) parentInput.value = String(commentId)
// 聚焦输入框并滚过去 // 聚焦输入框并滚过去
if (textarea) { if (editor) {
textarea.focus() editor.focus()
try { try {
var top = respondBox.getBoundingClientRect().top + window.pageYOffset - 90 var top = respondBox.getBoundingClientRect().top + window.pageYOffset - 90
window.scrollTo({ top: top, behavior: 'smooth' }) window.scrollTo({ top: top, behavior: 'smooth' })
@@ -176,49 +177,45 @@
} }
}) })
/* ── 富文本工具条(3.2.24)───────────────────────────────── /* ── 所见即所得编辑器(3.2.25)─────────────────────────────
* 评论内容仍是纯文本存储;工具条只在光标/选区处插入受控标签: * 3.2.24 的做法是往 textarea 里插 <strong> 这类源码:用户看到的
* B → <strong>…</strong> I → <em>…</em> * 是代码不是格式;光标点错位置再插表情,代码就被劈成两半 ——
* 颜色 → <span style="color:#xxx">…</span> * 3.2.25 用户明确要求改掉。
* 后端由 theme-comments.php 的 kses 白名单放行这三种标签
* (style 属性再经 WP 核心 safecss_filter_attr 消毒,只留安全属性)。
* *
* 选区为空时插入占位文本并**选中**它 —— 用户直接打字就覆盖占位, * 现在:#yang-editor 是 contentEditable,格式所见即所得;
* 这是各家编辑器工具条的通行做法;不选中的话占位文本会留在正文里。 * 提交前由 serializeEditor() 把编辑区内容序列化成**后端白名单
* HTML**(theme-comments.php 的 kses 白名单 + safecss_filter_attr
* 消毒保持不变):
* strong / em / span[style color] 保留
* 表情图 <img data-code> 转回 :code: 文本(存储不变)
* 其余一切标签 只保留文字(粘贴垃圾进不来)
*/ */
function wrapSelection (before, after, placeholder) { var editor = $('#yang-editor', form)
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 /* 工具按钮用 mousedown 抢焦点会弄丢编辑区选区 —— 一律挡掉,
$$('.comment-tool-btn[data-wrap-before]', form).forEach(function (btn) { * 这样「选中一段字 → 点 B」的选区在 click 到来时还在。
btn.addEventListener('click', function () { * ⚠️ 面板内部的元素(色板、表情链接)也要挡:3.2.25 本地探针实测,
wrapSelection( * 点色板时焦点被抢走,foreColor 落在塌陷光标上,颜色功能整个失效。 */
btn.getAttribute('data-wrap-before') || '', $$('.comment-tool-btn, .comment-color-swatch, .comment-color-panel, .comment-smile-panel, .comment-smile-panel a', form)
btn.getAttribute('data-wrap-after') || '', .forEach(function (b) {
I18N.boldPlaceholder || '加粗文字' 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 colorBtn = $('#yang-tool-color', form)
var colorPanel = $('#yang-color-panel', form) var colorPanel = $('#yang-color-panel', form)
if (colorBtn && colorPanel) { if (colorBtn && colorPanel) {
@@ -240,16 +237,17 @@
var c = sw.getAttribute('data-color') || '#000000' var c = sw.getAttribute('data-color') || '#000000'
// 色值只允许 #rrggbb,面板里写死的,这里再挡一道 // 色值只允许 #rrggbb,面板里写死的,这里再挡一道
if (!/^#[0-9a-fA-F]{6}$/.test(c)) return if (!/^#[0-9a-fA-F]{6}$/.test(c)) return
wrapSelection('<span style="color:' + c + '">', '</span>', I18N.colorPlaceholder || '彩色文字') exec('styleWithCSS', true)
exec('foreColor', c)
colorPanel.hidden = true colorPanel.hidden = true
colorBtn.setAttribute('aria-expanded', 'false') colorBtn.setAttribute('aria-expanded', 'false')
}) })
} }
/* ── 表情面板 ────────────────────────────────────────────── /* ── 表情面板 ──────────────────────────────────────────────
* 点表情按钮 → 把对应代码插到光标处(不是简单追加到末尾)。 * 插入**可见的表情图片**(img 带 data-code 记住代码),
* 旧实现是 `myField.value += tag`,光标本在中间也会跑到最后, * 不再插 :smile: 这类源码 —— 那正是 3.2.25 要改掉的行为。
* 用户想在某句话后面插表情就得重新点光标。 */ * 提交时 serializeEditor() 把图片转回代码,存储格式与旧评论兼容。 */
var smileBtn = $('#yang-smile-btn') var smileBtn = $('#yang-smile-btn')
var smilePanel = $('#yang-smile-panel') var smilePanel = $('#yang-smile-panel')
if (smileBtn && smilePanel) { if (smileBtn && smilePanel) {
@@ -273,26 +271,77 @@
var m = href.match(/grin\('([^']+)'\)/) var m = href.match(/grin\('([^']+)'\)/)
if (!m) return if (!m) return
ev.preventDefault() ev.preventDefault()
insertAtCursor(textarea, ' ' + m[1] + ' ') 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 smilePanel.hidden = true
smileBtn.setAttribute('aria-expanded', 'false') smileBtn.setAttribute('aria-expanded', 'false')
}) })
} }
function insertAtCursor (el, text) { /* 编辑区被清空后常残留 <br>/<div><br></div>,压成真空,
if (!el) return * 否则 :empty 占位符不显示。 */
el.focus() editor.addEventListener('input', function () {
// 现代浏览器 var h = editor.innerHTML
if (typeof el.setSelectionRange === 'function') { if (h === '<br>' || h === '<div><br></div>' || h === '<div><br></div><br>') {
var s = el.selectionStart || 0 editor.innerHTML = ''
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
} }
})
/* 粘贴只收纯文本:富文本粘贴会把整篇网页的样式标签搬进来,
* 序列化时虽然只剩文字,但先按纯文本插入可以少绕一圈。 */
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()
} }
/* ── 提交 ────────────────────────────────────────────────── /* ── 提交 ──────────────────────────────────────────────────
@@ -304,10 +353,16 @@
if (submitting) return // 双击保护 if (submitting) return // 双击保护
// 本地前置校验:空内容不发请求(省一次往返,也避免后端 500) // 编辑区内容序列化进真实提交字段(隐藏的 #comment)
if (textarea && textarea.value.trim() === '') { 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') setStatus(I18N.emptyComment || '评论内容不能为空。', 'err')
textarea.focus() if (editor) editor.focus()
return return
} }
@@ -352,6 +407,7 @@
insertComment(data) insertComment(data)
// 清空输入(保留昵称邮箱) // 清空输入(保留昵称邮箱)
if (editor) editor.innerHTML = ''
if (textarea) textarea.value = '' if (textarea) textarea.value = ''
/* 顺序很重要:exitReplyMode() 内部最后会 setStatus('','') /* 顺序很重要:exitReplyMode() 内部最后会 setStatus('','')
+11 -4
View File
@@ -241,13 +241,11 @@ if ($yang_per_page <= 0) {
<div class="comment-toolbar" role="toolbar" <div class="comment-toolbar" role="toolbar"
aria-label="<?php esc_attr_e('评论格式工具条', 'kratos'); ?>"> aria-label="<?php esc_attr_e('评论格式工具条', 'kratos'); ?>">
<button type="button" class="comment-tool-btn" id="yang-tool-bold" <button type="button" class="comment-tool-btn" id="yang-tool-bold"
data-wrap-before="&lt;strong&gt;" data-wrap-after="&lt;/strong&gt;"
title="<?php esc_attr_e('加粗', 'kratos'); ?>" title="<?php esc_attr_e('加粗', 'kratos'); ?>"
aria-label="<?php esc_attr_e('加粗', 'kratos'); ?>"> aria-label="<?php esc_attr_e('加粗', 'kratos'); ?>">
<span class="comment-tool-glyph comment-tool-glyph-bold" aria-hidden="true">B</span> <span class="comment-tool-glyph comment-tool-glyph-bold" aria-hidden="true">B</span>
</button> </button>
<button type="button" class="comment-tool-btn" id="yang-tool-italic" <button type="button" class="comment-tool-btn" id="yang-tool-italic"
data-wrap-before="&lt;em&gt;" data-wrap-after="&lt;/em&gt;"
title="<?php esc_attr_e('斜体', 'kratos'); ?>" title="<?php esc_attr_e('斜体', 'kratos'); ?>"
aria-label="<?php esc_attr_e('斜体', 'kratos'); ?>"> aria-label="<?php esc_attr_e('斜体', 'kratos'); ?>">
<span class="comment-tool-glyph comment-tool-glyph-italic" aria-hidden="true">I</span> <span class="comment-tool-glyph comment-tool-glyph-italic" aria-hidden="true">I</span>
@@ -304,8 +302,17 @@ if ($yang_per_page <= 0) {
</div> </div>
<textarea class="comment-input" id="comment" name="comment" rows="6" <textarea class="comment-input" id="comment" name="comment" rows="6"
placeholder="<?php esc_attr_e('说点什么…(支持 :smile: 这类表情代码)', 'kratos'); ?>" hidden aria-hidden="true" tabindex="-1"></textarea>
required="required" maxlength="65525"></textarea>
<?php /* 3.2.25 所见即所得编辑区:格式/表情所见即所得,
不再往框里插 <strong> 这类源码。真实内容提交前由
comments.js 序列化进上面隐藏的 #comment。
required 不能放隐藏 textarea 上(display:none 的
控件无法聚焦,原生校验会报错),空值检查在 JS 做。 */ ?>
<div class="comment-editor" id="yang-editor" contenteditable="true"
role="textbox" aria-multiline="true"
aria-label="<?php esc_attr_e('评论内容', 'kratos'); ?>"
data-placeholder="<?php esc_attr_e('说点什么…(加粗、颜色、表情点上方按钮)', 'kratos'); ?>"></div>
<div class="comment-bar"> <div class="comment-bar">
<div class="comment-bar-right"> <div class="comment-bar-right">
@@ -116,7 +116,10 @@ class Options_Framework_Admin {
if ( $this->options_screen != $hook ) if ( $this->options_screen != $hook )
return; return;
wp_enqueue_style( 'optionsframework', get_stylesheet_directory_uri() . '/inc/options-framework/css/optionsframework.css', array(), Options_Framework::VERSION ); // ⚠️ 版本号必须跟主题版本(THEME_VERSION)走:3.2.24 踩坑——
// 原来用框架常量 Options_Framework::VERSION(1.9.1 永不变),主题升级后
// 资源 URL 不变,浏览器拿缓存旧 CSS,后台「设置页重排」看起来像没做。
wp_enqueue_style( 'optionsframework', get_stylesheet_directory_uri() . '/inc/options-framework/css/optionsframework.css', array(), THEME_VERSION );
wp_enqueue_style( 'wp-color-picker' ); wp_enqueue_style( 'wp-color-picker' );
} }
@@ -135,7 +138,7 @@ class Options_Framework_Admin {
'options-custom', 'options-custom',
get_stylesheet_directory_uri() . '/inc/options-framework/js/options-custom.js', get_stylesheet_directory_uri() . '/inc/options-framework/js/options-custom.js',
array( 'jquery','wp-color-picker' ), array( 'jquery','wp-color-picker' ),
Options_Framework::VERSION THEME_VERSION
); );
// Inline scripts from options-interface.php // Inline scripts from options-interface.php
+1 -1
View File
@@ -1,7 +1,7 @@
/* /*
Theme Name: Kratos_Yang Theme Name: Kratos_Yang
Text Domain: kratos_Yang Text Domain: kratos_Yang
Version: 3.2.24 Version: 3.2.25
Requires PHP: 7.0 Requires PHP: 7.0
Description: 这是一个Kratos主题的分支版本,用来增加一些定制化功能。 Description: 这是一个Kratos主题的分支版本,用来增加一些定制化功能。
Tags: 博客, 双栏, 响应式, 自定义背景, 自定义颜色, 自定义图标, 自定义菜单, 特色图像, 收录优化, 邮件中心, 主题选项, 小工具, 文章置顶, 无障碍友好 Tags: 博客, 双栏, 响应式, 自定义背景, 自定义颜色, 自定义图标, 自定义菜单, 特色图像, 收录优化, 邮件中心, 主题选项, 小工具, 文章置顶, 无障碍友好