181 lines
4.3 KiB
JavaScript

import { useState, useRef } from 'react'
import PropTypes from 'prop-types'
import { DatePicker, Input } from 'antd'
import dayjs from 'dayjs'
import FunctionIcon from '../../Icons/FunctionIcon'
const OPERATOR_KEYS = ['+', '-']
const TIME_UNITS = {
s: 'second',
m: 'minute',
h: 'hour',
d: 'day',
w: 'week',
M: 'month',
y: 'year'
}
const TERM_REGEX = /([+-])(\d+(?:\.\d+)?)([smhdwMy])/g
const RELATIVE_TERM_START = /[+-]\d+(?:\.\d+)?[smhdwMy]/
const DATE_EXPR_FORMAT = 'YYYY-MM-DD HH:mm:ss'
function formatExprBase(date) {
if (date == null || !dayjs(date).isValid()) return ''
return dayjs(date).format(DATE_EXPR_FORMAT)
}
function applyTimeExpr(fallbackBase, expr) {
const trimmed = String(expr).trim()
if (!trimmed) return null
const termStart = trimmed.search(RELATIVE_TERM_START)
if (termStart === -1) return null
let base
let termsPart
if (termStart === 0) {
base =
fallbackBase && dayjs(fallbackBase).isValid()
? dayjs(fallbackBase)
: dayjs()
termsPart = trimmed
} else {
const baseStr = trimmed.slice(0, termStart).trim()
const parsed = dayjs(baseStr)
base = parsed.isValid()
? parsed
: fallbackBase && dayjs(fallbackBase).isValid()
? dayjs(fallbackBase)
: dayjs()
termsPart = trimmed.slice(termStart)
}
const termsStr = termsPart.replace(/\s/g, '')
const terms = [...termsStr.matchAll(TERM_REGEX)]
if (!terms.length) return null
const consumed = terms.map((t) => t[0]).join('')
if (consumed !== termsStr) return null
let result = base
for (const [, sign, amount, unit] of terms) {
const unitKey = TIME_UNITS[unit]
if (!unitKey) return null
const delta = sign === '+' ? parseFloat(amount) : -parseFloat(amount)
result = result.add(delta, unitKey)
}
return result
}
const TimeEdit = ({
value,
onChange,
onBlur,
disabled,
...rest
}) => {
const [isExprMode, setIsExprMode] = useState(false)
const [exprValue, setExprValue] = useState('')
const inputRef = useRef(null)
const dayjsValue =
value == null ? null : dayjs.isDayjs(value) ? value : dayjs(value)
const pickerValue =
dayjsValue && dayjsValue.isValid() ? dayjsValue : null
const switchToExprMode = (initialValue) => {
setIsExprMode(true)
setExprValue(initialValue)
setTimeout(() => {
const input = inputRef.current?.getElementsByTagName('input')[0]
input?.focus()
}, 0)
}
const exitExprMode = (result) => {
setIsExprMode(false)
setExprValue('')
if (result != null && result.isValid()) {
onChange?.(result)
}
}
const commitExpr = () => {
const expr = exprValue.trim()
if (expr && expr.match(/[+-]/) && expr.match(/[smhdwMy]/)) {
const result = applyTimeExpr(pickerValue, expr)
if (result != null) {
exitExprMode(result)
return
}
}
exitExprMode(null)
}
const handleDatePickerKeyDown = (e) => {
if (OPERATOR_KEYS.includes(e.key)) {
e.preventDefault()
e.stopPropagation()
switchToExprMode(formatExprBase(pickerValue) + e.key)
}
}
const handleInputKeyDown = (e) => {
if (e.key === 'Enter' || e.key === '=') {
e.preventDefault()
const result = applyTimeExpr(pickerValue, exprValue)
if (result != null) {
exitExprMode(result)
}
}
}
const handleInputBlur = (e) => {
commitExpr()
onBlur?.(e)
}
if (isExprMode) {
return (
<div className='input-number-cal' ref={inputRef}>
<Input
value={exprValue}
onChange={(e) => setExprValue(e.target.value)}
onKeyDown={handleInputKeyDown}
onBlur={handleInputBlur}
disabled={disabled}
placeholder='+1d, -2m, +1h'
style={{ width: '100%' }}
/>
<div className='input-number-cal-icon'>
<FunctionIcon style={{ fontSize: 24 }} />
</div>
</div>
)
}
return (
<DatePicker
showTime
style={{ width: '100%' }}
value={pickerValue}
onChange={onChange}
onKeyDown={handleDatePickerKeyDown}
disabled={disabled}
{...rest}
/>
)
}
TimeEdit.propTypes = {
value: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
onChange: PropTypes.func,
onBlur: PropTypes.func,
disabled: PropTypes.bool
}
export default TimeEdit