naive-ui
Version: 
A Vue 3 Component Library. Fairly Complete, Theme Customizable, Uses TypeScript, Fast
456 lines • 14.4 kB
JavaScript
import { createTreeMate } from 'treemate';
import { useIsMounted, useMergedState } from 'vooks';
import { computed, defineComponent, h, nextTick, ref, toRef, Transition } from 'vue';
import { VBinder, VFollower, VTarget } from 'vueuc';
import { NInternalSelectMenu } from "../../_internal/index.mjs";
import { useConfig, useFormItem, useTheme, useThemeClass } from "../../_mixins/index.mjs";
import { call, useAdjustedTo, warn } from "../../_utils/index.mjs";
import { NInput } from "../../input/index.mjs";
import { mentionLight } from "../styles/index.mjs";
import style from "./styles/index.cssr.mjs";
import { getRelativePosition } from "./utils.mjs";
export const mentionProps = Object.assign(Object.assign({}, useTheme.props), {
  to: useAdjustedTo.propTo,
  autosize: [Boolean, Object],
  options: {
    type: Array,
    default: []
  },
  filter: {
    type: Function,
    default: (pattern, option) => {
      if (!pattern) return true;
      if (typeof option.label === 'string') {
        return option.label.startsWith(pattern);
      }
      if (typeof option.value === 'string') {
        return option.value.startsWith(pattern);
      }
      return false;
    }
  },
  type: {
    type: String,
    default: 'text'
  },
  separator: {
    type: String,
    validator: separator => {
      if (separator.length !== 1) {
        warn('mention', '`separator`\'s length must be 1.');
        return false;
      }
      return true;
    },
    default: ' '
  },
  bordered: {
    type: Boolean,
    default: undefined
  },
  disabled: Boolean,
  value: String,
  defaultValue: {
    type: String,
    default: ''
  },
  loading: Boolean,
  prefix: {
    type: [String, Array],
    default: '@'
  },
  placeholder: {
    type: String,
    default: ''
  },
  placement: {
    type: String,
    default: 'bottom-start'
  },
  size: String,
  renderLabel: Function,
  status: String,
  'onUpdate:show': [Array, Function],
  onUpdateShow: [Array, Function],
  'onUpdate:value': [Array, Function],
  onUpdateValue: [Array, Function],
  onSearch: Function,
  onSelect: Function,
  onFocus: Function,
  onBlur: Function,
  // private
  internalDebug: Boolean
});
export default defineComponent({
  name: 'Mention',
  props: mentionProps,
  slots: Object,
  setup(props) {
    const {
      namespaceRef,
      mergedClsPrefixRef,
      mergedBorderedRef,
      inlineThemeDisabled
    } = useConfig(props);
    const themeRef = useTheme('Mention', '-mention', style, mentionLight, props, mergedClsPrefixRef);
    const formItem = useFormItem(props);
    const inputInstRef = ref(null);
    const cursorRef = ref(null);
    const followerRef = ref(null);
    const wrapperElRef = ref(null);
    const partialPatternRef = ref('');
    let cachedPrefix = null;
    // cached pattern end is for partial pattern
    // for example @abc|def
    // end is after `c`
    let cachedPartialPatternStart = null;
    let cachedPartialPatternEnd = null;
    const filteredOptionsRef = computed(() => {
      const {
        value: pattern
      } = partialPatternRef;
      return props.options.filter(option => props.filter(pattern, option));
    });
    const treeMateRef = computed(() => {
      return createTreeMate(filteredOptionsRef.value, {
        getKey: v => {
          return v.value;
        }
      });
    });
    const selectMenuInstRef = ref(null);
    const showMenuRef = ref(false);
    const uncontrolledValueRef = ref(props.defaultValue);
    const controlledValueRef = toRef(props, 'value');
    const mergedValueRef = useMergedState(controlledValueRef, uncontrolledValueRef);
    const cssVarsRef = computed(() => {
      const {
        self: {
          menuBoxShadow
        }
      } = themeRef.value;
      return {
        '--n-menu-box-shadow': menuBoxShadow
      };
    });
    const themeClassHandle = inlineThemeDisabled ? useThemeClass('mention', undefined, cssVarsRef, props) : undefined;
    function doUpdateShowMenu(show) {
      if (props.disabled) return;
      const {
        onUpdateShow,
        'onUpdate:show': _onUpdateShow
      } = props;
      if (onUpdateShow) call(onUpdateShow, show);
      if (_onUpdateShow) call(_onUpdateShow, show);
      if (!show) {
        cachedPrefix = null;
        cachedPartialPatternStart = null;
        cachedPartialPatternEnd = null;
      }
      showMenuRef.value = show;
    }
    function doUpdateValue(value) {
      const {
        onUpdateValue,
        'onUpdate:value': _onUpdateValue
      } = props;
      const {
        nTriggerFormChange,
        nTriggerFormInput
      } = formItem;
      if (_onUpdateValue) {
        call(_onUpdateValue, value);
      }
      if (onUpdateValue) {
        call(onUpdateValue, value);
      }
      nTriggerFormInput();
      nTriggerFormChange();
      uncontrolledValueRef.value = value;
    }
    function getInputEl() {
      return props.type === 'text' ? inputInstRef.value.inputElRef : inputInstRef.value.textareaElRef;
    }
    function deriveShowMenu() {
      var _a;
      const inputEl = getInputEl();
      if (document.activeElement !== inputEl) {
        doUpdateShowMenu(false);
        return;
      }
      const {
        selectionEnd
      } = inputEl;
      if (selectionEnd === null) {
        doUpdateShowMenu(false);
        return;
      }
      const inputValue = inputEl.value;
      const {
        separator
      } = props;
      const {
        prefix
      } = props;
      const prefixArray = typeof prefix === 'string' ? [prefix] : prefix;
      for (let i = selectionEnd - 1; i >= 0; --i) {
        const char = inputValue[i];
        if (char === separator || char === '\n' || char === '\r') {
          doUpdateShowMenu(false);
          return;
        }
        if (prefixArray.includes(char)) {
          const partialPattern = inputValue.slice(i + 1, selectionEnd);
          doUpdateShowMenu(true);
          (_a = props.onSearch) === null || _a === void 0 ? void 0 : _a.call(props, partialPattern, char);
          partialPatternRef.value = partialPattern;
          cachedPrefix = char;
          cachedPartialPatternStart = i + 1;
          cachedPartialPatternEnd = selectionEnd;
          return;
        }
      }
      doUpdateShowMenu(false);
    }
    function syncCursor() {
      const {
        value: cursorAnchor
      } = cursorRef;
      if (!cursorAnchor) return;
      const inputEl = getInputEl();
      const cursorPos = getRelativePosition(inputEl);
      const inputRect = inputEl.getBoundingClientRect();
      const wrapperRect = wrapperElRef.value.getBoundingClientRect();
      cursorAnchor.style.left = `${cursorPos.left + inputRect.left - wrapperRect.left}px`;
      cursorAnchor.style.top = `${cursorPos.top + inputRect.top - wrapperRect.top}px`;
      cursorAnchor.style.height = `${cursorPos.height}px`;
    }
    function syncPosition() {
      var _a;
      if (!showMenuRef.value) return;
      (_a = followerRef.value) === null || _a === void 0 ? void 0 : _a.syncPosition();
    }
    function handleInputUpdateValue(value) {
      doUpdateValue(value);
      // Vue update is mirco task.
      // So DOM must have been done when sync start in marco task.
      // I can't use nextTick(), Chrome doesn't update scrollLeft of INPUT
      // element is immediatelly updated. The behavior is wired but that's what
      // happens.
      syncAfterCursorMove();
    }
    function syncAfterCursorMove() {
      setTimeout(() => {
        syncCursor();
        deriveShowMenu();
        void nextTick().then(syncPosition);
      }, 0);
    }
    function handleInputKeyDown(e) {
      var _a, _b;
      if (e.key === 'ArrowLeft' || e.key === 'ArrowRight') {
        if ((_a = inputInstRef.value) === null || _a === void 0 ? void 0 : _a.isCompositing) return;
        syncAfterCursorMove();
      } else if (e.key === 'ArrowUp' || e.key === 'ArrowDown' || e.key === 'Enter') {
        if ((_b = inputInstRef.value) === null || _b === void 0 ? void 0 : _b.isCompositing) return;
        const {
          value: selectMenuInst
        } = selectMenuInstRef;
        if (showMenuRef.value) {
          if (selectMenuInst) {
            e.preventDefault();
            if (e.key === 'ArrowUp') {
              selectMenuInst.prev();
            } else if (e.key === 'ArrowDown') {
              selectMenuInst.next();
            } else {
              // Enter
              const pendingOptionTmNode = selectMenuInst.getPendingTmNode();
              if (pendingOptionTmNode) {
                handleSelect(pendingOptionTmNode);
              } else {
                doUpdateShowMenu(false);
              }
            }
          }
        } else {
          syncAfterCursorMove();
        }
      }
    }
    function handleInputFocus(e) {
      const {
        onFocus
      } = props;
      onFocus === null || onFocus === void 0 ? void 0 : onFocus(e);
      const {
        nTriggerFormFocus
      } = formItem;
      nTriggerFormFocus();
      syncAfterCursorMove();
    }
    function focus() {
      var _a;
      (_a = inputInstRef.value) === null || _a === void 0 ? void 0 : _a.focus();
    }
    function blur() {
      var _a;
      (_a = inputInstRef.value) === null || _a === void 0 ? void 0 : _a.blur();
    }
    function handleInputBlur(e) {
      const {
        onBlur
      } = props;
      onBlur === null || onBlur === void 0 ? void 0 : onBlur(e);
      const {
        nTriggerFormBlur
      } = formItem;
      nTriggerFormBlur();
      doUpdateShowMenu(false);
    }
    function handleSelect(tmNode) {
      var _a;
      if (cachedPrefix === null || cachedPartialPatternStart === null || cachedPartialPatternEnd === null) {
        if (process.env.NODE_ENV !== 'production') {
          warn('mention', 'Cache works unexpectly, this is probably a bug. Please create an issue.');
        }
        return;
      }
      const {
        rawNode: {
          value = ''
        }
      } = tmNode;
      const inputEl = getInputEl();
      const inputValue = inputEl.value;
      const {
        separator
      } = props;
      const nextEndPart = inputValue.slice(cachedPartialPatternEnd);
      const alreadySeparated = nextEndPart.startsWith(separator);
      const nextMiddlePart = `${value}${alreadySeparated ? '' : separator}`;
      doUpdateValue(inputValue.slice(0, cachedPartialPatternStart) + nextMiddlePart + nextEndPart);
      (_a = props.onSelect) === null || _a === void 0 ? void 0 : _a.call(props, tmNode.rawNode, cachedPrefix);
      const nextSelectionEnd = cachedPartialPatternStart + nextMiddlePart.length + (alreadySeparated ? 1 : 0);
      void nextTick().then(() => {
        // input value is updated
        inputEl.selectionStart = nextSelectionEnd;
        inputEl.selectionEnd = nextSelectionEnd;
        deriveShowMenu();
      });
    }
    function handleInputMouseDown() {
      if (!props.disabled) {
        syncAfterCursorMove();
      }
    }
    return {
      namespace: namespaceRef,
      mergedClsPrefix: mergedClsPrefixRef,
      mergedBordered: mergedBorderedRef,
      mergedSize: formItem.mergedSizeRef,
      mergedStatus: formItem.mergedStatusRef,
      mergedTheme: themeRef,
      treeMate: treeMateRef,
      selectMenuInstRef,
      inputInstRef,
      cursorRef,
      followerRef,
      wrapperElRef,
      showMenu: showMenuRef,
      adjustedTo: useAdjustedTo(props),
      isMounted: useIsMounted(),
      mergedValue: mergedValueRef,
      handleInputFocus,
      handleInputBlur,
      handleInputUpdateValue,
      handleInputKeyDown,
      handleSelect,
      handleInputMouseDown,
      focus,
      blur,
      cssVars: inlineThemeDisabled ? undefined : cssVarsRef,
      themeClass: themeClassHandle === null || themeClassHandle === void 0 ? void 0 : themeClassHandle.themeClass,
      onRender: themeClassHandle === null || themeClassHandle === void 0 ? void 0 : themeClassHandle.onRender
    };
  },
  render() {
    const {
      mergedTheme,
      mergedClsPrefix,
      $slots
    } = this;
    return h("div", {
      class: `${mergedClsPrefix}-mention`,
      ref: "wrapperElRef"
    }, h(NInput, {
      status: this.mergedStatus,
      themeOverrides: mergedTheme.peerOverrides.Input,
      theme: mergedTheme.peers.Input,
      size: this.mergedSize,
      autosize: this.autosize,
      type: this.type,
      ref: "inputInstRef",
      placeholder: this.placeholder,
      onMousedown: this.handleInputMouseDown,
      onUpdateValue: this.handleInputUpdateValue,
      onKeydown: this.handleInputKeyDown,
      onFocus: this.handleInputFocus,
      onBlur: this.handleInputBlur,
      bordered: this.mergedBordered,
      disabled: this.disabled,
      value: this.mergedValue
    }), h(VBinder, null, {
      default: () => [h(VTarget, null, {
        default: () => {
          const style = {
            position: 'absolute',
            width: 0
          };
          if (process.env.NODE_ENV !== 'production' && this.internalDebug) {
            style.width = '1px';
            style.background = 'red';
          }
          return h("div", {
            style: style,
            ref: "cursorRef"
          });
        }
      }), h(VFollower, {
        ref: "followerRef",
        placement: this.placement,
        show: this.showMenu,
        containerClass: this.namespace,
        to: this.adjustedTo,
        teleportDisabled: this.adjustedTo === useAdjustedTo.tdkey
      }, {
        default: () => h(Transition, {
          name: "fade-in-scale-up-transition",
          appear: this.isMounted
        }, {
          default: () => {
            const {
              mergedTheme,
              onRender
            } = this;
            onRender === null || onRender === void 0 ? void 0 : onRender();
            return this.showMenu ? h(NInternalSelectMenu, {
              clsPrefix: mergedClsPrefix,
              theme: mergedTheme.peers.InternalSelectMenu,
              themeOverrides: mergedTheme.peerOverrides.InternalSelectMenu,
              autoPending: true,
              ref: "selectMenuInstRef",
              class: [`${mergedClsPrefix}-mention-menu`, this.themeClass],
              loading: this.loading,
              treeMate: this.treeMate,
              virtualScroll: false,
              style: this.cssVars,
              onToggle: this.handleSelect,
              renderLabel: this.renderLabel
            }, $slots) : null;
          }
        })
      })]
    }));
  }
});