Vue3中使用自定义指令+VueComponents触发虚拟键盘😁

🫡1. 前言

之前做的一个项目的环境是一个没有键盘的设备中(屏幕支持点击,可以代替鼠标),这个时候就会出现一个问题,如果没有键盘,如何在网页中的input中输入文字呢?

根据此需求我找到了虚拟键盘这一概念,大概就是使用前端的手段来在页面上弹出一个键盘,然后点击键盘上的按键来表示真实键盘效果

功能描述: 可以通过虚拟键盘来控制每个表单的内容,当表单聚焦时弹出虚拟键盘,虚拟键盘中回显这个表单的内容,并且内容修改后,这个表单的内容也要同步修改,并且可以适配不同的组件库等

🤔2. 准备工作

  1. 首先我创建了一个demo项目,来专门完成这个功能,等到这个功能完成后再将其接入到开发项目中,这样可以防止开发时调试起来非常简介,也不需要线上项目中那么多环境之类的配置(os: 上几篇文章的fabric.js也是用这种方法,非常有效,可以尝试一下哦!)

  2. 创建Vue3项目

    js 复制代码
        yarn create vite
        npm init vite@latest 
        pnpm create vite 
  3. 输入项目名称

  4. 点击键盘上下方向键到Vue,再按回车选择vue

  5. 继续按照如上方式选择JavaScript

  6. 打开项目,初始化依赖包

    此时项目已经创建完毕,可以通过cd命令进入项目根目录后进行依赖下载

    js 复制代码
        npm install
        // 或
        yarn
  7. 下载依赖

    js 复制代码
    // npm 或 yarn
    
    npm install simple-keyboard
    
    // 补充: 因为线上项目使用的技术栈是Vue3+element-plus,所以这里还需要下载一个element-plus
    npm install element-plus
  8. main.js处理

    js 复制代码
    import { 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');
  9. 导入之前自己二次封装的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. 开始开发

  1. 首先创建一个虚拟键盘出来,即使没有自定义指令触发,虚拟键盘也可以控制弹出隐藏

    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>
  2. 开发自定义指令

    1. 自定义指令的API
    js 复制代码
       在绑定元素的 attribute 或事件监听器被应用之前调用, 在指令需要附加须要在普通的 v-on 事件监听器前调用的事件监听器时,这很有用
       created() { },
       当指令第一次绑定到元素并且在挂载父组件之前调用
       beforeMount() { },
       在绑定元素的父组件被挂载后调用
       mounted() { },
       在更新包含组件的 VNode 之前调用
       beforeUpdate() { },
       在包含组件的 VNode 及其子组件的 VNode 更新后调用
       updated() { },
       在卸载绑定元素的父组件之前调用
       beforeUnmount() { },
       当指令与元素解除绑定且父组件已卸载时, 只调用一次
       unmounted() { },
    1. 将自定义指令与虚拟键盘弹框结合
    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. 中间遇到的难点以及如何解决的

  1. 如何修改当前v-model绑定的值呢?

    这个问题困扰了我不少时间,因为之前陷入了思维误区,光想着将v-model绑定的值传递给自定义指令中,然后自定义指令再进行处理等等,但是实在是太麻烦.

    刚好突然想到了v-model的原理,就是监听表单的change与input事件,所以我只需要将新的值放到这个表单的value中,然后手动触发change与input方法就行了!

  2. 如何兼容不同的组件库

    在刚开始做这个功能的时候我就想到了,开发时是肯定不会写原生的input的,如果是一些组件库应该怎么办,总不可能组件写到组件库中的input里面吧,肯定是写在v-model的统一标签中的

    于是,我就去看element-plus里面的el-input的结构,发现最深层依然存在input,于是就写了一个辅助方法,可以判断当前指令绑定的dom是否为input元素,如果当前自定义指令就是绑定在input元素上,就不需要再进行处理了,如果不是,就进行递归操作,向更深层次去查找

    将找到的第一个input元素作为处理的元素,在后续的弹框虚拟键盘中输入的值作为value替换到这个input的value中,再调用这个input的change与input方法即可实现在不同的组件库中,只需要绑定这个自定义指令,都可以兼容

🎉6. 总结

个人感觉是非常锻炼Vue的一个小功能, 如果需要源码的可以掘金/csdn (都是同名的),私信我获取源码

相关推荐
web行路人8 分钟前
React中类组件和函数组件的理解和区别
前端·javascript·react.js·前端框架
番茄小酱00110 分钟前
Expo|ReactNative 中实现扫描二维码功能
javascript·react native·react.js
子非鱼92128 分钟前
【Ajax】跨域
javascript·ajax·cors·jsonp
超雄代码狂30 分钟前
ajax关于axios库的运用小案例
前端·javascript·ajax
嚣张农民1 小时前
推荐3个实用的760°全景框架
前端·vue.js·程序员
周亚鑫1 小时前
vue3 pdf base64转成文件流打开
前端·javascript·pdf
落魄小二1 小时前
el-table 表格索引不展示问题
javascript·vue.js·elementui
y5236481 小时前
Javascript监控元素样式变化
开发语言·javascript·ecmascript
fruge2 小时前
纯css制作声波扩散动画、js+css3波纹催眠动画特效、【css3动画】圆波扩散效果、雷达光波效果完整代码
javascript·css·css3
neter.asia2 小时前
vue中如何关闭eslint检测?
前端·javascript·vue.js