github.com/ngocphuongnb/tetua@v0.0.7-alpha/packages/editor/src/extensions/quick-edit/quick-edit.ts (about)

     1  import {
     2    Editor,
     3    posToDOMRect,
     4    isTextSelection,
     5    isNodeSelection,
     6  } from '@tiptap/core'
     7  import { EditorState, Plugin, PluginKey } from 'prosemirror-state'
     8  import { Node as ProsemirrorNode } from 'prosemirror-model'
     9  import { EditorView } from 'prosemirror-view'
    10  import tippy, { Instance, Props } from 'tippy.js'
    11  
    12  export interface QuickEditPluginProps {
    13    pluginKey: PluginKey | string,
    14    editor: Editor,
    15    tippyOptions?: Partial<Props>,
    16    shouldShow?: ((props: {
    17      editor: Editor,
    18      view: EditorView,
    19      state: EditorState,
    20      oldState?: EditorState,
    21      from: number,
    22      to: number,
    23    }) => boolean) | null,
    24  }
    25  
    26  export type QuickEditViewProps = QuickEditPluginProps & {
    27    view: EditorView,
    28  }
    29  
    30  export class QuickEditView {
    31    public editor: Editor
    32  
    33    public element: HTMLElement
    34  
    35    public view: EditorView
    36  
    37    public preventHide = false
    38  
    39    public tippy: Instance | undefined
    40  
    41    public tippyOptions?: Partial<Props>
    42  
    43    public shouldShow: Exclude<QuickEditPluginProps['shouldShow'], null> = ({
    44      view,
    45      state,
    46      from,
    47      to,
    48    }) => {
    49      const { doc, selection } = state
    50      const { empty } = selection
    51  
    52      // Sometime check for `empty` is not enough.
    53      // Doubleclick an empty paragraph returns a node size of 2.
    54      // So we check also for an empty text size.
    55      const isEmptyTextBlock = !doc.textBetween(from, to).length
    56        && isTextSelection(state.selection)
    57  
    58      if (
    59        !view.hasFocus()
    60        || empty
    61        || isEmptyTextBlock
    62      ) {
    63        return false
    64      }
    65  
    66      return true
    67    }
    68  
    69    constructor({
    70      editor,
    71      view,
    72      tippyOptions = {},
    73      shouldShow,
    74    }: QuickEditViewProps) {
    75      this.view = view
    76      this.editor = editor
    77      this.element = document.createElement('div')
    78      this.element.classList.add('mely-bubble');
    79  
    80      if (shouldShow) {
    81        this.shouldShow = shouldShow
    82      }
    83  
    84      this.element.addEventListener('mousedown', this.mousedownHandler, { capture: true })
    85      this.view.dom.addEventListener('dragstart', this.dragstartHandler)
    86      this.editor.on('focus', this.focusHandler)
    87      this.editor.on('blur', this.blurHandler)
    88      this.tippyOptions = tippyOptions
    89  
    90      // Detaches menu content from its current parent
    91      this.element.remove()
    92      this.element.style.visibility = 'visible'
    93    }
    94  
    95    mousedownHandler = () => {
    96      this.preventHide = true
    97    }
    98  
    99    dragstartHandler = () => {
   100      this.hide()
   101    }
   102  
   103    focusHandler = () => {
   104      // we use `setTimeout` to make sure `selection` is already updated
   105      setTimeout(() => this.update(this.editor.view))
   106    }
   107  
   108    blurHandler = ({ event }: { event: FocusEvent }) => {
   109      if (this.preventHide) {
   110        this.preventHide = false
   111  
   112        return
   113      }
   114  
   115      if (
   116        event?.relatedTarget
   117        && this.element.parentNode?.contains(event.relatedTarget as Node)
   118      ) {
   119        return
   120      }
   121  
   122      this.hide()
   123    }
   124  
   125    createTooltip() {
   126      const { element: editorElement } = this.editor.options
   127      const editorIsAttached = !!editorElement.parentElement
   128  
   129      if (this.tippy || !editorIsAttached) {
   130        return
   131      }
   132  
   133      this.tippy = tippy(editorElement, {
   134        duration: 0,
   135        getReferenceClientRect: null,
   136        content: this.element,
   137        interactive: true,
   138        arrow: true,
   139        trigger: 'manual',
   140        hideOnClick: 'toggle',
   141        theme: 'light',
   142        placement: 'top',
   143        // placement: 'auto',
   144        ...this.tippyOptions,
   145      })
   146  
   147      // maybe we have to hide tippy on its own blur event as well
   148      if (this.tippy.popper.firstChild) {
   149        (this.tippy.popper.firstChild as HTMLElement).addEventListener('blur', event => {
   150          this.blurHandler({ event })
   151        })
   152      }
   153    }
   154  
   155    showTippy(view: EditorView, from: number, to: number) {
   156      this.tippy?.setProps({
   157        getReferenceClientRect: () => {
   158          if (isNodeSelection(view.state.selection)) {
   159            const node = view.nodeDOM(from) as HTMLElement
   160  
   161            if (node) {
   162              return node.getBoundingClientRect()
   163            }
   164          }
   165  
   166          return posToDOMRect(view, from, to)
   167        },
   168      })
   169  
   170      this.show();
   171    }
   172  
   173    showLinkSettingPopup(view: EditorView) {
   174      const { state } = view;
   175      const { selection } = state;
   176      const { ranges } = selection;
   177      const linkMarkType = state.schema.marks.link;
   178      const position = selection.$from;
   179      const from = Math.min(...ranges.map(range => range.$from.pos));
   180      const to = Math.max(...ranges.map(range => range.$to.pos));
   181      let hasLinkMark = false;
   182      let selectionChildCount = 0;
   183      let linkMark: ProsemirrorNode = null;
   184  
   185      for (let i = 0; !hasLinkMark && i < ranges.length; i++) {
   186        let { $from, $to } = ranges[i];
   187        hasLinkMark = state.doc.rangeHasMark($from.pos, $to.pos, linkMarkType);
   188      }
   189  
   190      position.doc.nodesBetween(selection.from, selection.to, (node, pos) => {
   191        if (selection.from === pos) {
   192          linkMark = node;
   193        }
   194        selectionChildCount++;
   195        return true;
   196      });
   197  
   198      if (!hasLinkMark || !linkMark || selectionChildCount > 2) {
   199        return this.hide();
   200      }
   201  
   202      this.element.innerHTML = '';
   203      this.element.classList.add('mely-bubble-inline');
   204      const linkInputElement = document.createElement('input');
   205      const linkApplyElement = document.createElement('button');
   206      const linkUnsetElement = document.createElement('button');
   207      const selectionTo = selection.to;
   208  
   209      const setLink = () => {
   210        this.editor.chain().setLink({
   211          href: linkInputElement.value,
   212        }).run();
   213        this.editor.chain().focus(selectionTo).run();
   214        this.hide();
   215      }
   216  
   217      const unsetLink = () => {
   218        this.editor.chain().unsetLink().run();
   219        this.editor.chain().focus(selectionTo).run();
   220        this.hide();
   221      }
   222  
   223      const onSubmmitLink = (e: KeyboardEvent) => {
   224        if (e.key === 'Enter') {
   225          e.preventDefault();
   226          e.stopImmediatePropagation();
   227          setLink();
   228          return false;
   229        }
   230      }
   231  
   232  
   233      linkApplyElement.type = 'button';
   234      linkApplyElement.textContent = 'Apply';
   235      linkUnsetElement.type = 'button';
   236      linkUnsetElement.textContent = 'Unset';
   237  
   238      linkUnsetElement.addEventListener('click', unsetLink);
   239      linkApplyElement.addEventListener('click', setLink);
   240      linkInputElement.addEventListener('keyup', onSubmmitLink);
   241      linkInputElement.addEventListener('keydown', onSubmmitLink);
   242      linkInputElement.addEventListener('keypress', onSubmmitLink);
   243  
   244      this.element.append(linkInputElement, linkApplyElement, linkUnsetElement);
   245      linkInputElement.value = this.editor.getAttributes('link').href;
   246  
   247      this.showTippy(view, from, to);
   248      // setTimeout(() => linkInputElement.focus(), 0);
   249    }
   250  
   251    showImageSettingPopup(view: EditorView) {
   252      const { state } = view;
   253      const { selection } = state;
   254      const { ranges } = selection;
   255      const from = Math.min(...ranges.map(range => range.$from.pos));
   256      const to = Math.max(...ranges.map(range => range.$to.pos));
   257  
   258      if (!selection || !isNodeSelection(selection) || selection.empty || selection.node.type.name !== 'image' || !selection.node.attrs.src) {
   259        return;
   260      }
   261  
   262      const setImageAttrs = () => {
   263        this.editor.chain().focus().setImage({
   264          src: imgHref.value,
   265          alt: imgInfoAlt.value,
   266          title: imgInfoTitle.value,
   267        }).run();
   268      }
   269  
   270      const onSubmmitImageAttrs = (e: KeyboardEvent) => {
   271        if (e.key === 'Enter') {
   272          e.preventDefault();
   273          e.stopImmediatePropagation();
   274          setImageAttrs();
   275          return false;
   276        }
   277      }
   278  
   279      const imageAttrs = selection.node.attrs || {};
   280      this.element.innerHTML = '';
   281  
   282      const imgInfo = document.createElement('div');
   283      imgInfo.className = 'mely-editor-bubble-content';
   284  
   285      const imgHref = document.createElement('input');
   286      imgHref.value = imageAttrs.src || '';
   287  
   288      const imgInfoTitle = document.createElement('input');
   289      imgInfoTitle.setAttribute('placeholder', 'Title');
   290      imgInfoTitle.value = imageAttrs.title || '';
   291      imgInfoTitle.addEventListener('keyup', onSubmmitImageAttrs);
   292      imgInfoTitle.addEventListener('keydown', onSubmmitImageAttrs);
   293      imgInfoTitle.addEventListener('keypress', onSubmmitImageAttrs);
   294  
   295      const imgInfoAlt = document.createElement('input');
   296      imgInfoAlt.setAttribute('placeholder', 'Alt');
   297      imgInfoAlt.value = imageAttrs.alt || '';
   298      imgInfoAlt.addEventListener('keyup', onSubmmitImageAttrs);
   299      imgInfoAlt.addEventListener('keydown', onSubmmitImageAttrs);
   300      imgInfoAlt.addEventListener('keypress', onSubmmitImageAttrs);
   301  
   302      const action = document.createElement('div');
   303      action.className = 'mely-editor-bubble-inline-actions';
   304  
   305      const imageApplyElement = document.createElement('button');
   306      const imageUnsetElement = document.createElement('button');
   307      imageApplyElement.type = 'button';
   308      imageApplyElement.textContent = 'Apply';
   309      imageUnsetElement.type = 'button';
   310      imageUnsetElement.textContent = 'Remove';
   311  
   312      imageApplyElement.addEventListener('click', setImageAttrs);
   313      imageUnsetElement.addEventListener('click', () => {
   314  
   315        this.editor.chain().focus().deleteRange({ from, to }).run();
   316      });
   317  
   318      action.append(imageApplyElement, imageUnsetElement);
   319  
   320      imgInfo.append(imgHref, imgInfoTitle, imgInfoAlt, action);
   321  
   322      this.element.append(imgInfo);
   323      this.showTippy(view, from, to);
   324    }
   325  
   326    showIframeSettingPopup(view: EditorView) {
   327      const { state } = view;
   328      const { selection } = state;
   329      const { ranges } = selection;
   330      const from = Math.min(...ranges.map(range => range.$from.pos));
   331      const to = Math.max(...ranges.map(range => range.$to.pos));
   332  
   333      if (!selection || !isNodeSelection(selection) || selection.empty || selection.node.type.name !== 'iframe' || !selection.node.attrs.src) {
   334        return;
   335      }
   336  
   337      const setIframeAttrs = () => {
   338        this.editor.chain().focus().setIframe({
   339          src: iframeSrc.value,
   340          width: parseInt(iframeWidth.value),
   341          height: parseInt(iframeHeight.value),
   342        }).run();
   343      }
   344  
   345      const onSubmmitImageAttrs = (e: KeyboardEvent) => {
   346        if (e.key === 'Enter') {
   347          e.preventDefault();
   348          e.stopImmediatePropagation();
   349          setIframeAttrs();
   350          return false;
   351        }
   352      }
   353  
   354      const iframeAttrs = selection.node.attrs || {};
   355      this.element.innerHTML = '';
   356  
   357      const iframeInfo = document.createElement('div');
   358      iframeInfo.className = 'mely-editor-bubble-content';
   359  
   360      const iframeSrc = document.createElement('input');
   361      iframeSrc.value = iframeAttrs.src || '';
   362  
   363      const iframeWidth = document.createElement('input');
   364      iframeWidth.setAttribute('placeholder', 'Width');
   365      iframeWidth.value = iframeAttrs.width || '';
   366      iframeWidth.type = 'number';
   367      iframeWidth.addEventListener('keyup', onSubmmitImageAttrs);
   368      iframeWidth.addEventListener('keydown', onSubmmitImageAttrs);
   369      iframeWidth.addEventListener('keypress', onSubmmitImageAttrs);
   370  
   371      const iframeHeight = document.createElement('input');
   372      iframeHeight.setAttribute('placeholder', 'Height');
   373      iframeHeight.value = iframeAttrs.height || '';
   374      iframeHeight.type = 'number';
   375      iframeHeight.addEventListener('keyup', onSubmmitImageAttrs);
   376      iframeHeight.addEventListener('keydown', onSubmmitImageAttrs);
   377      iframeHeight.addEventListener('keypress', onSubmmitImageAttrs);
   378  
   379      const action = document.createElement('div');
   380      action.className = 'mely-editor-bubble-inline-actions';
   381  
   382      const imageApplyElement = document.createElement('button');
   383      const imageUnsetElement = document.createElement('button');
   384      imageApplyElement.type = 'button';
   385      imageApplyElement.textContent = 'Apply';
   386      imageUnsetElement.type = 'button';
   387      imageUnsetElement.textContent = 'Remove';
   388  
   389      imageApplyElement.addEventListener('click', setIframeAttrs);
   390      imageUnsetElement.addEventListener('click', () => {
   391        this.editor.chain().focus().deleteRange({ from, to }).run();
   392      });
   393  
   394      action.append(imageApplyElement, imageUnsetElement);
   395      iframeInfo.append(iframeSrc, iframeWidth, iframeHeight, action);
   396  
   397      this.element.append(iframeInfo);
   398      this.showTippy(view, from, to);
   399    }
   400  
   401    update(view: EditorView, oldState?: EditorState) {
   402      const { state, composing } = view;
   403      const { doc, selection } = state;
   404      const { ranges } = selection;
   405      const isSame = oldState && oldState.doc.eq(doc) && oldState.selection.eq(selection);
   406      const from = Math.min(...ranges.map(range => range.$from.pos));
   407      const to = Math.max(...ranges.map(range => range.$to.pos));
   408  
   409      if (composing || isSame) {
   410        return
   411      }
   412  
   413      this.createTooltip()
   414      const shouldShow = this.shouldShow?.({
   415        editor: this.editor,
   416        view,
   417        state,
   418        oldState,
   419        from,
   420        to,
   421      })
   422  
   423      if (!shouldShow) {
   424        return this.hide()
   425      }
   426  
   427      this.showLinkSettingPopup(view);
   428      this.showImageSettingPopup(view);
   429      this.showIframeSettingPopup(view);
   430    }
   431  
   432    show() {
   433      this.tippy?.show()
   434    }
   435  
   436    hide() {
   437      this.element.innerHTML = '';
   438      this.tippy?.hide()
   439    }
   440  
   441    destroy() {
   442      this.tippy?.destroy()
   443      this.element.removeEventListener('mousedown', this.mousedownHandler, { capture: true })
   444      this.view.dom.removeEventListener('dragstart', this.dragstartHandler)
   445      this.editor.off('focus', this.focusHandler)
   446      this.editor.off('blur', this.blurHandler)
   447    }
   448  }
   449  
   450  export const QuickEditPlugin = (options: QuickEditPluginProps) => {
   451    return new Plugin({
   452      key: typeof options.pluginKey === 'string'
   453        ? new PluginKey(options.pluginKey)
   454        : options.pluginKey,
   455      view: view => new QuickEditView({ view, ...options }),
   456    })
   457  }