* Copyright (c) 2022 - present TinyVue Authors.
* Copyright (c) 2022 - present Huawei Cloud Computing Technologies Co., Ltd.
*
* Use of this source code is governed by an MIT-style license.
*
* THE OPEN SOURCE SOFTWARE IN THIS PRODUCT IS DISTRIBUTED IN THE HOPE THAT IT WILL BE USEFUL,
* BUT WITHOUT ANY WARRANTY, WITHOUT EVEN THE IMPLIED WARRANTY OF MERCHANTABILITY OR FITNESS FOR
* A PARTICULAR PURPOSE. SEE THE APPLICABLE LICENSES FOR MORE DETAILS.
*
*/
import { omitText } from '@opentiny/utils'
import { isServer } from '@opentiny/utils'
import type { IInputApi, IInputRenderlessParamUtils, IInputRenderlessParams, IInputState } from 'types/input.type'
const HIDDEN_STYLE = `
height:0 !important;visibility:hidden !important;overflow:hidden !important;
position:absolute !important;z-index:-1000 !important;top:0 !important;right:0 !important
`
const CONTEXT_STYLE = [
'width',
'line-height',
'padding-top',
'padding-bottom',
'padding-left',
'padding-right',
'border-width',
'box-sizing',
'letter-spacing',
'font-family',
'font-weight',
'font-size',
'text-rendering',
'text-transform',
'text-indent'
]
const STYLE = {
BoxSizing: 'box-sizing',
BorderBox: 'border-box',
ContentBox: 'content-box',
PaddingTop: 'padding-top',
PaddingBottom: 'padding-bottom',
BorderTopWidth: 'border-top-width',
BorderBottomWidth: 'border-bottom-width'
}
const isKorean = (text: string): boolean => /([(\uAC00-\uD7AF)|(\u3130-\u318F)])+/gi.test(text)
export const showBox = (state: IInputState) => (): void => {
if (state.inputDisabled) {
return false
}
state.boxVisibility = true
}
export const inputStyle =
({ props }) =>
() => {
return {
textAlign: props.textAlign
}
}
export const calculateNodeStyling =
() =>
(
targetElement: HTMLElement
): {
contextStyle: string
paddingSize: number
borderSize: number
boxSizing: string
} => {
const style = window.getComputedStyle(targetElement)
const boxSizing = style.getPropertyValue(STYLE.BoxSizing)
const paddingSize =
parseFloat(style.getPropertyValue(STYLE.PaddingBottom)) + parseFloat(style.getPropertyValue(STYLE.PaddingTop))
const borderSize =
parseFloat(style.getPropertyValue(STYLE.BorderBottomWidth)) +
parseFloat(style.getPropertyValue(STYLE.BorderTopWidth))
const contextStyle = CONTEXT_STYLE.map((name) => `${name}:${style.getPropertyValue(name)}`).join(';')
return { contextStyle, paddingSize, borderSize, boxSizing }
}
export const calcTextareaHeight =
({
api,
hiddenTextarea,
props,
state,
mode,
constants
}: Pick<IInputRenderlessParams, 'api' | 'props' | 'state' | 'mode' | 'constants'> & {
hiddenTextarea: HTMLTextAreaElement | null
}) =>
(
targetElement: HTMLTextAreaElement,
minRows = 1,
maxRows = null
): {
minHeight?: string
height?: string
} => {
if (!targetElement) {
return {
minHeight: '',
height: ''
}
}
if (!hiddenTextarea) {
hiddenTextarea = document.createElement('textarea')
document.body.appendChild(hiddenTextarea)
}
const { paddingSize, borderSize, boxSizing, contextStyle } = api.calculateNodeStyling(targetElement)
hiddenTextarea.setAttribute('style', `${contextStyle};${HIDDEN_STYLE}`)
const safePlaceholder = targetElement.placeholder ? targetElement.placeholder.trim().split('\n')[0] : ''
hiddenTextarea.value = targetElement.value || safePlaceholder || ''
let height = hiddenTextarea.scrollHeight
const textareaStyle: {
minHeight?: string
height?: string
} = {}
if (mode === 'mobile') {
height = Math.max(hiddenTextarea.scrollHeight, constants.TEXTAREA_HEIGHT_MOBILE)
}
if (boxSizing === STYLE.BorderBox) {
height = height + borderSize * 2 + paddingSize
} else if (boxSizing === STYLE.ContentBox) {
height = height - paddingSize
}
hiddenTextarea.value = ''
const singleRowHeight = hiddenTextarea.scrollHeight - paddingSize
if (minRows !== null) {
let minHeight = singleRowHeight * minRows
if (boxSizing === STYLE.BorderBox) {
minHeight = minHeight + paddingSize + borderSize
}
if (props.size || minRows === 1) {
minHeight = props.size === 'mini' ? minHeight * 0.67 : props.size === 'small' ? minHeight : minHeight * 1.17
}
if (props.height) {
minHeight = props.height
}
if (!state.isDisplayOnly) {
height = Math.max(minHeight, height)
textareaStyle.minHeight = `${Math.floor(minHeight)}px`
} else {
textareaStyle.minHeight = `0px`
}
}
if (maxRows !== null) {
let maxHeight = singleRowHeight * maxRows
if (boxSizing === STYLE.BorderBox) {
maxHeight += borderSize + paddingSize
}
height = Math.min(maxHeight, height)
}
textareaStyle.height = `${height}px`
hiddenTextarea.parentNode && hiddenTextarea.parentNode.removeChild(hiddenTextarea)
hiddenTextarea = null
return textareaStyle
}
export const getInput = (vm: IInputRenderlessParamUtils['vm']) => (): HTMLTextAreaElement | HTMLInputElement =>
vm.$refs.input || vm.$refs.textarea
export const blur = (api: IInputApi) => (): void => api.getInput().blur()
export const focus = (api: IInputApi) => (): void => api.getInput().focus()
export const select = (api: IInputApi) => (): void => api.getInput().select()
export const handleBlur =
({
api,
componentName,
eventName,
emit,
props,
state,
vm
}: Pick<IInputRenderlessParams, 'api' | 'emit' | 'props' | 'state' | 'vm'> & {
componentName: string
eventName: string
}) =>
(event: FocusEvent): void => {
state.focused = false
emit('blur', event)
api.isMemoryStorage.value = false
if (props.validateEvent) {
api.dispatch(componentName, eventName, [props.modelValue])
}
if (props.hoverExpand) {
vm.$refs.textarea.scrollTop = 0
}
}
export const handleFocus =
({ api, emit, state }: Pick<IInputRenderlessParams, 'api' | 'emit' | 'state'>) =>
(event: FocusEvent): void => {
state.focused = true
emit('focus', event)
api.searchMemory((event.target as HTMLInputElement | HTMLTextAreaElement).value)
}
export const handleInput =
({ api, emit, nextTick, state }: Pick<IInputRenderlessParams, 'api' | 'emit' | 'nextTick' | 'state'>) =>
(event: Event): void => {
if (state.isComposing) {
return
}
if ((event.target as HTMLInputElement | HTMLTextAreaElement).value === state.nativeInputValue) {
return
}
emit('update:modelValue', (event.target as HTMLInputElement | HTMLTextAreaElement).value)
emit('input', event)
api.searchMemory((event.target as HTMLInputElement | HTMLTextAreaElement).value)
nextTick(api.setNativeInputValue)
}
export const handleChange =
(emit: IInputRenderlessParams['emit']) =>
(event: Event): void =>
emit('change', (event.target as HTMLInputElement | HTMLTextAreaElement).value)
export const resizeTextarea =
({ api, parent, vm, state, props }: Pick<IInputRenderlessParams, 'api' | 'parent' | 'vm' | 'state' | 'props'>) =>
(): void => {
if (isServer) {
return
}
const { autosize, type } = parent
if (type !== 'textarea' || !vm.$refs.textarea) {
return
}
if (props.hoverExpand && !state.enteredTextarea) {
state.textareaCalcStyle = {
minHeight: state.textareaHeight,
height: state.textareaHeight
}
return
}
if (!autosize || state.isDisplayOnly) {
state.textareaCalcStyle = {
minHeight: api.calcTextareaHeight(vm.$refs.textarea).minHeight
}
return
}
const minRows = autosize.minRows
const maxRows = autosize.maxRows
state.textareaCalcStyle = api.calcTextareaHeight(vm.$refs.textarea, minRows, maxRows)
}
export const setNativeInputValue =
({ api, state }: Pick<IInputRenderlessParams, 'api' | 'state'>) =>
(): void => {
const input = api.getInput()
if (!input) {
return
}
if (input.value === state.nativeInputValue) {
return
}
input.value = state.nativeInputValue
}
export const handleCompositionStart = (state: IInputState) => (): void => (state.isComposing = true)
export const handleCompositionUpdate =
(state: IInputState) =>
(event: CompositionEvent): void => {
const text = (event.target as HTMLInputElement | HTMLTextAreaElement).value
const lastCharacter = text[text.length - 1] || ''
state.isComposing = !isKorean(lastCharacter)
}
export const handleCompositionEnd =
({ api, state }: Pick<IInputRenderlessParams, 'api' | 'state'>) =>
(event: CompositionEvent): void => {
if (state.isComposing) {
state.isComposing = false
api.handleInput(event)
}
}
export const calcIconOffset =
({ vm, parent }: Pick<IInputRenderlessParams, 'parent' | 'vm'>) =>
(place: 'prefix' | 'suffix'): void => {
const elList = vm.$refs[place] ? [vm.$refs[place]] : []
if (!elList.length) {
return
}
let el: HTMLElement | null = null
for (let i = 0, len = elList.length; i < len; i++) {
if (elList[i].parentNode === parent.$el) {
el = elList[i]
break
}
}
if (!el) {
return
}
const pendantMap = { suffix: 'append', prefix: 'prepend' }
const pendant = pendantMap[place]
if (parent.$slots[pendant]) {
const dom = vm.$refs[pendant]
let transform
if (place === 'suffix') {
transform = `translateX(-${dom.offsetWidth}px) translateY(-50%)`
} else if (place === 'prefix') {
transform = `translate(${dom.offsetWidth}px, -50%)`
}
el.style.transform = transform
} else {
el.removeAttribute('style')
}
}
export const updateIconOffset = (api: IInputApi) => (): void => {
api.calcIconOffset('prefix')
api.calcIconOffset('suffix')
}
export const clear = (emit: IInputRenderlessParams['emit']) => (): void => {
emit('update:modelValue', '')
emit('change', '')
emit('clear')
}
export const handlePasswordVisible =
({ api, nextTick, state }: Pick<IInputRenderlessParams, 'api' | 'nextTick' | 'state'>) =>
(): void => {
state.passwordVisible = !state.passwordVisible
nextTick(api.focus)
}
export const getSuffixVisible =
({ parent, props, state }: Pick<IInputRenderlessParams, 'parent' | 'props' | 'state'>) =>
(): boolean =>
parent.$slots.suffix ||
props.suffixIcon ||
state.showClear ||
props.showPassword ||
state.isWordLimitVisible ||
(state.validateState && state.needStatusIcon) ||
(props.mask && state.inputDisabled)
export const textLength = (value: number | string | undefined): number => {
if (typeof value === 'number') {
return String(value).length
}
return (value || '').length
}
export const watchFormSelect =
({ emit, props, state }: Pick<IInputRenderlessParams, 'emit' | 'props' | 'state'>) =>
(value: string | number | undefined): void => {
if (props.isSelect) {
emit('update:modelValue', value)
emit('change', value)
const filterData = props.selectMenu.length && props.selectMenu.filter((item) => item.id === value).shift()
state.checkedLabel = filterData ? filterData.label : ''
}
}
export const hasSelection = (api: IInputApi) => (): boolean => {
const input = api.getInput()
return input && input.selectionStart !== input.selectionEnd
}
export const handleEnterDisplayOnlyContent =
({ state, props }: Pick<IInputRenderlessParams, 'state' | 'props'>) =>
($event: MouseEvent, type?: 'textarea'): void => {
if (type === 'textarea' && props.popupMore) return
const target = type === 'textarea' ? $event.target.querySelector('.text-box') : $event.target
state.displayOnlyTooltip = ''
if (!target) {
return
}
const isOverText =
target.scrollWidth > target.offsetWidth || (type === 'textarea' && target.scrollHeight > target.offsetHeight)
if (isOverText) {
state.displayOnlyTooltip = props.displayOnlyContent || state.nativeInputValue
} else {
let isOverTextWhenMask = false
if (props.mask && state.maskValueVisible) {
const text = target.textContent
const font = window.getComputedStyle(target).font
const rect = target.getBoundingClientRect()
const iconWidth = 16 + 15
1、omitText使用canvas来计算文字渲染后宽度来计算有没有文本超长
2、html标签换行情况下,会导致textContent比原文本多出前后空格,导致canvas计算宽度比html实际渲染宽度大,最终误判
3、将文本内容去除前后空格,再交给canvas计算宽度,消除空格带来的误差
*/
const calcText = text?.trim() || ''
isOverTextWhenMask = omitText(calcText, font, rect.width - iconWidth).o
}
if (isOverTextWhenMask) {
state.displayOnlyTooltip = props.displayOnlyContent || state.nativeInputValue
}
}
}
export const hiddenPassword =
({ state, props }: Pick<IInputRenderlessParams, 'state' | 'props'>) =>
(): string => {
let str = ''
const password = props.displayOnlyContent || state.nativeInputValue
for (let i = 0; i < password.length; i++) {
str += '*'
}
return str
}
export const getDisplayedMaskValue =
({ state }: Pick<IInputRenderlessParams, 'state'>) =>
() => {
if (state.maskValueVisible) {
return state.nativeInputValue
} else {
return state.nativeInputValue && state.maskSymbol
}
}
export const setInputDomValue =
({ state, props, nextTick, vm }: Pick<IInputRenderlessParams, 'state' | 'props' | 'nextTick' | 'vm'>) =>
(type) => {
nextTick(() => {
const input = vm.$refs.input
if (props.mask && state.nativeInputValue && input) {
input.value = state.maskValueVisible || !state.inputDisabled ? state.nativeInputValue : state.maskSymbol
}
if (type === 'mask' && !props.mask && input) {
input.value = state.nativeInputValue
}
})
}
export const handleEnterTextarea =
({ api, state, props, nextTick }) =>
() => {
if (state.isDragging) {
return
}
if (props.hoverExpand && !state.isDisplayOnly) {
state.enteredTextarea = true
nextTick(api.resizeTextarea)
}
}
export const handleLeaveTextarea =
({ api, state, props, nextTick, vm }) =>
() => {
if (state.isDragging) {
return
}
if (props.hoverExpand && !state.isDisplayOnly) {
state.enteredTextarea = false
nextTick(() => {
api.resizeTextarea()
vm.$refs.textarea.scrollTop = 0
})
}
}
export const getDisplayOnlyText =
({ parent, state, props }) =>
() => {
const text = props.displayOnlyContent || state.nativeInputValue
const showEmptyValue =
typeof props.showEmptyValue === 'boolean' ? props.showEmptyValue : (parent.tinyForm || {}).showEmptyValue
return showEmptyValue ? text : text || '-'
}
export const setShowMoreBtn =
({ state, vm }) =>
(init) => {
if (state.timer) clearTimeout(state.timer)
state.timer = setTimeout(() => {
const textBox = vm.$refs && vm.$refs.textBox
if (!textBox) return
if (init && textBox.offsetHeight === 0) {
let textBoxClone = textBox.cloneNode(true)
textBoxClone.style.visibility = 'hidden'
textBoxClone.style.position = 'absolute'
textBoxClone.style.left = '-9999px'
document.body.appendChild(textBoxClone)
if (textBoxClone.scrollHeight > textBoxClone.offsetHeight) {
state.showMoreBtn = true
}
document.body.removeChild(textBoxClone)
textBoxClone = null
} else if (textBox.scrollHeight > textBox.offsetHeight) {
state.showMoreBtn = true
} else {
state.showMoreBtn = false
}
}, 100)
}
export const handleTextareaMouseDown =
({ state }) =>
() =>
(state.isDragging = true)
export const handleTextareaMouseUp =
({ state, api }) =>
(isOutside) => {
state.isDragging = false
if (isOutside) {
api.handleLeaveTextarea()
}
}