如何用div手写一个富文本编辑器(contenteditable="true")

如何用div手写一个富文本编辑器(contenteditable="true")

日常工作中,总会有需求要求我们编辑文本,但是又要在里面插入一些特殊的内容,并显示特定的颜色边框等等,这时候textarea就无法满足需求了,但是用富文本编辑器又有点多余,没那么多的东西,并且插入项的自定义很多富文本编辑器支持并不是那么美好,所以就不得不自己手写富文本框了,这种自己手写的想怎么玩怎么玩,封装成组件之后就很灵活。

具体实现是利用div上写上contenteditable="true"属性,使div可编辑,具体这个属性的作用我就不多说了,不知道的可以查一下

效果图展示

不难看出,分为了3个区域:顶部工具栏,输入主体,底部工具栏

HTML代码

xml 复制代码
<el-form-item label="文本消息内容:" prop="content">
    <!-- 这里是用于表单校验的,不需要的话可以删除 -->
    <el-input v-model="textData!.content" style="display: none" />
    <div class="text-editor">
        <!-- 顶部工具栏 -->
        <div class="toolbar flex align-center pd-lr-20 text-16">
            <span v-if="props.toolbar.length > 0">插入:</span>
            <div class="tools flex align-center mg-l-10 flex-sub text-14">
                <template v-for="(item, index) in toolbarList">
                    <div
                        class="tools-item flex align-center pointer"
                        :key="index"
                        :class="item.key"
                        v-if="props.toolbar.indexOf(item.key) >= 0"
                        @click="handleToolbarClick(item.key)"
                    >
                        <el-icon v-if="item.key === 'nickname'"><User /></el-icon
                        ><el-icon v-else><Link /></el-icon>{{ item.title }}
                    </div>
                </template>
            </div>
        </div>
​
        <!-- 编辑器主体部分 -->
        <div class="main">
            <div
                id="message-input"
                ref="messageInputDom"
                class="mess-input"
                contenteditable="true"
                spellcheck="false"
                @paste="handlePaste"
                @blur="saveCursor"
                @input="inputChange"
            ></div>
        </div>
    </div>
    <!-- 底部插入表情 -->
    <div class="emoji flex align-center" v-if="emoji">
        点击插入:<emoji @insert="insertEmoji"></emoji>
    </div>
</el-form-item>

TS代码

typescript 复制代码
import { reactive, ref } from 'vue';
import { createUniqueString } from '@/utils/util';
import Emoji from './emoji/index.vue';
import { debounce } from 'lodash';
const props = defineProps({
    toolbar: {
        type: Array,
        default: () => {
            return ['nickname', 'link', 'miniprogram', 'variable', 'unvariable'];
        }
    },
    emoji: {
        type: Boolean,
        default: true
    }
});
​
const textData = ref({
    content: '',
});
​
const toolbarList = ref([
    {
        title: '客户昵称',
        template: '',
        icon: '@/assets/images/editor/toolbar/ic_fsnc.png',
        key:'nickname'
    },
    // 其余的可自行扩展
]);
​
/**
 * 顶部工具栏按钮点击
 */
const handleToolbarClick = (bartype: string) => {
    switch (bartype) {
        case 'nickname':
            insertNickname();
            break;
       // case 'link':
       //     openLinkModal();
       //     break;
       // case 'unvariable':
       //     insertUnvariable();
       //     break;
        default:
            console.log('不晓得点的啥子');
    }
};
// 插入客户昵称
const insertNickname = () => {
    insertHtml('<a class="yz-tag primary" contenteditable="false">粉丝昵称</a>');
};
​
// 光标离开记录光标位置
const saveCursor = () => {
    let selection = window.getSelection();
    range.value = selection!.getRangeAt(0);
};
​
// 公共方法,插入节点到编辑器,当用户从未手动点击编辑器(编辑器未获取到焦点)时设置焦点到文档末尾
const insertHtml = (data: any) => {
    if (range.value) {
        const textNode = parseHTML(data);
        range.value.insertNode(textNode);
        inputChange();
        setFocus();
    } else {
        messageInputDom.value.focus();
        let selection = window.getSelection();
        range.value = selection!.getRangeAt(0);
        insertHtml(data);
    }
};
​
// 将字符串转化为真实节点
const parseHTML = (htmlString: string): any => {
    const range = document.createRange();
    const fragment = range.createContextualFragment(htmlString);
    return fragment as any;
};
​
// 设置光标位置到最后
const setFocus = () => {
    let el = messageInputDom.value;
    let range = document.createRange();
    range.selectNodeContents(el);
    range.collapse(false);
    let selection = window.getSelection();
    selection!.removeAllRanges();
    selection!.addRange(range);
};
// 这里我的需求是转换成保留格式的纯文本,如果你们不需要domData就够用
const inputChange =debounce(() => {
    const domData = messageInputDom.value.innerHTML;
    console.log('%c [ 初始dom ]-276', 'font-size:13px; background:pink; color:#bf2c9f;', domData);
    // 将HTML实体转换为普通文本,防止特殊字符转译
    const plainText = decodeHtmlEntities(domData);
    let nHtml = plainText
        .replace(/<a [^>]+primary[^>]+>粉丝昵称</a *>/g, '%NICKNAME%')
        .replace(/<a [^>]+primary[^>]+>插入时间变量</a *>/g, '%TIME%')
        // 空p或div只含br的情况,转为对应数量的换行
        .replace(/<(p|div)>\s*((<br\s*/?>\s*)+)</\1>/gi, (m, tag, brs) => '\n'.repeat((brs.match(/<br/gi) || []).length))
        // 其他div或p,内容后加一个换行
        .replace(/<(p|div)[^>]*>([\s\S]*?)</\1>/gi, (m, tag, content) => {
            // 如果内容里已经有换行结尾,则不再加
            content = content.replace(/<br\s*/?>/gi, '\n');
            return content.endsWith('\n') ? content : content + '\n';
        })
            // 只去除非a标签,a标签保留
        .replace(/<(?!/?a(?=>|\s))[^>]+>/gi, '')
            // 去除末尾多余换行
        .replace(/\n+$/, '');
    textData.value.content = nHtml.trim();
    console.log(
        '%c [ 文本最终结果 ]-298',
        'font-size:13px; background:pink; color:#bf2c9f;',
        textData.value
    );
},100) ;
​
// 解码html实体
function decodeHtmlEntities(str: any) {
    const txt = document.createElement('textarea');
    txt.innerHTML = str;
    return txt.value;
}
​
// 粘贴设置防止xss攻击,我这里由于需求原因做了一下换行相关处理,保证从微信和word之类的复制过来格式不乱
const handlePaste = (e: any) => {
    e.preventDefault();
    const clipboardData = e.clipboardData || (window as any).clipboardData;
    const html = clipboardData.getData('text/html');
    let text = clipboardData.getData('text/plain') || '';
​
    if (html) {
        // 用 DOM 解析富文本,只保留文本内容和换行
        const pdom = document.createElement('div');
        pdom.innerHTML = html;
        // 获取带换行的纯文本
        text = getTextWithLineBreaks(pdom);
        // 去除首尾多余换行
        text = text.replace(/^\n+|\n+$/g, '');
        document.execCommand('insertText', false, text);
    } else {
        // 纯文本直接粘贴
        document.execCommand('insertText', false, text);
    }
};
​
// 保留文本和换行的辅助函数
function getTextWithLineBreaks(node: Node): string {
    let text = '';
    node.childNodes.forEach((child) => {
        if (child.nodeType === 3) {
            // 文本节点
            text += child.textContent || '';
        } else if (child.nodeType === 1) {
            // 元素节点
            const tag = (child as HTMLElement).tagName.toLowerCase();
            if (tag === 'br') {
                text += '\n';
            } else {
                // 递归获取子内容
                const childText = getTextWithLineBreaks(child);
​
                // 判断是否块级标签
                if (['p', 'div', 'li', 'tr'].includes(tag)) {
                    // 只包含br的情况
                    const onlyBr = Array.from(child.childNodes).every(
                        (n) => n.nodeType === 1 && (n as HTMLElement).tagName.toLowerCase() === 'br'
                    );
                    if (onlyBr && child.childNodes.length > 0) {
                        // 有几个br就加几个换行
                        text += '\n'.repeat(child.childNodes.length);
                    } else if (childText !== '') {
                        // 有内容,内容后加一个换行
                        text += childText + '\n';
                    } else {
                        // 空块级标签,加一个换行
                        text += '\n';
                    }
                } else {
                    text += childText;
                }
            }
        }
    });
    return text;
}
​
// 插入emoji
const insertEmoji = (v: any) => {
    insertHtml(v.emoji);
};
​
const initHtml = () => {
    messageInputDom.value.innerHTML = '<p><br></p>';
};
initHtml()
​

有个特殊的处理地点,如果按我上述代码,最后并不是直接调用initHtml,因为传给后端的是保留格式的纯文本,所以初始化的时候应该将文本转换成dom

javascript 复制代码
// 转换文本中的内容为编辑器显示的内容
const transVariable = () => {
    if (textData.value.content) {
        const arr = textData.value.content.split('\n');
        const showMsgBox = arr
            .map((item) => {
                return '<p>' + item + '</p>';
            })
            .join('')
            .replace(/<a[^>]*>[^<]*</a *>/g, function (o) {
                // createUniqueString我就不放了,实际是随机串生成
                let id = `_${createUniqueString()}`,
                    linktype = '';
                return o
                    .replace(/id="([^"]*)"/, function (t, idStr) {
                        linktype = idStr.split('_')[0];
                        if (linktype === 'link') {
                            id = `link_${id}`;
                        } else {
                            id = `mini_${id}`;
                        }
                        return `id="${id}" class="yz-tag has-edit ${linktype == 'link' ? 'info' : 'success'}" contenteditable="false"`;
                    })
                    .replace(/data-miniprogram-path="([^"]*)"/, function (t, pathStr) {
                        return `${t} href="${pathStr}"`;
                    })
                    .replace(/>([^<]*)</, function (t, text) {
                        return `>${text}<`;
                    });
            })
            .replace(
                /%NICKNAME%/g,
                '<a class="yz-tag primary" contenteditable="false">粉丝昵称</a>'
            )
            .replace(
                /%TIME%/g,
                '<a class="yz-tag primary" contenteditable="false">插入时间变量</a>'
            );
        messageInputDom.value.innerHTML = showMsgBox;
    } else {
        initHtml();
    }
};
// 我这里不使用nextick 是因为弹窗弹出有动画时间,做成了组件放弹窗中了,如果是不是弹窗或有延迟什么的不必我这样
setTimeout(() => {
    transVariable();
}, 100);

逻辑梳理

1、初始化页面,如果有入参则格式化(transVariable),如果没有则直接初始化(initHtml)

2、点击工具栏插入对应内容(handleToolbarClick)根据点击类型判断。代码中只有一个示例,根据实际情况添加,内容确认完成后确认插入节点到编辑器(insertHtml)。insertHtml是保证插入内容在指定位置,插入内容后光标定位至内容最后(setFocus)

3、当触发blur时记录当前鼠标位置,确保点击插入内容时在光标位置而不是在其他位置

4、当粘贴文本或富文本内容时进行格式化处理(handlePaste)

5、当输入内容时(inputChange)将dom格式化处理转换为需要的文字赋值给textData。加上防抖防止粘贴和插入时多次触发(防抖我用的lodash,大家也可以自己写一个,也不难)

6、点击表情时直接插入内容,表情组件很多我这里就不放了,不需要的也可以直接删除

1、内容最好还是做成组件,毕竟单内容来说不少了。作为组件时更改

ini 复制代码
const textData = defineModel({
    default: () => ({
        content: ''
    })
});
​
// 使用
<TextEditor
    :toolbar="toolbar"
    v-model="textData"
/>

2、表单验证是否需要自行添加哈

ini 复制代码
const textRef = ref()
const validateForm = () => {
    if (!textRef.value) return;
    return textRef.value.validate();
};
defineExpose({
    transVariable,
    setFocus,
    validateForm
});

完整代码

我自己的toolbarList是个对象,根据实际情况自行选择

xml 复制代码
<template>
    <div class="text-14 color-28" :class="{ 'mg-t-16': typeValue === 'text' }">
        <el-form :model="textData" ref="textRef" label-position="top" :rules="rules">
            <el-form-item label="文本消息内容:" prop="content">
                <el-input v-model="textData!.content" style="display: none" />
                <div class="text-editor" :class="{ 'mg-t-10': typeValue === 'text' }">
                    <div class="textEditor"></div>
                    <!-- 顶部工具栏 -->
                    <div class="toolbar flex align-center pd-lr-20 text-16">
                        <span v-if="props.toolbar.length > 0">插入:</span>
                        <div class="tools flex align-center mg-l-10 flex-sub text-14">
                            <template v-for="(item, name) in toolbarList">
                                <div
                                    class="tools-item flex align-center pointer"
                                    :key="name"
                                    :class="name"
                                    v-if="props.toolbar.indexOf(name) >= 0"
                                    @click="handleToolbarClick(name)"
                                >
                                    <el-icon v-if="name === 'nickname'"><User /></el-icon
                                    ><el-icon v-else><Link /></el-icon>{{ item.title }}
                                </div>
                            </template>
                        </div>
                    </div>
​
                    <!-- 编辑器主体部分 -->
                    <div class="main">
                        <div
                            id="message-input"
                            ref="messageInputDom"
                            class="mess-input"
                            contenteditable="true"
                            spellcheck="false"
                            @paste="handlePaste"
                            @blur="saveCursor"
                            @input="inputChange"
                        ></div>
                    </div>
                </div>
                <!-- 底部插入表情 -->
                <div class="emoji flex align-center" v-if="emoji">
                    点击插入:<emoji @insert="insertEmoji"></emoji>
                </div>
            </el-form-item>
        </el-form>
        <MainDialog v-model="dialogFormVisible" title="插入链接" align-center width="720">
            <div class="modalForm linkModal">
                <div class="modalForm-item">
                    <div class="modalForm-label">链接文本:</div>
                    <div class="modalForm-content">
                        <el-input
                            class="modalForm-input"
                            type="text"
                            placeholder="请输入链接显示文本"
                            v-model.trim="linkForm.name"
                        />
                    </div>
                </div>
                <div class="modalForm-item mg-t-20">
                    <div class="modalForm-label">链接地址:</div>
                    <div class="modalForm-content">
                        <el-input
                            class="modalForm-input"
                            type="text"
                            placeholder="请输入要插入的链接地址"
                            v-model.trim="linkForm.link"
                        />
                    </div>
                </div>
            </div>
            <template #footer>
                <div class="dialog-footer">
                    <el-button type="info" class="cancel margin-right-sm" @click="handleClose">
                        取消
                    </el-button>
                    <el-button type="primary" class="confirm" @click="handleConfirm">
                        确定
                    </el-button>
                </div>
            </template>
        </MainDialog>
    </div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue';
import MainDialog from '../../MainDialog/index.vue';
import { ElMessage, type FormRules } from 'element-plus';
import { urlReg } from '@/constant/reg';
import { createUniqueString } from '@/utils/util';
import Emoji from './emoji/index.vue';
import { debounce } from 'lodash';
​
const props = defineProps({
    modelValue: {
        type: Object,
        default: () => {
            return {
                content: ''
            };
        }
    },
    toolbar: {
        type: Array,
        default: () => {
            return ['nickname', 'link', 'miniprogram', 'variable', 'unvariable'];
        }
    },
    typeValue: {
        type: String,
        default: 'text'
    },
    toolbarList: {
        type: Object,
        default: () => {
            return {
                nickname: {
                    title: '客户昵称',
                    template: '',
                    icon: '@/assets/images/editor/toolbar/ic_fsnc.png'
                }
                // link: {
                //     title: '超链接',
                //     template: '',
                //     icon: '@/assets/images/editor/toolbar/ic_clj.png'
                // }
            };
        }
    },
    emoji: {
        type: Boolean,
        default: true
    }
});
const rules = reactive<FormRules>({
    content: [{ required: true, message: '请输入内容', trigger: ['blur', 'change'] }]
});
const messageInputDom = ref();
​
const range = ref();
const textRef = ref();
const dialogFormVisible = ref(false);
const linkForm = ref({
    name: '',
    link: ''
});
const textData = defineModel({
    default: () => ({
        content: ''
    })
});
​
/**
 * 顶部工具栏按钮点击
 */
const handleToolbarClick = (bartype: string) => {
    switch (bartype) {
        case 'nickname':
            insertNickname();
            break;
        case 'link':
            openLinkModal();
            break;
        case 'unvariable':
            insertUnvariable();
            break;
        default:
            console.log('不晓得点的啥子');
    }
};
// 记录光标位置
const saveCursor = () => {
    let selection = window.getSelection();
    range.value = selection!.getRangeAt(0);
};
// 插入客户昵称
const insertNickname = () => {
    insertHtml('<a class="yz-tag primary" contenteditable="false">粉丝昵称</a>');
};
// 插入时间变量
const insertUnvariable = () => {
    insertHtml('<a class="yz-tag primary" contenteditable="false">插入时间变量</a>');
};
// 打开链接弹窗
const openLinkModal = () => {
    dialogFormVisible.value = true;
};
// 关闭链接弹窗
const handleClose = () => {
    dialogFormVisible.value = false;
};
// 链接确认
const handleConfirm = () => {
    const errmsg = verifyLinkForm();
    if (errmsg) {
        return ElMessage(errmsg);
    }
    insertLink();
    handleClose();
};
const verifyLinkForm = () => {
    if (!linkForm.value.name) {
        return '请输入链接显示文本';
    }
    if (!linkForm.value.link) {
        return '请输入要插入的链接地址';
    }
    if (!urlReg.test(linkForm.value.link)) {
        return '请输入正确格式的链接地址!';
    }
    return;
};
// 新插入超链接
const insertLink = () => {
    const id = 'link_' + createUniqueString();
    insertHtml(
        '<a id="'
            .concat(id, '" href="')
            .concat(
                linkForm.value.link.includes('http') || linkForm.value.link.includes('weixin://')
                    ? linkForm.value.link
                    : 'http://' + linkForm.value.link,
                '" data-name="'
            )
            .concat(
                linkForm.value.name,
                '" class="yz-tag info has-edit" contenteditable="false" onclick="return false;">'
            )
            .concat(linkForm.value.name, '</a>')
    );
};
// 公共方法,插入节点到编辑器,当用户从未手动点击编辑器(编辑器未获取到焦点)时设置焦点到文档末尾
const insertHtml = (data: any) => {
    if (range.value) {
        const textNode = parseHTML(data);
        range.value.insertNode(textNode);
        inputChange();
        setFocus();
    } else {
        messageInputDom.value.focus();
        let selection = window.getSelection();
        range.value = selection!.getRangeAt(0);
        insertHtml(data);
    }
};
const initHtml = () => {
    messageInputDom.value.innerHTML = '<p><br></p>';
};
// 设置光标位置到最后
const setFocus = () => {
    let el = messageInputDom.value;
    let range = document.createRange();
    range.selectNodeContents(el);
    range.collapse(false);
    let selection = window.getSelection();
    selection!.removeAllRanges();
    selection!.addRange(range);
};
// 将字符串转化为真实节点
const parseHTML = (htmlString: string): any => {
    const range = document.createRange();
    const fragment = range.createContextualFragment(htmlString);
    return fragment as any;
};
// 插入emoji
const insertEmoji = (v: any) => {
    insertHtml(v.emoji);
};
// 解码html实体
function decodeHtmlEntities(str: any) {
    const txt = document.createElement('textarea');
    txt.innerHTML = str;
    return txt.value;
}
const inputChange =debounce(() => {
    const domData = messageInputDom.value.innerHTML;
    // 将HTML实体转换为普通文本,防止特殊字符转译
    const plainText = decodeHtmlEntities(domData);
    let nHtml = plainText
        .replace(/<a [^>]+primary[^>]+>粉丝昵称</a *>/g, '%NICKNAME%')
        .replace(/<a [^>]+primary[^>]+>插入时间变量</a *>/g, '%TIME%')
        // 空p或div只含br的情况,转为对应数量的换行
        .replace(/<(p|div)>\s*((<br\s*/?>\s*)+)</\1>/gi, (m, tag, brs) => '\n'.repeat((brs.match(/<br/gi) || []).length))
        // 其他div或p,内容后加一个换行
        .replace(/<(p|div)[^>]*>([\s\S]*?)</\1>/gi, (m, tag, content) => {
            // 如果内容里已经有换行结尾,则不再加
            content = content.replace(/<br\s*/?>/gi, '\n');
            return content.endsWith('\n') ? content : content + '\n';
        })
            // 只去除非a标签,a标签保留
        .replace(/<(?!/?a(?=>|\s))[^>]+>/gi, '')
            // 去除末尾多余换行
        .replace(/\n+$/, '');
    textData.value.content = nHtml.trim();
},100) ;
// 转换文本中的内容为编辑器显示的内容
const transVariable = () => {
    if (textData.value.content) {
        const arr = textData.value.content.split('\n');
        const showMsgBox = arr
            .map((item) => {
                return '<p>' + item + '</p>';
            })
            .join('')
            .replace(/<a[^>]*>[^<]*</a *>/g, function (o) {
                let id = `_${createUniqueString()}`,
                    linktype = '';
                return o
                    .replace(/id="([^"]*)"/, function (t, idStr) {
                        linktype = idStr.split('_')[0];
                        if (linktype === 'link') {
                            id = `link_${id}`;
                        } else {
                            id = `mini_${id}`;
                        }
                        return `id="${id}" class="yz-tag has-edit ${linktype == 'link' ? 'info' : 'success'}" contenteditable="false"`;
                    })
                    .replace(/data-miniprogram-path="([^"]*)"/, function (t, pathStr) {
                        return `${t} href="${pathStr}"`;
                    })
                    .replace(/>([^<]*)</, function (t, text) {
                        return `>${text}<`;
                    });
            })
            .replace(
                /%NICKNAME%/g,
                '<a class="yz-tag primary" contenteditable="false">粉丝昵称</a>'
            )
            .replace(
                /%TIME%/g,
                '<a class="yz-tag primary" contenteditable="false">插入时间变量</a>'
            );
        messageInputDom.value.innerHTML = showMsgBox;
    } else {
        initHtml();
    }
};
// 不能使用nextick 因为弹窗弹出有动画时间
setTimeout(() => {
    transVariable();
}, 100);
// 粘贴设置防止xss攻击
const handlePaste = (e: any) => {
    e.preventDefault();
    const clipboardData = e.clipboardData || (window as any).clipboardData;
    const html = clipboardData.getData('text/html');
    let text = clipboardData.getData('text/plain') || '';
​
    if (html) {
        // 用 DOM 解析富文本,只保留文本内容和换行
        const pdom = document.createElement('div');
        pdom.innerHTML = html;
        // 获取带换行的纯文本
        text = getTextWithLineBreaks(pdom);
        // 去除首尾多余换行
        text = text.replace(/^\n+|\n+$/g, '');
        document.execCommand('insertText', false, text);
    } else {
        // 纯文本直接粘贴
        document.execCommand('insertText', false, text);
    }
};
// 保留文本和换行的辅助函数
function getTextWithLineBreaks(node: Node): string {
    let text = '';
    node.childNodes.forEach((child) => {
        if (child.nodeType === 3) {
            // 文本节点
            text += child.textContent || '';
        } else if (child.nodeType === 1) {
            // 元素节点
            const tag = (child as HTMLElement).tagName.toLowerCase();
            if (tag === 'br') {
                text += '\n';
            } else {
                // 递归获取子内容
                const childText = getTextWithLineBreaks(child);
​
                // 判断是否块级标签
                if (['p', 'div', 'li', 'tr'].includes(tag)) {
                    // 只包含br的情况
                    const onlyBr = Array.from(child.childNodes).every(
                        (n) => n.nodeType === 1 && (n as HTMLElement).tagName.toLowerCase() === 'br'
                    );
                    if (onlyBr && child.childNodes.length > 0) {
                        // 有几个br就加几个换行
                        text += '\n'.repeat(child.childNodes.length);
                    } else if (childText !== '') {
                        // 有内容,内容后加一个换行
                        text += childText + '\n';
                    } else {
                        // 空块级标签,加一个换行
                        text += '\n';
                    }
                } else {
                    text += childText;
                }
            }
        }
    });
    return text;
}
const validateForm = () => {
    if (!textRef.value) return;
    return textRef.value.validate();
};
defineExpose({
    transVariable,
    setFocus,
    validateForm
});
</script>
<style lang="scss" scoped>
.toolbar {
    border: 1px solid #e5e5e5;
    background-color: #f8f8f8;
    height: 46px;
    border-bottom: none;
    .tools {
        height: 100%;
        &-item {
            height: 100%;
            &:not(:first-child) {
                margin-left: 20px;
            }
            &.nickname {
                color: #fd5451;
            }
            &.link {
                color: #67c23a;
            }
            &.miniprogram {
                color: #4e73ec;
            }
            &.variable {
                color: #686868;
            }
        }
    }
}
.main {
    border: 1px solid #e5e5e5;
    border-top: none;
}
.mess-input {
    padding: 8px;
    height: 255px;
    overflow: auto;
    word-break: break-all; // 允许长单词或符号在任意位置换行
    overflow-wrap: break-word; // 确保内容不会超出容器
    line-break: anywhere; // 允许在任何地方断行,包括全角符号
}
.modalForm {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    height: 396px;
    &.linkModal {
        padding: 0 56px;
    }
    &.miniModal {
        padding: 0 24px;
    }
    font-size: 16px;
    color: #282828;
    &-item {
        display: flex;
        align-items: center;
        justify-content: space-between;
        width: 100%;
    }
    &-label {
        font-weight: bold;
        width: 0;
        flex: 1;
        text-align: right;
        white-space: nowrap;
        word-break: keep-all;
    }
    &-content {
        margin-left: 20px;
        width: 460px;
        :deep(.el-select) {
            height: 48px;
            .el-input {
                &__inner {
                    height: 48px;
                    line-height: 48px;
                }
                &__icon {
                    line-height: 48px;
                }
            }
        }
    }
    &-input {
        width: 460px;
        height: 48px;
        box-sizing: border-box;
        outline: none;
        &::-webkit-input-placeholder {
            color: #999999;
        }
    }
    &-link {
        font-size: 14px;
    }
}
.emoji {
    font-size: 14px;
    margin-top: 16px;
}
.text-editor {
    width: 100%;
}
</style>
​
相关推荐
小小小小宇19 分钟前
前端模拟一个setTimeout
前端
萌萌哒草头将军23 分钟前
🚀🚀🚀 不要只知道 Vite 了,可以看看 Farm ,Rust 编写的快速且一致的打包工具
前端·vue.js·react.js
芝士加1 小时前
Playwright vs MidScene:自动化工具“双雄”谁更适合你?
前端·javascript
Carlos_sam2 小时前
OpenLayers:封装一个自定义罗盘控件
前端·javascript
前端南玖2 小时前
深入Vue3响应式:手写实现reactive与ref
前端·javascript·vue.js
wordbaby3 小时前
React Router 双重加载器机制:服务端 loader 与客户端 clientLoader 完整解析
前端·react.js
itslife3 小时前
Fiber 架构
前端·react.js
3Katrina3 小时前
妈妈再也不用担心我的课设了---Vibe Coding帮你实现期末课设!
前端·后端·设计
hubber3 小时前
一次 SPA 架构下的性能优化实践
前端
可乐只喝可乐4 小时前
从0到1构建一个Agent智能体
前端·typescript·agent