Files
webs/power_leasing/src/utils/noEmojiGuard.js

88 lines
3.3 KiB
JavaScript
Raw Normal View History

2025-09-26 16:40:38 +08:00
/**
* 全局输入表情符号拦截守卫极简无侵入
* 作用拦截所有原生 input/textarea 的输入事件移除 Emoji并重新派发 input 事件以同步 v-model
* 注意
* - 跳过正在输入法合成阶段compositionstart ~ compositionend避免影响中文输入
* - 默认对所有可编辑 input/textarea 生效如需个别放行可在元素上加 data-allow-emoji="true"
*/
export const initNoEmojiGuard = () => {
if (typeof window === 'undefined') return
if (window.__noEmojiGuardInitialized) return
window.__noEmojiGuardInitialized = true
// 覆盖常见 Emoji、旗帜、杂项符号、ZWJ、变体选择符、组合键帽
const emojiPattern = /[\u{1F300}-\u{1FAFF}]|[\u{1F1E6}-\u{1F1FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]|[\u{FE0F}]|[\u{200D}]|[\u{20E3}]/gu
/**
* 判断是否是需要拦截的可编辑元素
* @param {EventTarget} el 事件目标
* @returns {boolean}
*/
const isEditableTarget = (el) => {
if (!el || !(el instanceof Element)) return false
if (el.getAttribute && el.getAttribute('data-allow-emoji') === 'true') return false
const tag = el.tagName
if (tag === 'INPUT') {
const type = (el.getAttribute('type') || 'text').toLowerCase()
// 排除不会产生文本的类型
const disallow = ['checkbox', 'radio', 'file', 'hidden', 'button', 'submit', 'reset', 'range', 'color', 'date', 'datetime-local', 'month', 'time', 'week']
return disallow.indexOf(type) === -1
}
if (tag === 'TEXTAREA') return true
return false
}
// 记录输入法合成状态
const setComposing = (el, composing) => {
try { el.__noEmojiComposing = composing } catch (e) {}
}
const isComposing = (el) => !!(el && el.__noEmojiComposing)
// 结束合成时做一次清洗
document.addEventListener('compositionstart', (e) => {
if (!isEditableTarget(e.target)) return
setComposing(e.target, true)
}, true)
document.addEventListener('compositionend', (e) => {
if (!isEditableTarget(e.target)) return
setComposing(e.target, false)
sanitizeAndRedispatch(e.target)
}, true)
// 主输入拦截:捕获阶段尽早处理
document.addEventListener('input', (e) => {
const target = e.target
if (!isEditableTarget(target)) return
if (isComposing(target)) return
sanitizeAndRedispatch(target)
}, true)
/**
* 清洗目标元素的值并在变更时重新派发 input 事件
* @param {HTMLInputElement|HTMLTextAreaElement} target
*/
function sanitizeAndRedispatch(target) {
const before = String(target.value ?? '')
if (!before) return
if (!emojiPattern.test(before)) return
const selectionStart = target.selectionStart
const selectionEnd = target.selectionEnd
const after = before.replace(emojiPattern, '')
if (after === before) return
target.value = after
try {
// 重置光标,尽量贴近原位置
if (typeof selectionStart === 'number' && typeof selectionEnd === 'number') {
const removed = before.length - after.length
const nextPos = Math.max(0, selectionStart - removed)
target.setSelectionRange(nextPos, nextPos)
}
} catch (e) {}
// 重新派发 input 事件以同步 v-model
const evt = new Event('input', { bubbles: true })
target.dispatchEvent(evt)
}
}