用 contentEditable 快速封装一个组件,产品直呼:你这效果真令我欢喜!!!

扯皮

最近已经上手公司的业务了,但是整体难度不大,因为大部分的总结都是写在了内网语雀里,外加下班后回家被杨戬各种虐,“猴欢喜” 的台词听了有小几十遍🤐,所以好久没有在掘金上写过文章了😶

作为一名校招生进来接手的都是一些简单需求,能记录的还真不多,这不稍微有一点需要动脑子的咱就赶紧总结到掘金上🤪,废话不多说先简单阐述一下问题背景:

目前我这边接手的是一个关于 AI 的移动端项目,脱敏描述一下就是一个移动端的 GPT,底层 AI 相关的内容肯定都是由蚂蚁的大佬们负责,我一个小前端只是简单对接一下流式接口展示下问答界面😐

所以视图上就是一个聊天窗口,这里拿百度的的文心一言来看,整体样式都大差不差:

我们在用户输入的文本框中做了不少工作,比如用户可以输入一些特殊字符如 / 来唤起该平台的一些内置指令场景,以此方便用户更针对性的提问,可以看到文心一言也有类似的功能🤔:

只不过它这里是让用户自己创建,而我们是内置的,不管怎样从实现角度来看都只需要让文本框组件受控,监听用户输入内容展示对应的操作即可,所以这里针对于用户输入内容的处理堆积的逻辑还是很复杂的

直到有一天产品提出了这样的新需求,要实现大概这样的效果🤨:

嘶,好家伙🤔,好好的文本框直接升级成富文本了?文本框里还内置了其他表单组件?因此就有了这篇文章...

正文

需求思考

一开始只拿到了设计同学出的设计稿,简单来说就是需要内置几套 prompt 喂给 AI,只不过这里的 prompt 又允许用户手动配置一些内容罢了...

但看着内部封装的一个将近 1000 行的 TextAreaInput 屎山代码我陷入的沉思...🤔 内部实现的一个 / 快捷指令我看了下就有小几百行,而且完全属于硬编码没有考虑扩展性,现在依旧要在输入框上做文章,且又是新功能,那势必又要堆屎山了

除此之外,TextArea 真的能实现这样的效果么?这里有一个大大的问号,纯文本输入肯定是不可能了,所以第一时间想到的方案是内置这套模板结构将其定位到 TextArea 中,然后用户输入的内容根据该模板高度进行换行处理,想想都麻烦...

抛开 TextArea,社区中是否有可用的相关组件?不太行,说它是富文本也不完全是,调研了常见富文本编辑器都不太行,而且太过沉重,只是一个小需求没必要再引入一个第三方库解决,不过在调研过程中有注意到这样的一个属性:contentEditable

突然想到这个属性不是我第一次见了🤨,记得刚开始校招培训时要求实现一个 ToDoList,期间要实现一个点击 item 编辑对应文本并进行保存的效果,当时以为就是需要创建额外 state 控制编辑态,点击时将文本替换为一个 Input 进行编辑,之后再切回来...

后来看了眼我们培训讲师的实现,只是利用了 contentEditable 属性就轻易的实现了这样的效果,就是样式有些丑,但还是学到了...

因此考虑到这个需求本身没有过多的定制要求,虽然有些特殊但是较为简单,所以直接靠 contentEditable 手搓个这样的效果完全没问题,因为现在公司用的是 React 技术栈,所以后续文章的实现都会转向 React,开搞!

组件封装

确定组件 props 和样式搭建

既然要封装组件肯定要先确定其入口,这里简单起个名字就叫 ContenteditableInput

需要哪些 props?当然要看我们的需求啦,因为我们就是在 div 上设置对应的 contentEditable 属性,组件实质上就是个 div 套壳,所以先继承 div 上的属性下来:

 

ts

代码解读

复制代码

interface ContenteditableInputProps extends React.HTMLAttributes<HTMLDivElement> { // your props... }

为什么要先继承 div 的属性?因为原生 div 上也是能绑定一些输入表单相关事件的哦,有些属性就没必要再重复写上去了:

其次再来看我们这个需求都需要哪些相关的属性:

 

ts

代码解读

复制代码

interface ContenteditableInputProps extends React.HTMLAttributes<HTMLDivElement> { placeholder?: string; additionalKey?: string; // 问答模板标识 id(方便获取 DOM) additionalContent?: React.ReactNode; // 问答模板结构 onTextChange?: (text: string) => void; // 输入更新时对外抛出事件 }

前两个属性就不用说了,additionalContent 相关的就是我们要让用户传入的一些表单相关的内容模板

除此之外还需要向父组件提供一个 reset 方法,用来重置该组件为初始状态,具体等做到了再说:

 

ts

代码解读

复制代码

interface ContenteditableInputActionType { reset: () => void; } const ContenteditableInput = forwardRef<ContenteditableInputActionType, ContenteditableInputProps>((props, ref) => { const reset = () => { // ... // }; useImperativeHandle(ref, () => ({ reset, })); return <div {...props}></div>; }); export default ContenteditableInput;

基本属性确定了,现在开始来搭建基本样式,首先肯定要给 div 设置 contentEditable 属性,当然在 React 中如果只设置该属性,那么在 div 中设置初始值时会有以下警告:

React 官方文档给出了解决方式,设置 suppressContentEditableWarning 即可:

下面就简单来先来写写 CSS,给容器一个类名,补充如下 CSS 代码:

 

css

代码解读

复制代码

.editable-input-container { box-sizing: border-box; padding: 15px; width: 100%; height: 100px; border: 1px solid #d9d9d9; border-radius: 10px; transition: border 0.3s; overflow: auto; } .editable-input-container:focus { outline: none; /* key */ border-color: #1677ff; box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.1); }

其他属性没什么好说的,主要是这里聚焦时的 outline 需要设置为 none,因为只是开启 contentEditable 后聚焦会有这样的效果:

应该不会只有我一个人一开始觉得这丑到不行的边框是 border 吧😅?扒开 F12 看半天才发现是 outline,自己封装的话肯定把这里的边框换成自己的,这样就对胃了:

 

tsx

代码解读

复制代码

const ContenteditableInput = forwardRef<ContenteditableInputActionType, ContenteditableInputProps>((props, ref) => { // ... return ( <div {...props} contentEditable="true" suppressContentEditableWarning className={`editable-input-container ${className || ""}`} > hello </div> ); });

下面实现一下原生 div 没有的属性:placeholder,通常有两种解法:CSS、JS,这里我们肯定选择纯 CSS 的实现,JS 还需要监听额外事件并保存对应的聚焦状态,想想都费劲🤐

纯 CSS 的思路也很简单,既然 div 没有 placeholder 属性,那我们可以 data- 开头自定义属性,给它绑定对应的 placeholder 值:

 

tsx

代码解读

复制代码

const ContenteditableInput = forwardRef<ContenteditableInputActionType, ContenteditableInputProps>((props, ref) => { // new const { className, placeholder, ...otherProps } = props; // ... return ( <div {...otherProps} contentEditable="true" suppressContentEditableWarning className={`editable-input-container ${className || ""}`} // new data-placeholder={placeholder || "请输入..."} > hello </div> ); });

下面就是 CSS 的表演了,通过伪类 + 伪元素的方式,在其 empty 状态下设置对应的 before 伪元素即可,至于 content 可以直接通过 attr() 获取我们上面的自定义属性:

 

css

代码解读

复制代码

.editable-input-container:empty::before { content: attr(data-placeholder); color: #bfbfbf; }

关于 attr 可以自行查阅 MDN 文档,实现的 placeholder 效果还是不错的:

额外内容配置

下面就要针对于这次需求的核心功能:初始内容配置,因为在 div 中可以包裹任何元素,就包括我们最初展示的表单:

 

html

代码解读

复制代码

<div {...props} contentEditable="true" suppressContentEditableWarning className={`editable-input-container ${className || ""}`} data-placeholder={placeholder || "请输入..."} > <Input placeholder="输入名称..." style={{ width: "200px" }} /> </div>

但问题来了,这里的表单状态组件内该怎么维护呢🤔?毕竟用户可以传入任意表单模板,甚至还包含其他文字。想想既然是用户定制化的,就全交给用户好了,想受控受控,爱咋咋地🙃,我们一开始的 additionalContent 就是干这个的,不过这里最好给一个 <br /> 来进行换行,以此来区分出配置内容和输入内容:

 

tsx

代码解读

复制代码

const ContenteditableInput = forwardRef<ContenteditableInputActionType, ContenteditableInputProps>((props, ref) => { const { className, placeholder, additionalContent, ...otherProps } = props; // ... return ( <div {...otherProps} contentEditable="true" suppressContentEditableWarning className={`editable-input-container ${className || ""}`} data-placeholder={placeholder || "请输入..."} > {/* new */} {additionalContent} {additionalContent && <br />} </div> ); });

现在再在父组件中使用就能看到想要的效果了:

 

tsx

代码解读

复制代码

const CustomTemplate = () => { return ( <div> <span>请选择使用的框架:</span> <Select style={{ width: "100px" }} placeholder="请选择" />,<span>使用的状态管理库:</span> <Select style={{ width: "100px" }} placeholder="请选择" /> ,请根据以上内容生成。 </div> ); }; function App() { return ( <div style={{ padding: "15px" }}> <ContenteditableInput style={{ height: "150px" }} additionalContent={<CustomTemplate />} /> </div> ); }

只有效果可不行,怎么拿到用户输入的内容呢?可以监听 input 事件,通过 DOM 的 textCotent 来获取内容:

 

tsx

代码解读

复制代码

<div {...otherProps} contentEditable="true" suppressContentEditableWarning className={`editable-input-container ${className || ""}`} data-placeholder={placeholder || "请输入..."} onInput={(e: any) => { console.log("e:", e.target.textContent); }} > {/* new */} {additionalContent} {additionalContent && <br />} </div>

感觉不太对劲🤔,这把用户初始配置的内容都携带上了,还有表单的 placeholder...🤣

说好了针对于用户配置的内容让他们自己玩去,我们内部肯定要把这里的内容排除的,那就需要换个思路了,需要用户给我们提供一些信息辅助我们排除,那就是 additionalKey 属性

需要用户将配置的模板 DOM id 传递过来,这样我们内部就能够通过获取 DOM 的方式进行处理:

 

tsx

代码解读

复制代码

const ContenteditableInput = forwardRef<ContenteditableInputActionType, ContenteditableInputProps>((props, ref) => { const { className, placeholder, additionalContent, additionalKey, onTextChange, ...otherProps } = props; // new const inputDomRef = useRef<HTMLDivElement>(null); // ... return ( <div {...otherProps} ref={inputDomRef} contentEditable="true" suppressContentEditableWarning className={`editable-input-container ${className || ""}`} data-placeholder={placeholder || "请输入..."} onInput={(e: any) => { // new let text = ""; if (additionalKey) { const children = inputDomRef.current!.childNodes; children.forEach((item: any) => { if (item.id !== additionalKey) { text += item.textContent; } }); } else { text = e.target.textContent; } onTextChange?.(text); }} > {additionalContent} {additionalContent && <br />} </div> ); });

思路很简单,就是通过拿到我们的 div DOM 获取其孩子节点,输入内容时进行遍历与传入的 id 进行比较排除即可,来看父组件和效果:

 

tsx

代码解读

复制代码

const CustomTemplate = () => { return ( <div id="Template"> <span>请选择使用的框架:</span> <Select style={{ width: "100px" }} placeholder="请选择" />,<span>使用的状态管理库:</span> <Select style={{ width: "100px" }} placeholder="请选择" /> ,请根据以上内容生成。 </div> ); }; function App() { return ( <div style={{ padding: "15px" }}> <ContenteditableInput style={{ height: "150px" }} additionalContent={<CustomTemplate />} additionalKey="Template" onTextChange={(text) => { console.log("text:", text); }} /> </div> ); }

重置功能

contentEditable 允许我们编辑内部的任何内容,也可以直接编辑配置的内容,这显然不是我们想要的:

好像也没什么办法去阻止,或许可以通过监听其他事件来特殊判断🤔,但耗时又耗力,跟产品 battle 可以折中一下,给用户提供一个重置操作,点击按钮后可以恢复原来初始状态,唉这就简单啦,那就是我们最开始定义的 reset 方法

实际上就是当用户点击按钮时需要强制刷新一下组件,那内部定义一个状态来触发 dispatch 就好了:

 

tsx

代码解读

复制代码

const ContenteditableInput = forwardRef<ContenteditableInputActionType, ContenteditableInputProps>((props, ref) => { const { className, placeholder, additionalContent, additionalKey, onTextChange, ...otherProps } = props; // new const [, update] = useState(0); const inputDomRef = useRef<HTMLDivElement>(null); console.log("render"); // new const reset = () => { update((pre) => pre + 1); }; useImperativeHandle(ref, () => ({ reset, })); // ...省略部分属性 return ( <div {...otherProps} ref={inputDomRef} contentEditable="true" suppressContentEditableWarning > {additionalContent} {additionalContent && <br />} </div> ); }); // 父组件 function App() { // new const inputDomRef = useRef<React.ComponentRef<typeof ContenteditableInput>>(null); return ( <div style={{ padding: "15px" }}> <ContenteditableInput ref={inputDomRef} style={{ height: "150px" }} additionalContent={<CustomTemplate />} additionalKey="template" /> <Button type="primary" onClick={() => { inputDomRef.current?.reset(); }} > 重置 </Button> </div> ); }

好像没有生效啊🤔,点击按钮右侧日志打印确实更新组件了,但是内容没有还原:

检查看右侧 DOM 元素好像都没有刷新:

这时候就要思考一个问题,最外层的 div 进行重置肯定是在 diff 算法的过程中给排除掉了,现在我想要每次组件更新时也把 div 给强制更新了,该怎么做呢?🤔

回想 diff 算法比对的依据,无论是 Vue 还是 React 都会以 key 属性作为唯一标识,即便这里的 key 属性主要使用在循环遍历中设置,但是也可以放在单一元素上,我们这样设置再来看看效果:

 

tsx

代码解读

复制代码

const ContenteditableInput = forwardRef<ContenteditableInputActionType, ContenteditableInputProps>((props, ref) => { const { className, placeholder, additionalContent, additionalKey, onTextChange, ...otherProps } = props; // new const [key, update] = useState(0); const inputDomRef = useRef<HTMLDivElement>(null); const reset = () => { update((pre) => pre + 1); }; useImperativeHandle(ref, () => ({ reset, })); // ...省略部分属性 return ( <div {...otherProps} ref={inputDomRef} // new key={key} contentEditable="true" suppressContentEditableWarning > {additionalContent} {additionalContent && <br />} </div> ); });

符合预期🤩!!!强制更改 key 属性来达到触发 diff 更新元素的目的,结束

End

整个组件实现起来还是比较简单的,核心就是 contentEditable 属性,但实际以上实现还有很多缺陷,比如删除初始内容再输入时会将输入的内容保持在初始 DOM 元素中而不是创建新元素,以及删除初始内容的过程中针对于表单项的删除十分怪异,后来跟产品沟通了一下都还可以接受就没有再去优化

从开始调研发现不少富文本编辑器都是基于 contentEditable 属性就说明可玩性极强,涉及到编辑器的内容都属于是大坑,所以有现成的还是直接用吧,不过像以上比较小的定制需求还是可以自己手搓看看的

源码如下:

 

tsx

代码解读

复制代码

import { forwardRef, useImperativeHandle, useRef, useState } from "react"; import "./index.css"; interface ContenteditableInputProps extends React.HTMLAttributes<HTMLDivElement> { placeholder?: string; additionalKey?: string; // 问答模板标识 id(方便获取 DOM) additionalContent?: React.ReactNode; // 问答模板结构 onTextChange?: (text: string) => void; // 输入更新时对外抛出事件(问答) } interface ContenteditableInputActionType { reset: () => void; } const ContenteditableInput = forwardRef<ContenteditableInputActionType, ContenteditableInputProps>((props, ref) => { const { className, placeholder, additionalContent, additionalKey, onTextChange, ...otherProps } = props; const [key, update] = useState(0); const inputDomRef = useRef<HTMLDivElement>(null); const reset = () => { update((pre) => pre + 1); }; useImperativeHandle(ref, () => ({ reset, })); return ( <div {...otherProps} ref={inputDomRef} key={key} contentEditable="true" suppressContentEditableWarning className={`editable-input-container ${className || ""}`} data-placeholder={placeholder || "请输入..."} onInput={(e: any) => { let text = ""; if (additionalKey) { const children = inputDomRef.current!.childNodes; children.forEach((item: any) => { if (item.id !== additionalKey) { text += item.textContent; } }); } else { text = e.target.textContent; } onTextChange?.(text); }} > {additionalContent} {additionalContent && <br />} </div> ); }); export default ContenteditableInput;

 

css

代码解读

复制代码

.editable-input-container { box-sizing: border-box; padding: 15px; width: 100%; border: 1px solid #d9d9d9; border-radius: 10px; transition: border 0.3s; overflow: auto; } .editable-input-container:focus { outline: none; border-color: #1677ff; box-shadow: 0 0 0 2px rgba(5, 145, 255, 0.1); } .editable-input-container:empty::before { content: attr(data-placeholder); color: #bfbfbf; }

原文链接:https://juejin.cn/post/7418413333622014015

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值