aiao-io/aiao

View on GitHub
libs/elements/src/lib/components/text-editor/text-editor.tsx

Summary

Maintainability
A
3 hrs
Test Coverage
import { renderHiddenInput } from '@aiao/elements-cdk';
import { Component, Element, Event, EventEmitter, h, Host, Method, Prop, State, Watch } from '@stencil/core';

import { InputChangeEventDetail } from '../../interfaces/input.interface';
import { docGetSelection, getSelectionElements, restoreRange, saveRange } from '../../utils/selection';
import { TextActionState } from '../text-editor-bar/text-editor-bar.interface';
import { TextEditorAcitons as TA } from './text-editor.interface';

const IGNORE_ACTIONS = [TA.heading, TA.undo, TA.redo, TA.createLink, TA.indent, TA.outdent, TA.quote];
let richTextEditorInputId = 0;
// https://developer.mozilla.org/zh-CN/docs/Web/API/Document/execCommand

@Component({
  tag: 'aiao-text-editor',
  styleUrl: 'text-editor.scss',
  shadow: true
})
export class RichTextEditor {
  @Element() el!: HTMLElement;
  private range?: Range;
  private _lastElement?: HTMLElement;
  private inputId = `aiao-text-editor:${richTextEditorInputId++}`;

  // --------------------------------------------------------------[ Event ]

  /**
   * 值改变
   */
  @Event() aiaoChange!: EventEmitter<InputChangeEventDetail>;
  @Event() aiaoStateChange!: EventEmitter<TextActionState>;

  // --------------------------------------------------------------[ State ]

  @State() editable = false;
  @State() _state: TextActionState = {};

  // --------------------------------------------------------------[ Prop ]

  /**
   * 显示命令条
   */
  @Prop() showActionBar = true;
  /**
   * 段落符
   */
  @Prop() defaultParagraphSeparator = 'p';
  /**
   * 禁用
   */
  @Prop() disabled = false;
  /**
   * 绑定的 dom 元素
   */
  @Prop() element?: HTMLElement;
  /**
   * form name
   */
  @Prop() name = this.inputId;
  /**
   * form value
   */
  @Prop({ mutable: true }) value = '';

  // --------------------------------------------------------------[ Watch ]
  @Watch('element')
  elementChanged(value?: HTMLElement) {
    if (this._lastElement !== value && value) {
      if (this._lastElement) {
        this._lastElement.removeEventListener('keyup', this.statChangeHander);
        this._lastElement.removeEventListener('mouseup', this.statChangeHander);
        this._lastElement.removeEventListener('input', this.onInput);
        this._lastElement.removeEventListener('focus', this.onFocus);
        this._lastElement.removeEventListener('blur', this.onBlur);
      }
      this._lastElement = value;
      this._lastElement.addEventListener('keyup', this.statChangeHander);
      this._lastElement.addEventListener('mouseup', this.statChangeHander);
      this._lastElement.addEventListener('input', this.onInput);
      this._lastElement.addEventListener('focus', this.onFocus);
      this._lastElement.addEventListener('blur', this.onBlur);
      this._lastElement.contentEditable = 'true';
    }
  }
  //

  // --------------------------------------------------------------[ public function ]

  /**
   * 得到选中的标签
   */
  @Method()
  async getSelectionElements() {
    return getSelectionElements(document);
  }

  /**
   * 记录选择位置
   */
  @Method()
  async saveSelection() {
    this._saveSelection();
  }

  /**
   * 恢复选择位置
   */
  @Method()
  async restoreSelection() {
    this._restoreSelection();
  }

  async action(
    action:
      | TA.bold
      | TA.indent
      | TA.insertHorizontalRule
      | TA.insertOrderedList
      | TA.insertUnorderedList
      | TA.italic
      | TA.redo
      | TA.strikeThrough
      | TA.underline
      | TA.undo
      | TA.unlink
      | TA.outdent
      | TA.justifyCenter
      | TA.justifyFull
      | TA.justifyLeft
      | TA.justifyRight
  ): Promise<any>;
  async action(
    action: TA.foreColor | TA.createLink | TA.foreColor | TA.insertHTML | TA.insertImage,
    value: string
  ): Promise<any>;
  async action(action: TA.fontSize | TA.heading, value: number): Promise<any>;
  /**
   * 执行命令
   * @param action 命令
   * @param value 值
   */
  @Method()
  async action(action: TA, value?: any) {
    switch (action) {
      case TA.bold:
      case TA.indent:
      case TA.insertHorizontalRule:
      case TA.insertOrderedList:
      case TA.insertUnorderedList:
      case TA.italic:
      case TA.justifyCenter:
      case TA.justifyFull:
      case TA.justifyLeft:
      case TA.justifyRight:
      case TA.outdent:
      case TA.redo:
      case TA.strikeThrough:
      case TA.underline:
      case TA.undo:
      case TA.unlink:
        this.exec(action);
        break;
      case TA.backColor:
      case TA.createLink:
      case TA.foreColor:
      case TA.insertHTML:
      case TA.insertImage:
        this.exec(action, (value || '').trim());
        break;
      case TA.fontSize:
        this.exec(action, value);
        break;
      case TA.heading:
        this.exec('formatBlock', `h${value}`);
        break;
      case TA.paragraph:
      case TA.quote:
        const { insertOrderedList, insertUnorderedList } = this._state;
        if (!(insertOrderedList || insertUnorderedList)) {
          if (action === TA.paragraph) {
            this.exec('formatBlock', value || this.paragraphSeparator);
          } else {
            this.exec('formatBlock', 'blockquote');
          }
        }
        break;
      default:
        break;
    }
    this.statChangeHander();
  }

  // --------------------------------------------------------------[ Listen ]

  onInput = () => {
    this.checkEle();
    this.value = this._lastElement!.innerHTML;
    this.aiaoChange.emit({ value: this.value });
  };

  onFocus = (_: Event) => {
    //
  };

  onBlur = (_: Event) => {
    //
  };

  // --------------------------------------------------------------[ public function ]

  private _restoreSelection() {
    if (this.range) {
      restoreRange(document, this.range);
    }
  }
  private _saveSelection() {
    this.range = saveRange(document);
  }

  // --------------------------------------------------------------[ event Handler ]

  private onAction = (event: CustomEvent<any>) => {
    const { action, value } = event.detail;
    this.action(action, value);
  };

  // --------------------------------------------------------------[ private function ]
  private statChangeHander = () => {
    this._state = this.getState();
    this.aiaoStateChange.emit(this._state);
  };

  private get paragraphSeparator() {
    return this.defaultParagraphSeparator || 'p';
  }

  // 检测元素, 保证文本使用 p 标签
  private checkEle() {
    if (this._lastElement?.firstChild && this._lastElement.firstChild.nodeType === 3) {
      this.exec('formatBlock', this.paragraphSeparator);
    }
  }

  private getSelectionAttributeValue(attribute: string) {
    let selection = docGetSelection(document);
    if (this._lastElement) {
      selection = docGetSelection(this._lastElement.ownerDocument);
    }
    if (selection.rangeCount < 1) {
      return '';
    }
    const range = selection.getRangeAt(0);
    const pe = range.commonAncestorContainer.parentElement;
    return pe ? pe.getAttribute(attribute) : '';
  }

  private getState() {
    const backState: TextActionState = {};
    const fb = this.queryCommandValue('formatBlock');
    if (fb === 'blockquote') {
      backState[TA.quote] = true;
    }
    // heading
    const heading = /^h(?<heading>\d+)$/i.exec(fb)?.groups?.heading;
    if (heading) {
      backState[TA.heading] = +heading;
    }

    // link
    const href = this.getSelectionAttributeValue('href');
    if (href) {
      backState.createLink = href;
    }

    Object.keys(TA)
      .filter(key => IGNORE_ACTIONS.includes(key as any) === false)
      .forEach(key => {
        let val: any = this.queryCommandValue(key);
        if (val === 'false' || val === '') {
          return;
        }
        if (val === 'true') {
          val = true;
        }
        backState[key] = val;
      });

    if (backState.fontSize) {
      backState.fontSize = +backState.fontSize;
    }

    return backState;
  }

  private queryCommandValue(commandId: string) {
    return document.queryCommandValue(commandId);
  }

  private exec(commandId: any, value?: any) {
    return document.execCommand(commandId, true, value);
  }

  // --------------------------------------------------------------[ lifecycle ]

  componentDidLoad() {
    const ele = this.element || this._lastElement;
    if (ele) {
      this.elementChanged(ele);
      ele.innerHTML = this.value || '';
    }
  }

  render() {
    renderHiddenInput(true, this.el, this.name, this.value, this.disabled);
    const cls = {
      'inline-element': !this.element
    };
    return (
      <Host class={cls}>
        {this.showActionBar && (
          <aiao-text-editor-bar actionState={this._state} onAction={this.onAction}></aiao-text-editor-bar>
        )}
        {!this.element && <div class="element" ref={e => this.elementChanged(e)}></div>}
      </Host>
    );
  }
}