vue + elementUI 实现特殊字符(上标、下标、特殊符号等)输入框

标准unicode特殊字符输入框

  • 支持标准unicode特殊字符
  • 输入框完全支持elmentUI input属性定义
  • 在编辑位置插入选中字符,自动更新input光标
  • 注意:并不是所有的数字和字母都有对应的上标和下标字符,当满足不了时需要寻找其他的解决方案,如:富文本输入框自定义上标、下标格式,可以满足所有需要的上标下标

上标、下标生成参考:https://tools360.net/zh/subscript-generator

1、组件定义

ts 复制代码
<!--
* @description 标准unicode特殊字符输入框
* @see 参考:https://tools360.net/zh/subscript-generator
-->
<template>
 <div class="special-char-input">
   <el-input
     v-bind="props"
     :value="innerValue"
     validateEvent
     @blur="handleBlurInput"
     @input="handleInputValue"
     ref="inputRef"
   >
   </el-input>
   <Transition name="slide-fade" mode="out-in">
     <div
       v-if="!panelVisible"
       style="
         text-align: right;
         line-height: 1;
         padding-right: 10px;
         width: 100%;
         position: absolute;
       "
       key="link"
     >
       <el-link icon="el-icon-edit" @click="handleShowPanel" style="padding: 5px"
         >特殊字符库</el-link
       >
     </div>
     <div v-if="panelVisible" class="special-char-panel">
       <el-card
         :body-style="{ 'padding-top': '10px', 'padding-bottom': '5px' }"
         style="border-color: #dcdfe6; margin-bottom: 5px"
         key="panel"
       >
         <div class="title-box">
           <div class="title-txt">
             <i class="el-icon-edit" style="margin-right: 5px"></i>特殊字符库
           </div>
           <div class="title-action">
             <el-button
               style="padding: 0"
               type="text"
               icon="el-icon-caret-top"
               @click="handleHidePanel"
               >收起</el-button
             >
           </div>
         </div>
         <el-tabs tab-position="left" v-model="activeTab" @tab-click="handleTabClick">
           <el-tab-pane v-for="(tab, idx) in tabs" :key="idx" :label="tab.label" :name="tab.key">
             <div class="char-content">
               <div
                 class="char-item"
                 v-for="(char, idx) in getTabChars(tab.key)"
                 :key="idx"
                 @click="handleClickChar(char)"
                 :title="`${tab.label}: ${char}`"
                 :style="{ 'font-size': tab.fontSize || '17px' }"
               >
                 {{ char }}
               </div>
             </div>
           </el-tab-pane>
         </el-tabs>
       </el-card>
     </div>
   </Transition>
 </div>
</template>

<script setup lang="ts">
import { ElementUIComponentSize } from 'element-ui/types/component';
import type { AutoSize, ElInput, InputType, Resizability } from 'element-ui/types/input';
import { computed, nextTick, watch, watchEffect } from 'vue';
import { getCurrentInstance } from 'vue';
import { h, onMounted, ref, unref } from 'vue';

/**
* @description 无法直接使用ElInput,因为 class是运行时,此处需要静态声明
*/
type Props = {
 /** Type of input */
 type?: InputType;

 /** Binding value */
 value?: string | number;

 /** Maximum Input text length */
 maxlength?: number;

 /** Minimum Input text length */
 minlength?: number;

 /** Placeholder of Input */
 placeholder?: string;

 /** Whether Input is disabled */
 disabled?: boolean;

 /** Size of Input, works when type is not 'textarea' */
 size?: ElementUIComponentSize;

 /** Prefix icon class */
 prefixIcon?: string;

 /** Suffix icon class */
 suffixIcon?: string;

 /** Number of rows of textarea, only works when type is 'textarea' */
 rows?: number;

 /** Whether textarea has an adaptive height, only works when type is 'textarea' */
 autosize?: boolean | Partial<AutoSize>;

 /** @Deprecated in next major version */
 autoComplete?: string;

 /** Same as autocomplete in native input */
 autocomplete?: string;

 /** Same as name in native input */
 name?: string;

 /** Same as readonly in native input */
 readonly?: boolean;

 /** Same as max in native input */
 max?: any;

 /** Same as min in native input */
 min?: any;

 /** Same as step in native input */
 step?: any;

 /** Control the resizability */
 resize?: Resizability;

 /** Same as autofocus in native input */
 autofocus?: boolean;

 /** Same as form in native input */
 form?: string;

 /** Whether to trigger form validatio */
 validateEvent?: boolean;

 /** Whether the input is clearable */
 clearable?: boolean;

 /** Whether to show password */
 showPassword?: boolean;

 /** Whether to show wordCount when setting maxLength */
 showWordLimit?: boolean;
};

const props = defineProps<Props>();
const emits = defineEmits<{
 (e: 'input', val: string): void;
}>();
const inputRef = ref<ElInput>();
/** 选择面板可见 */
const panelVisible = ref(false);
/** 输入框失焦时的索引 */
const blurIndex = ref<number>();
/** 输入框值 */
const innerValue = ref<string>();

// prettier-ignore
const charSets:{[key:string]:string[]} = {
 // 下标分组
 subscript: [
   '₀', '₁', '₂', '₃', '₄', '₅', '₆', '₇', '₈', '₉',
   '₊', '₋', '₌', '₍', '₎',
   'ₐ', 'ₑ' ,'ₕ', 'ᵢ', 'ⱼ', 'ₖ', 'ₗ', 'ₘ', 'ₙ', 'ₒ',
   'ₚ', 'ᵣ', 'ₛ', 'ₜ', 'ᵤ', 'ᵥ', 'ₓ',
   'ₔ', 'ᵦ', '𝑔','ᵧ', 'ᵨ', 'ᵩ', 'ᵪ',
 ],

 // 上标分组
 superscript: [
   '⁰', '¹', '²', '³', '⁴', '⁵', '⁶', '⁷', '⁸', '⁹',
   '⁺', '⁻', '⁼', '⁽', '⁾', 
   'ᵃ', 'ᵇ', 'ᶜ', 'ᵈ', 'ᵉ', 'ᶠ', 'ᵍ', 'ʰ', 'ⁱ', 'ʲ', 
   'ᵏ', 'ˡ', 'ᵐ', 'ⁿ', 'ᵒ', 'ᵖ', 'ʳ', 'ˢ', 'ᵗ', 'ᵘ', 
   'ᵛ', 'ʷ', 'ˣ', 'ʸ', 'ᶻ',
   'ᴬ', 'ᴮ', 'ᴰ', 'ᴱ', 'ᴳ', 'ᴴ', 'ᴵ', 'ᴶ', 'ᴷ', 'ᴸ', 
   'ᴹ', 'ᴺ', 'ᴼ', 'ᴾ', 'ᴿ', 'ᵀ', 'ᵁ', 'ⱽ', 'ᵂ',
   'ᵝ', 'ᵞ', 'ᵟ', 'ᵋ', 'ᶿ', 'ᵠ', 'ᵡ',
 ],

 // 罗马数字
 roman: [
   '①', '②', '③', '④', '⑤', '⑥', '⑦', '⑧', '⑨', '⑩',
   '⑪', '⑫', '⑬', '⑭', '⑮', '⑯', '⑰', '⑱', '⑲', '⑳',
   'ⅰ', 'ⅱ', 'ⅲ', 'ⅳ', 'ⅴ', 'ⅵ', 'ⅶ', 'ⅷ', 'ⅸ', 'ⅹ','ⅺ', 'ⅻ',
   'Ⅰ', 'Ⅱ', 'Ⅲ', 'Ⅳ', 'Ⅴ', 'Ⅵ', 'Ⅶ', 'Ⅷ', 'Ⅸ', 'Ⅹ','Ⅺ', 'Ⅻ', 
 ],

 //  希腊字母
 letter: [
   'α', 'β', 'γ', 'δ', 'ε', 'ζ', 'η', 'θ', 'ι', 'κ',
   'λ', 'μ', 'ν', 'ξ', 'ο', 'π', 'ρ', 'σ', 'τ', 'υ',
   'φ', 'χ', 'ψ', 'ω',
   'Α', 'Β', 'Γ', 'Δ', 'Ε', 'Ζ', 'Η', 'Θ', 'Ι', 'Κ',
   'Λ', 'Μ', 'Ν', 'Ξ', 'Ο', 'Π', 'Ρ', 'Σ', 'Τ', 'Υ',
   'Φ', 'Χ', 'Ψ', 'Ω'
 ],

 // 特殊符号
 symbol: [
   '℃', '℉', '°', '′', '″', 
   '±', '×', '÷', '√', '∞', 
   '≈', '≠', '≡', '≤', '≥', '≦', '≧', '≨', '≩', 
   '™', '®', '©', '℠', '℗', '℡', 
   '∮','∯', '∰', '∱', '∲', '∳', 
   '∫', '∑', '∏', '∂', '∆',
   '∈', '∉', '∋', '∌','⊂', '⊃', '⊄', '⊅', '⊆', '⊇', '⊈', '⊉', '⊊','⊋', 
 ]
 /* math:[
   '±', '×', '÷', '√', '∞', '∫', '∑', '∏', '∂', '∆',
   '≈', '≠', '≡', '≤', '≥', '∈', '∉', '∋', '∌', '⊕',
   '⊗', '⊥', '∥', '∠', '∟', '∘', '∙', '⋆', '★', '☆',
   '✔', '✕', '◯', '□', '△', '▷', '◁', '◇', '○', '●',
   '◆', '■', '▲', '▼', '►', '◄', '●', '◦', '‣', '♦',
   '♥', '♠', '♣', '✓', '✔', '✗', '✘', '∝', '∅', '∇',
   '¬', '∧', '∨', '∩', '∪', '∴', '∵', '∶', '∷', '∼',
   '∽', '≃', '≅', '≇', '≉', '≊', '≋', '≌', '≍', '≎',
   '≏', '≐', '≑', '≒', '≓', '≔', '≕', '≖', '≗', '≘',
   '≙', '≚', '≛', '≜', '≝', '≞', '≟', '≠', '≡', '≢',
   '≣', '≤', '≥', '≦', '≧', '≨', '≩', '≪', '≫', '≬',
   '≭', '≮', '≯', '≰', '≱', '≲', '≳', '≴', '≵', '≶',
   '≷', '≸', '≹', '≺', '≻', '≼', '≽', '≾', '≿', '⊀',
   '⊁', '⊂', '⊃', '⊄', '⊅', '⊆', '⊇', '⊈', '⊉', '⊊',
   '⊋', '⊌', '⊍', '⊎', '⊏', '⊐', '⊑', '⊒', '⊓', '⊔'
 ], */
 /* symbol: [
   '℃', '℉', '°', '′', '″', 'ℏ', 'Å', 'µ', 'Ω', 'Φ',
   'Ψ', 'α', 'β', 'γ', 'δ', 'ε', 'ζ', 'η', 'θ', 'ι',
   'κ', 'λ', 'μ', 'ν', 'ξ', 'ο', 'π', 'ρ', 'σ', 'τ',
   'υ', 'φ', 'χ', 'ψ', 'ω', 'Γ', 'Δ', 'Θ', 'Λ', 'Ξ',
   'Π', 'Σ', 'Υ', 'Φ', 'Ψ', 'Ω', '∇', '∂', '∫', '∮',
   '∯', '∰', '∱', '∲', '∳', '⊥', '∥', '∠', '∡', '∢',
   '⊾', '⊿', '⋔', '⋕', '⋖', '⋗', '⋘', '⋙', '⋚', '⋛',
   '⋜', '⋝', '⋞', '⋟', '⋠', '⋡', '⋢', '⋣', '⋤', '⋥',
   '⋦', '⋧', '⋨', '⋩', '⋪', '⋫', '⋬', '⋭', '⋮', '⋯',
   '⋰', '⋱', '⋲', '⋳', '⋴', '⋵', '⋶', '⋷', '⋸', '⋹'
 ] */
};

// 标签列表
const tabs = [
 { key: 'superscript', label: '上标', fontSize: '21px' },
 { key: 'subscript', label: '下标', fontSize: '21px' },
 { key: 'roman', label: '数字序号', fontSize: '17px' },
 { key: 'letter', label: '希腊字母', fontSize: '17px' },
 { key: 'symbol', label: '特殊符号', fontSize: '17px' },
];
// 当前激活标签
const activeTab = ref<string>(tabs[0].key);

/** 获取tab的字符集 */
const getTabChars = (tabKey: string) => {
 const chars = charSets[tabKey] || [];
 // chars = chars.sort((a, b) => a.codePointAt(0)! - b.codePointAt(0)!);
 return Array.from(new Set(chars));
};

/** 更新输入框光标位置 */
const updateInputCursorPosition = async () => {
 await nextTick();
 inputRef.value?.focus();
 const inputEl =
   inputRef.value?.$el.querySelector('textarea') || inputRef.value?.$el.querySelector('input');
 const index = blurIndex.value ?? innerValue.value?.length ?? 0;
 if (inputEl) {
   inputEl.setSelectionRange(index, index);
 }
};

/** 输入框内容变更 */
const handleInputValue = (value: string) => {
 innerValue.value = value;
 emits('input', value);
};

/** 输入框失焦 */
const handleBlurInput = (e: FocusEvent) => {
 blurIndex.value = (e.target as HTMLInputElement)?.selectionStart || 0;
};

/** 显示面板 */
const handleShowPanel = (e: Event) => {
 panelVisible.value = true;
 updateInputCursorPosition();
};

/** 收起面板 */
const handleHidePanel = (e: Event) => {
 panelVisible.value = false;
};

/** 切换tab */
const handleTabClick = () => {
 updateInputCursorPosition();
};

/** 选中字符 */
const handleClickChar = (char: string) => {
 const index = blurIndex.value ?? 0;
 const str = innerValue.value || '';
 const newValue = str.slice(0, index) + char + str.slice(index);
 innerValue.value = newValue;
 blurIndex.value = index + char.length;

 // 触发input事件
 emits('input', newValue);

 // 更新光标位置
 updateInputCursorPosition();
};

watch(
 () => props.value,
 (val) => {
   if (val !== innerValue.value) {
     innerValue.value = typeof val === 'number' ? val.toString() : val;
   }
 },
 { deep: true, immediate: true },
);
</script>

<style scoped lang="scss">
.special-char-input {
 position: relative;
 padding-bottom: 5px;
 .special-char-panel {
   width: 100%;
   // position: absolute;
   z-index: 999;
   .title-box {
     display: flex;
     align-items: center;
     .title-txt {
       font-size: 15px;
       font-weight: bold;
       flex: 1;
     }
     .title-action {
     }
   }
   .char-content {
     padding: 16px;
     display: flex;
     flex-wrap: wrap;
     gap: 8px;
     max-height: 300px;
     overflow-y: auto;

     .char-item {
       color: #333;
       cursor: pointer;
       border: 1px solid #dcdfe6;
       border-radius: 4px;
       transition: all 0.3s;
       width: 35px;
       height: 35px;
       text-align: center;
       line-height: 35px;
       /* font-family:
         'Microsoft YaHei', 微软雅黑, STHei, 华文黑体, 'Helvetica Neue', Helvetica, Arial,
         sans-serif; */

       &:hover {
         background: #f5f7fa;
         border-color: #409eff;
         color: #409eff;
       }
     }
   }
 }
}

.slide-fade-enter-active {
 transition: all 0.2s ease;
}
.slide-fade-leave-active {
 transition: all 0.2s cubic-bezier(1, 0.5, 0.8, 1);
}
.slide-fade-enter,
.slide-fade-leave-to {
 transform: translateX(0) translateY(-10%);
 opacity: 0;
}
</style>

2、组件使用

ts 复制代码
<SpecialCharInput
   v-model="formInfo.scopeLimit"
   type="textarea"
   placeholder="请输入限制范围"
   :autosize="{ minRows: 4 }"
   :maxlength="2000"
   show-word-limit
 ></SpecialCharInput>
相关推荐
残冬醉离殇6 小时前
从多页面到单页面——浏览器导航的进化史
vue.js
逻极6 小时前
Next.js vs Vue.js:2025年全栈战场,谁主沉浮?
开发语言·javascript·vue.js·reactjs
杰克尼6 小时前
vue-day02
前端·javascript·vue.js
一只小阿乐6 小时前
vue3 中实现父子组件v-model双向绑定 总结
前端·javascript·vue.js·vue3·组件·v-model语法糖
星光一影6 小时前
快递比价寄件系统技术解析:基于PHP+Vue+小程序的高效聚合配送解决方案
vue.js·mysql·小程序·php
qq_338032926 小时前
Vue 3 的<script setup> 和 Vue 2 的 Options API的关系
前端·javascript·vue.js
擦拉嘿6 小时前
Days.js实时更新时间格式文案在切换全局语言之后的方案
vue.js·days.js·动态更新时间
lumi.6 小时前
Vue Router页面跳转指南:告别a标签,拥抱组件化无刷新跳转
前端·javascript·vue.js
yeyuningzi6 小时前
VUE 运行npm run dev命令提示error Missing script: “dev“
前端·vue.js·npm