🫡1. 前言
之前做的一个项目的环境是一个没有键盘的设备中(屏幕支持点击,可以代替鼠标),这个时候就会出现一个问题,如果没有键盘,如何在网页中的input
中输入文字呢?
根据此需求我找到了虚拟键盘这一概念,大概就是使用前端的手段来在页面上弹出一个键盘,然后点击键盘上的按键来表示真实键盘效果
功能描述: 可以通过虚拟键盘来控制每个表单的内容,当表单聚焦时弹出虚拟键盘,虚拟键盘中回显这个表单的内容,并且内容修改后,这个表单的内容也要同步修改,并且可以适配不同的组件库等
🤔2. 准备工作
-
首先我创建了一个
demo项目
,来专门完成这个功能,等到这个功能完成后再将其接入到开发项目中,这样可以防止开发时调试起来非常简介,也不需要线上项目中那么多环境之类的配置(os: 上几篇文章的fabric.js
也是用这种方法,非常有效,可以尝试一下哦!) -
创建Vue3项目
jsyarn create vite npm init vite@latest pnpm create vite
-
输入项目名称
-
点击键盘上下方向键到Vue,再按回车选择vue
-
继续按照如上方式选择JavaScript
-
打开项目,初始化依赖包
此时项目已经创建完毕,可以通过cd命令进入项目根目录后进行依赖下载
jsnpm install // 或 yarn
-
下载依赖
js// npm 或 yarn npm install simple-keyboard // 补充: 因为线上项目使用的技术栈是Vue3+element-plus,所以这里还需要下载一个element-plus npm install element-plus
-
main.js处理
jsimport { createApp } from 'vue'; import App from './App.vue'; import './assets/styles/index.scss'; import './assets/styles/bootstrap.css'; import 'simple-keyboard/build/css/index.css'; import directive from '@/directives/index'; import ElementPlus from 'element-plus'; import 'element-plus/dist/index.css'; import { components } from './components/index'; const app = createApp(App); // 全局组件挂载 components(app); app.use(directive).use(ElementPlus).mount('#app');
-
导入之前自己二次封装的element-plus的弹窗组件(这个组件后续会进行分享,使用起来还是很简单的🙃)
js// 项目的src目录下新建components文件夹() // src/components/index.js // 二次封装的弹框组件 import DialogModal from './DialogModal/index.vue'; // 虚拟键盘组件 import Keyboard from './Keyboard/index.vue'; // 全局方法挂载 export function components(app) { app.component('Keyboard', Keyboard); app.component('DialogModal', DialogModal); }
🤨3. 开始开发
-
首先创建一个虚拟键盘出来,即使没有自定义指令触发,虚拟键盘也可以控制弹出隐藏
js<template> <!-- DialogModal就是二次封装的弹框组件,在前面已经进行全局导入了,所以这里可以直接使用 --> <DialogModal :title="''" ref="dialogModalRef" @close="close"> <template #text="{ row }"> <!-- <el-button @click="openModal(row)">打开弹框</el-button> --> <span></span> </template> <template #body> <div class="form-floating"> <textarea class="form-control input" placeholder="输入区域" id="textarea" style="height: 100px"></textarea> <label for="textarea">输入区域</label> </div> <!-- 键盘1 --> <div class="letter show"> <div class="simple-keyboard-main"></div> </div> <!-- 键盘2 --> <div class="fn"> <div class="select-box left-select"></div> <div class="simple-keyboard-main2"></div> <div class="select-box right-select"></div> </div> </template> <template #footer> <span></span> </template> </DialogModal> <div class="modal fade" id="keyboardModal" tabindex="-1" aria-labelledby="textarea" aria-hidden="true"></div> </template> <script setup> // 虚拟键盘第三方库 import SimpleKeyboard from 'simple-keyboard'; import { ref, nextTick } from 'vue'; // 弹框实例 const dialogModalRef = ref(); // 自定义键盘文本域 let textBox = ''; /** * 键盘 */ let commonKeyboardOptions = { onKeyPress: button => onKeyPress(button), theme: 'simple-keyboard hg-theme-default', physicalKeyboardHighlight: true, syncInstanceInputs: true, mergeDisplay: true, }; let keyboard = ''; function handleShift() { let currentLayout = keyboard.options.layoutName; let shiftToggle = currentLayout === 'default' ? 'shift' : 'default'; keyboard.setOptions({ layoutName: shiftToggle, }); } // 切换键盘 function handleSwitch(button) { console.log(button); nextTick(() => { if (button === '{f(x)}') { document.querySelector('.fn').classList.add('show'); document.querySelector('.letter').classList.remove('show'); } else if (button === '{ABC}') { document.querySelector('.letter').classList.add('show'); document.querySelector('.fn').classList.remove('show'); } }); } // 点击enter提交 function handleSubmit(button) { dialogModalRef.value.dialog.visible = false; // TODO: 以下代码分别为修改input框的value以及调用input的chang事件,为v-model的核心方法,两个都触发执行,就是触发了v-model,所以v-model的也会被修改 // 将写出结果内容添加到全局的 window.targetInput.value = textBox.value; // 触发input事件,以更新v-model绑定的变量 window.targetInput.dispatchEvent(new Event('input')); } function handleEsc() { dialogModalRef.value.dialog.visible = false; } function getButtonValue(button) { if (button === '{True(HI)}') button = '{True}'; if (button === '{False(LO)}') button = '{False}'; let arr = ['{True}', '{False}', '{and}', '{or}', '{not}', '{xor}']; if (arr.includes(button)) { return button.slice(1, button.length - 1); } else if (button === '{space}') { return ' '; } else if (button.includes('numpad')) { return button.slice(7, button.length - 1); } else { return button; } } function onKeyPress(button) { if (button === '{escape}') { handleEsc(); return; } if (button === '{enter}') { handleSubmit(button); return; } if (button === '{shift}' || button === '{shiftleft}' || button === '{shiftright}' || button === '{capslock}') { handleShift(); return; } if (button === '{backspace}') { // 获取光标位置 let cursorPosition = textBox.selectionStart; // 获取文本框中的值 let value = textBox.value; let newValue = removeCharAtIndex(value, cursorPosition - 1); textBox.value = newValue; keyboard.setInput(newValue); moveCursor(cursorPosition, -1); } else if (button === '{f(x)}' || button === '{ABC}') { handleSwitch(button); } else if (button === '{arrowleft}' || button === '{arrowright}') { let offset = button === '{arrowleft}' ? -1 : 1; let currentPosition = textBox.selectionStart; let newPosition = currentPosition + offset; textBox.setSelectionRange(newPosition, newPosition); } else { // 获取光标位置 let cursorPosition = textBox.selectionStart; // 获取文本框中的值 let value = textBox.value; // 输入的键盘值 let buttonValue = getButtonValue(button); // 在光标位置插入值 let newValue = insertString(value, cursorPosition, buttonValue); textBox.value = newValue; keyboard.setInput(newValue); moveCursor(cursorPosition, buttonValue.length); } // 将focus操作加入延时队列 setTimeout(() => { textBox.focus(); }, 0); } // 在指定位置插入字符串 function insertString(originalString, index, insertion) { return originalString.slice(0, index) + insertion + originalString.slice(index); } // 移除指定位置的字符串 function removeCharAtIndex(inputString, index) { return inputString.slice(0, index) + inputString.slice(index + 1); } // 将光标移动到指定位置 function moveCursor(currentPosition, offset) { let newPosition = currentPosition + offset; // 计算新的光标位置 textBox.setSelectionRange(newPosition, newPosition); // 移动光标 } /** * 下拉选择 */ let selectOptions = { onChange: function (event) { console.log(event); }, }; let selectList = [ { title: '选择设备', options: [ { value: '', label: '--' }, { value: '1', label: '设备1' }, { value: '2', label: '设备2' }, ], }, { title: '获取所选参数值', options: [ { value: '', label: '--' }, { value: '1', label: '参数值1' }, { value: '2', label: '参数值2' }, ], }, ]; async function createSelect(item, selectOptions) { await nextTick(); let selectDom = document.createElement('select'); selectDom.classList.add('form-select'); selectDom.setAttribute('data-title', item.title); selectDom.onchange = selectOptions.onChange; for (let i = 0; i < item.options.length; i++) { let optionDom = document.createElement('option'); optionDom.value = item.options[i].value; optionDom.textContent = item.options[i].label; selectDom.appendChild(optionDom); } return selectDom; } async function renderSelectList(parentDom, selectList, selectOptions) { await nextTick(); let fragment = document.createDocumentFragment(); for (let i = 0; i < selectList.length; i++) { let itemDom = document.createElement('div'); itemDom.classList.add('item'); let div = document.createElement('div'); div.textContent = selectList[i].title; itemDom.appendChild(div); let selectDom = await createSelect(selectList[i], selectOptions); itemDom.appendChild(selectDom); fragment.appendChild(itemDom); } parentDom.appendChild(fragment); } // 打开键盘弹框 const openModal = value => { // 打开弹框 dialogModalRef.value.dialog.visible = true; // 创建键盘 nextTick(() => { textBox = document.getElementById('textarea'); if (value && textBox) { textBox.value = value; } keyboard = new SimpleKeyboard('.simple-keyboard-main', { ...commonKeyboardOptions, layout: { default: ['{escape} 1 2 3 4 5 6 7 8 9 0 - = {backspace}', 'q w e r t y u i o p [ ] \\', "{capslock} a s d f g h j k l ; ' {enter}", 'z x c v b n m , . /', '{f(x)} {shiftleft} {space} {arrowleft} {arrowright}'], shift: ['{escape} ! @ # $ % ^ & * ( ) _ + {backspace}', 'Q W E R T Y U I O P { } |', '{capslock} A S D F G H J K L : " {enter}', 'Z X C V B N M < > ?', '{f(x)} {shiftleft} {space} {arrowleft} {arrowright}'], }, display: { '{escape}': 'esc ⎋', '{tab}': 'tab ⇥', '{backspace}': 'backspace ⌫', '{enter}': '提交 ↵', '{capslock}': 'caps lock ⇪', '{shiftleft}': 'shift ⇧', '{arrowup}': '上', '{arrowdown}': '下', '{arrowleft}': '左', '{arrowright}': '右', '{f(x)}': 'f(x)', }, }); new SimpleKeyboard('.simple-keyboard-main2', { ...commonKeyboardOptions, layout: { default: ['{True(HI)} {False(LO)} {escape} {backspace}', '{and} {or} {not} {xor} ( ) {numpad7} {numpad8} {numpad9}', '< > = ~= >= <= {numpad4} {numpad5} {numpad6}', '+ - * / % ^ {numpad1} {numpad2} {numpad3}', '" \' {space} {numpad0} , .', '{ABC} {enter} {arrowleft} {arrowright}'], }, display: { '{escape}': 'esc ⎋', '{backspace}': 'backspace ⌫', '{enter}': '提交 ↵', '{ABC}': 'ABC', '{True(HI)}': 'True(HI)', '{False(LO)}': 'False(LO)', '{and}': 'and', '{or}': 'or', '{not}': 'not', '{xor}': 'xor', '{arrowleft}': '←', '{arrowright}': '→', }, }); renderSelectList(document.querySelector('.left-select'), selectList, selectOptions); renderSelectList(document.querySelector('.right-select'), selectList, selectOptions); }); }; // 关闭弹框回调 const close = () => { window.isFocus = false; }; // TODO: 打开键盘弹框方法挂载全局(这里是为了可以在其他地方打开虚拟键盘弹框,例如自定义指令控制) window.Keyboard.openModal = openModal; </script> <style scoped> input { font-size: 20px; } /* .modal { --bs-modal-width: 1100px; } */ .fn, .letter { display: none; margin: 10px 0 0; background-color: #ececec; } div.show { display: flex; } .fn .select-box { padding: 0 10px 5px; width: 230px; } .fn .select-box .item { margin-top: 10px; } /* 重置键盘盒子样式 */ .hg-theme-default { width: unset; flex: 1; } /* 重置指定键盘1的样式 */ .simple-keyboard-main { --key-width: 63px; --margin-width: 5px; --longer-key-width: calc(var(--key-width) * 2 + var(--margin-width)); --space-key-width: calc(var(--key-width) * 7 + var(--margin-width) * 6); } .simple-keyboard-main .hg-row .hg-button { flex-grow: 0; height: var(--key-width); width: var(--key-width); } .simple-keyboard-main .hg-row { justify-content: center; } .simple-keyboard-main .hg-row .hg-button[data-skbtn='{escape}'] { width: var(--longer-key-width); } .simple-keyboard-main .hg-row .hg-button[data-skbtn='{backspace}'] { width: var(--longer-key-width); } .simple-keyboard-main .hg-row .hg-button[data-skbtn='{capslock}'] { width: var(--longer-key-width); } .simple-keyboard-main .hg-row .hg-button[data-skbtn='{enter}'] { width: var(--longer-key-width); } .simple-keyboard-main .hg-row .hg-button[data-skbtn='{shiftleft}'] { width: var(--longer-key-width); } .simple-keyboard-main .hg-row .hg-button[data-skbtn='{space}'] { width: var(--space-key-width); } /* 重置指定键盘1的样式 */ .simple-keyboard-main2 { --key-width: 63px; --margin-width: 5px; --longer-key-width: calc(var(--key-width) * 2 + var(--margin-width)); --backspace-key-width: calc(var(--key-width) * 3 + var(--margin-width) * 2); --enter-key-width: calc(var(--key-width) * 5 + var(--margin-width) * 4); --space-key-width: calc(var(--key-width) * 4 + var(--margin-width) * 3); } .simple-keyboard-main2 .hg-row .hg-button { flex-grow: 0; height: var(--key-width); width: var(--key-width); } .simple-keyboard-main2 .hg-row { justify-content: center; } .simple-keyboard-main2 .hg-row .hg-button[data-skbtn='{True(HI)}'] { width: var(--longer-key-width); } .simple-keyboard-main2 .hg-row .hg-button[data-skbtn='{False(LO)}'] { width: var(--longer-key-width); } .simple-keyboard-main2 .hg-row .hg-button[data-skbtn='{escape}'] { width: var(--longer-key-width); } .simple-keyboard-main2 .hg-row .hg-button[data-skbtn='{backspace}'] { width: var(--backspace-key-width); } .simple-keyboard-main2 .hg-row .hg-button[data-skbtn='ABC'] { width: var(--longer-key-width); } .simple-keyboard-main2 .hg-row .hg-button[data-skbtn='{enter}'] { width: var(--enter-key-width); } .simple-keyboard-main2 .hg-row .hg-button[data-skbtn='{space}'] { width: var(--space-key-width); } </style>
-
开发自定义指令
- 自定义指令的API
js在绑定元素的 attribute 或事件监听器被应用之前调用, 在指令需要附加须要在普通的 v-on 事件监听器前调用的事件监听器时,这很有用 created() { }, 当指令第一次绑定到元素并且在挂载父组件之前调用 beforeMount() { }, 在绑定元素的父组件被挂载后调用 mounted() { }, 在更新包含组件的 VNode 之前调用 beforeUpdate() { }, 在包含组件的 VNode 及其子组件的 VNode 更新后调用 updated() { }, 在卸载绑定元素的父组件之前调用 beforeUnmount() { }, 当指令与元素解除绑定且父组件已卸载时, 只调用一次 unmounted() { },
- 将自定义指令与虚拟键盘弹框结合
js// /src/directives/index.js import { nextTick } from 'vue'; import Keyboard from '@/components/Keyboard/index'; /** * 递归查找input元素 * @param {object} dom dom树 * @returns 找到的input元素或者null */ function findFirstInputElement(dom) { // 检查dom是否是input元素 if (dom.tagName === 'INPUT') { return dom; } // 如果不是input元素,且有子节点,则递归查找子节点 if (dom.children) { for (let child of dom.children) { let input = findFirstInputElement(child); if (input) { return input; // 如果找到input元素,立即返回 } } } // 如果没有找到input元素,返回null return null; } export default { install(app) { app.directive('keyboard', { mounted(el, binding) { // 获取需要监听聚焦的input let input = findFirstInputElement(el); // 边界处理判断 if (input === null) return console.log('没有要聚焦的input'); // 绑定指令的input取消聚焦 input.blur(); // 判断是否已经获取焦点 window.isFocus = false; // 打开键盘方法 const { openModal } = window.Keyboard; // 聚焦事件函数 const focusFn = () => { // 如果已经获取过焦点,并且没有取消焦点,则不允许后续代码运行 if (window.isFocus) return; // 设置已获取焦点 window.isFocus = true; // 打开键盘弹框 openModal && openModal(input.value); // 设置全局当前触发指令的input window.targetInput = input }; // 绑定表单聚焦事件 input.onfocus = focusFn; // 每次取消聚焦时,都重新监听聚焦函数,将之前的聚焦函数覆盖 input.onblur = () => { input.onfocus = focusFn; }; }, beforeUnmount(el) { console.log('触发beforeUnmount'); el.onfocus = () => {}; el.onblur = () => {}; }, }); }, };
🤗4. 测试一下
js
<template>
<div>
<el-input v-model="a" v-keyboard />
<el-input v-model="b" v-keyboard />
<el-input v-model="c" v-keyboard />
<el-input v-model="d" v-keyboard />
<Keyboard></Keyboard>
</div>
</template>
<script setup>
import { ref } from 'vue';
let a = ref(1);
let b = ref(2);
let c = ref(3);
let d = ref(4);
</script>
<style lang="scss" scoped></style>
在App.vue中使用以上代码,当某个表单聚焦时,就会弹出虚拟键盘弹框
并且虚拟键盘弹框中也会有这个表单的value值,在弹出的虚拟键盘中对表单的值进行修改,并点击提交,就会发现对应的表单的值被修改了
至此,使用自定义指令+封装组件实现的虚拟键盘功能就完成了
🥱5. 中间遇到的难点以及如何解决的
-
如何修改当前v-model绑定的值呢?
这个问题困扰了我不少时间,因为之前陷入了思维误区,光想着将v-model绑定的值传递给自定义指令中,然后自定义指令再进行处理等等,但是实在是太麻烦.
刚好突然想到了v-model的原理,就是监听表单的change与input事件,所以我只需要将新的值放到这个表单的value中,然后手动触发change与input方法就行了!
-
如何兼容不同的组件库
在刚开始做这个功能的时候我就想到了,开发时是肯定不会写原生的input的,如果是一些组件库应该怎么办,总不可能组件写到组件库中的input里面吧,肯定是写在v-model的统一标签中的
于是,我就去看element-plus里面的el-input的结构,发现最深层依然存在input,于是就写了一个辅助方法,可以判断当前指令绑定的dom是否为input元素,如果当前自定义指令就是绑定在input元素上,就不需要再进行处理了,如果不是,就进行递归操作,向更深层次去查找
将找到的第一个input元素作为处理的元素,在后续的弹框虚拟键盘中输入的值作为value替换到这个input的value中,再调用这个input的change与input方法即可实现在不同的组件库中,只需要绑定这个自定义指令,都可以兼容
🎉6. 总结
个人感觉是非常锻炼Vue的一个小功能, 如果需要源码的可以掘金/csdn (都是同名的),私信我获取源码