/** * 全局输入表情符号拦截守卫(极简,无侵入) * 作用:拦截所有原生 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) } }