有趣的 contentEditable

我心飞翔 分类:javascript

以前在知乎看到一篇关于《一行代理可以做什么?》的回答:

有趣的 contentEditable

当时试了一下确实很好玩,于是每次都可以在妹子面前秀一波操作,在他们惊叹的目光中,我心里开心地笑了——嗯,又让一个不懂技术的人发现到了程序的美🐶,咳咳。

一直以来,我都觉得这个属性只是为了存在而存在的,然而在今天接到的需求之后,我发现这个感觉没什么用的属性竟然完美地解决了我的需求。

需求

需求很简单,在输入框里添加按钮就好了。这种功能一般用于邮件群发,这里的按钮“姓名”其实就是一个变量,后端应该要自动填充真实用户的姓名,然后再把邮件发给用户的。

有趣的 contentEditable

问题

这个需求乍一看感觉可以用 position: relative + position: absolute 来完成。但是细想就不太可能:按钮肯定会覆盖输入内容的,而且单单一个删除“姓名”按钮这个功能就很难做。

再说只用 <textarea> 也不可能实现,因为<textarea>里就不可能存在输入 button 的情况。

我想另一个可能就是以<div>为底,在<div>最后加一个宽度为1px的<input>,然后用双向绑定去实现添加按钮和修改文本功能,用这个1px宽度的<input>来实现 focus 和blur功能。但是感觉也特别难实现。

最后在一篇 stackoverflow 里找到了答案:Button inside TextArea in HTML

然后我又搜了一下看到了这个库:react-contenteditable

解决方案

看到 contentEditable 的时候还是有点震惊的,毕竟这个一直被我用来秀来秀去的属性竟然在这一天解决了我的问题。

这个库用起来也很有意思,使用函数组件的时候,它不像我们普通那里一个 value 一个 onChange 就搞定了,而它需要我们传一个 innerRef 来控制里面的文本。

function App() {
  const innerRef = useRef<HTMLElement>(null);
  const value = useRef<string>('');

  const onChange = (event: ContentEditableEvent) => {
    value.current = event.target.value;
  }

  const onAddButton = () => {
    if (!innerRef.current) {
      return;
    }
    innerRef.current.innerHTML += '&nbsp;<button contenteditable="false">姓名</button>&nbsp;'
  }

  return (
    <div className="App">
      <ContentEditable 
        style={{ border: '1px solid black', height: 100 }} 
        innerRef={innerRef} 
        html={value.current} 
        onChange={onChange} 
      />
      <button onClick={onAddButton}>添加姓名</button>
    </div>
  );
}
 

细看 react-contentEditable 源码

上面说到的使用 ref 来控制文本的变化让我好奇里面到底是怎么实现的,所以我把他的 github clone 了下来,发现这里面的实现确实不太简单。Github 在这里。

render 函数

因为我们使用 contentEditable 来实现输入输出,所以几乎任何元素都是可以的,因此,这个组件允许我们传入 tagName 来指定要以哪个元素为基底。

render() {
  const { tagName, html, innerRef, ...props } = this.props;

  return React.createElement(
    tagName || 'div',
    {
      ...props,
      ref: typeof innerRef === 'function' ? (current: HTMLElement) => {
        innerRef(current)
        this.el.current = current
      } : innerRef || this.el,
      onInput: this.emitChange,
      onBlur: this.props.onBlur || this.emitChange,
      onKeyUp: this.props.onKeyUp || this.emitChange,
      onKeyDown: this.props.onKeyDown || this.emitChange,
      contentEditable: !this.props.disabled,
      dangerouslySetInnerHTML: { __html: html }
    },
    this.props.children);
}
 

这里的 render 函数就是为了一个指定渲染哪个函数,同时绑定一些事件,是否开启 contentEditable 属性,并传入 props。

我们还观察到这里的值其实是通过 dangerouslySetInnerHTML: { __html: html } 来展示的。

那既然都是 dangerously 了,那我们当然就要想到去防止脚本注入了嘛,所以源码也对值进行 normalize 了:

function normalizeHtml(str: string): string {
  return str && str.replace(/&nbsp;|\u202F|\u00A0/g, ' ');
}
 

事件

还有一个值得注意的点是其实除了 <input><textarea> 之外,<div> 也是可以触发 onInput 事件的。

比如我自己也尝试实现了一下:

const VarInput: FC<IProps> = (props) => {
  const { value, tag, disabled, onInput, ref, ...restProps } = props;

  const innerRef = useRef(null);

  const curtRef = ref || innerRef;

  const emitChange = () => {
    const callbackValue: string = curtRef.current ? curtRef.current.innerHTML : '';

    onInput!(callbackValue);
  }

  const varInputProps = {
    ...restProps,
    ref: curtRef,
    contentEditable: !disabled,
    onInput: emitChange,
    dangerouslySetInnerHTML: { __html: value }
  }

  return createElement(tag || 'div', varInputProps);
}
 

使用的时候

function App() {
  const [value] = useState('');

  const onChange = (value: string) => {
    console.log(value); // 打印 value
  }

  return (
    <div className="App">
      <VarInput value={value} onInput={onChange} />
    </div>
  );
 

然而,当我只绑定 onChange 的时候却不会触发事件!所以,onInput 和 onChange 在这里是有区别的!

emitChange

这里的 onChange, onInput 等回调事件其实都是调用了 emitChange 函数:

  emitChange = (originalEvt: React.SyntheticEvent<any>) => {
    const el = this.getEl();
    if (!el) return;

    const html = el.innerHTML;
    if (this.props.onChange && html !== this.lastHtml) {
      // Clone event with Object.assign to avoid
      // "Cannot assign to read only property 'target' of object"
      const evt = Object.assign({}, originalEvt, {
        target: {
          value: html
        }
      });
      this.props.onChange(evt);
    }
    this.lastHtml = html;
  }
 

这里也很好理解,毕竟只是获取 innerHTML 并构造一个 event,再放到 onChange 里就完事了。简单。

componentDidUpdate

说实话上面的事件我自己也都实现了一次,但是有个问题我一直做不了,那就是我每次输入的时候,光标都会移到最前面!!!比如我输入 "hello",结果就会显示:"olleh",这是什么鬼?!

有趣的 contentEditable

用法:

function App() {
  const [value, setValue] = useState('');

  const onChange = (value: string) => {
    console.log(value);
    setValue(value)
  }

  return (
    <div className="App">
      <VarInput value={value} onInput={onChange} />
    </div>
  );
}
 

这个是因为我在 setValue 的时候,光标会移到最前面,回到源码,它也是考虑到了这一点的。他用了一个函数放在 componentDidUpdate 里处理了这种情况:

componentDidUpdate() {
  const el = this.getEl();
  if (!el) return;

  // Perhaps React (whose VDOM gets outdated because we often prevent
  // rerendering) did not update the DOM. So we update it manually now.
  if (this.props.html !== el.innerHTML) {
    el.innerHTML = this.props.html;
  }
  this.lastHtml = this.props.html;
  replaceCaret(el);
}

function replaceCaret(el: HTMLElement) {
  // Place the caret at the end of the element
  const target = document.createTextNode('');
  el.appendChild(target);
  // do not move caret if element was not focused
  const isTargetFocused = document.activeElement === el;
  if (target !== null && target.nodeValue !== null && isTargetFocused) {
    var sel = window.getSelection();
    if (sel !== null) {
      var range = document.createRange();
      range.setStart(target, target.nodeValue.length);
      range.collapse(true);
      sel.removeAllRanges();
      sel.addRange(range);
    }
    if (el instanceof HTMLElement) el.focus();
  }
}
 

这个函数确保了每次更新后光标都会移动到最后一个位置上,果然我想的还是太 naive 了。

shouldComponentUpdate

最后一个部分就是 shouldComponentUpdate 了,这里主要是做一些 props 是否改变来判断是否需要重新渲染组件而已,相信大家都会就不多做介绍了。

最后

下次秀这个属性的时候可以把这篇文章也给妹子看看,一起学习🐶(逃

(完)

回复

我来回复
  • 暂无回复内容