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 (都是同名的),私信我获取源码

相关推荐
烛阴1 小时前
Promise无法中断?教你三招优雅实现异步任务取消
前端·javascript
GUIQU.1 小时前
【Vue】单元测试(Jest/Vue Test Utils)
前端·vue.js
前端张三1 小时前
vue3中ref在js中为什么需要.value才能获取/修改值?
前端·javascript·vue.js
爱的叹息2 小时前
解决 Dart Sass 的旧 JS API 弃用警告 的详细步骤和解决方案
javascript·rust·sass
夕水2 小时前
这个提升效率宝藏级工具一定要收藏使用
前端·javascript·trae
会飞的鱼先生3 小时前
vue3 内置组件KeepAlive的使用
前端·javascript·vue.js
苹果酱05673 小时前
【Azure Redis 缓存】在Azure Redis中,如何限制只允许Azure App Service访问?
java·vue.js·spring boot·mysql·课程设计
前端大白话3 小时前
前端崩溃瞬间救星!10 个 JavaScript 实战技巧大揭秘
前端·javascript
一千柯橘4 小时前
Nestjs 解决 request entity too large
javascript·后端
举个栗子dhy4 小时前
如何处理动态地址栏参数,以及Object.entries() 、Object.fromEntries()和URLSearchParams.entries()使用
javascript