有趣的 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",這是什麼鬼?!

用法:

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 是否改變來判斷是否需要重新渲染組件而已,相信大家都會就不多做介紹了。

最後

下次秀這個屬性的時候可以把這篇文章也給妹子看看,一起學習🐶(逃

(完)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章