ant-design-vue/components/vc-mentions/src/Mentions.tsx

308 lines
9.0 KiB
Vue

import type { CSSProperties, ExtractPropTypes } from 'vue';
import {
toRef,
watchEffect,
defineComponent,
provide,
withDirectives,
ref,
reactive,
onUpdated,
nextTick,
computed,
} from 'vue';
import classNames from '../../_util/classNames';
import KeyCode from '../../_util/KeyCode';
import { initDefaultProps } from '../../_util/props-util';
import {
getBeforeSelectionText,
getLastMeasureIndex,
replaceWithMeasure,
setInputSelection,
} from './util';
import KeywordTrigger from './KeywordTrigger';
import { vcMentionsProps, defaultProps } from './mentionsProps';
import type { OptionProps } from './Option';
import MentionsContextKey from './MentionsContext';
import antInputDirective from '../../_util/antInputDirective';
import omit from '../../_util/omit';
import type { EventHandler } from '../../_util/EventInterface';
export type MentionsProps = Partial<ExtractPropTypes<typeof vcMentionsProps>>;
function noop() {}
export default defineComponent({
compatConfig: { MODE: 3 },
name: 'Mentions',
inheritAttrs: false,
props: initDefaultProps(vcMentionsProps, defaultProps),
slots: ['notFoundContent', 'option'],
emits: ['change', 'select', 'search', 'focus', 'blur', 'pressenter'],
setup(props, { emit, attrs, expose, slots }) {
const measure = ref(null);
const textarea = ref(null);
const focusId = ref();
const state = reactive({
value: props.value || '',
measuring: false,
measureLocation: 0,
measureText: null,
measurePrefix: '',
activeIndex: 0,
isFocus: false,
});
watchEffect(() => {
state.value = props.value;
});
const triggerChange = (val: string) => {
emit('change', val);
};
const onChange: EventHandler = ({ target: { value, composing }, isComposing }) => {
if (isComposing || composing) return;
triggerChange(value);
};
const startMeasure = (measureText: string, measurePrefix: string, measureLocation: number) => {
Object.assign(state, {
measuring: true,
measureText,
measurePrefix,
measureLocation,
activeIndex: 0,
});
};
const stopMeasure = (callback?: () => void) => {
Object.assign(state, {
measuring: false,
measureLocation: 0,
measureText: null,
});
callback?.();
};
const onKeyDown = (event: KeyboardEvent) => {
const { which } = event;
// Skip if not measuring
if (!state.measuring) {
return;
}
if (which === KeyCode.UP || which === KeyCode.DOWN) {
// Control arrow function
const optionLen = options.value.length;
const offset = which === KeyCode.UP ? -1 : 1;
const newActiveIndex = (state.activeIndex + offset + optionLen) % optionLen;
state.activeIndex = newActiveIndex;
event.preventDefault();
} else if (which === KeyCode.ESC) {
stopMeasure();
} else if (which === KeyCode.ENTER) {
// Measure hit
event.preventDefault();
if (!options.value.length) {
stopMeasure();
return;
}
const option = options.value[state.activeIndex];
selectOption(option);
}
};
const onKeyUp = (event: KeyboardEvent) => {
const { key, which } = event;
const { measureText: prevMeasureText, measuring } = state;
const { prefix, validateSearch } = props;
const target = event.target as HTMLTextAreaElement;
if ((target as any).composing) {
return;
}
const selectionStartText = getBeforeSelectionText(target);
const { location: measureIndex, prefix: measurePrefix } = getLastMeasureIndex(
selectionStartText,
prefix,
);
// Skip if match the white key list
if ([KeyCode.ESC, KeyCode.UP, KeyCode.DOWN, KeyCode.ENTER].indexOf(which) !== -1) {
return;
}
if (measureIndex !== -1) {
const measureText = selectionStartText.slice(measureIndex + measurePrefix.length);
const validateMeasure = validateSearch(measureText, props);
const matchOption = !!getOptions(measureText).length;
if (validateMeasure) {
if (
key === measurePrefix ||
key === 'Shift' ||
measuring ||
(measureText !== prevMeasureText && matchOption)
) {
startMeasure(measureText, measurePrefix, measureIndex);
}
} else if (measuring) {
// Stop if measureText is invalidate
stopMeasure();
}
/**
* We will trigger `onSearch` to developer since they may use for async update.
* If met `space` means user finished searching.
*/
if (validateMeasure) {
emit('search', measureText, measurePrefix);
}
} else if (measuring) {
stopMeasure();
}
};
const onPressEnter = event => {
if (!state.measuring) {
emit('pressenter', event);
}
};
const onInputFocus = (event: Event) => {
onFocus(event);
};
const onInputBlur = (event: Event) => {
onBlur(event);
};
const onFocus = (event: Event) => {
clearTimeout(focusId.value);
const { isFocus } = state;
if (!isFocus && event) {
emit('focus', event);
}
state.isFocus = true;
};
const onBlur = (event: Event) => {
focusId.value = setTimeout(() => {
state.isFocus = false;
stopMeasure();
emit('blur', event);
}, 100);
};
const selectOption = (option: OptionProps) => {
const { split } = props;
const { value: mentionValue = '' } = option;
const { text, selectionLocation } = replaceWithMeasure(state.value, {
measureLocation: state.measureLocation,
targetText: mentionValue,
prefix: state.measurePrefix,
selectionStart: textarea.value.selectionStart,
split,
});
triggerChange(text);
stopMeasure(() => {
// We need restore the selection position
setInputSelection(textarea.value, selectionLocation);
});
emit('select', option, state.measurePrefix);
};
const setActiveIndex = (activeIndex: number) => {
state.activeIndex = activeIndex;
};
const getOptions = (measureText?: string) => {
const targetMeasureText = measureText || state.measureText || '';
const { filterOption } = props;
const list = props.options.filter((option: OptionProps) => {
/** Return all result if `filterOption` is false. */
if (!!filterOption === false) {
return true;
}
return (filterOption as Function)(targetMeasureText, option);
});
return list;
};
const options = computed(() => {
return getOptions();
});
const focus = () => {
textarea.value.focus();
};
const blur = () => {
textarea.value.blur();
};
expose({ blur, focus });
provide(MentionsContextKey, {
activeIndex: toRef(state, 'activeIndex'),
setActiveIndex,
selectOption,
onFocus,
onBlur,
loading: toRef(props, 'loading'),
});
onUpdated(() => {
nextTick(() => {
if (state.measuring) {
measure.value.scrollTop = textarea.value.scrollTop;
}
});
});
return () => {
const { measureLocation, measurePrefix, measuring } = state;
const { prefixCls, placement, transitionName, getPopupContainer, direction, ...restProps } =
props;
const { class: className, style, ...otherAttrs } = attrs;
const inputProps = omit(restProps, [
'value',
'prefix',
'split',
'validateSearch',
'filterOption',
'options',
'loading',
]);
const textareaProps = {
...inputProps,
...otherAttrs,
onChange: noop,
onSelect: noop,
value: state.value,
onInput: onChange,
onBlur: onInputBlur,
onKeydown: onKeyDown,
onKeyup: onKeyUp,
onFocus: onInputFocus,
onPressenter: onPressEnter,
};
return (
<div class={classNames(prefixCls, className)} style={style as CSSProperties}>
{withDirectives(<textarea ref={textarea} {...textareaProps} />, [[antInputDirective]])}
{measuring && (
<div ref={measure} class={`${prefixCls}-measure`}>
{state.value.slice(0, measureLocation)}
<KeywordTrigger
prefixCls={prefixCls}
transitionName={transitionName}
dropdownClassName={props.dropdownClassName}
placement={placement}
options={measuring ? options.value : []}
visible
direction={direction}
getPopupContainer={getPopupContainer}
v-slots={{ notFoundContent: slots.notFoundContent, option: slots.option }}
>
<span>{measurePrefix}</span>
</KeywordTrigger>
{state.value.slice(measureLocation + measurePrefix.length)}
</div>
)}
</div>
);
};
},
});