Skip to content

Commit 1fd8712

Browse files
committed
feat: add TimeMsInputControl component for handling time input
1 parent 9a81e82 commit 1fd8712

2 files changed

Lines changed: 177 additions & 0 deletions

File tree

packages/shared-lib/src/lib/JSONSchemaUtil.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export enum SchemaFormUIField {
3636
* to determine which variant is selected.
3737
* - string properties. Valid values are `base64-image`.
3838
* - boolean properties. Valid values are `switch`.
39+
* - number properties. Valid values are `timeMs`.
3940
* - array properties with items.type string. Valid values are `bread-crumbs`.
4041
*/
4142
DisplayType = 'ui:displayType',
Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import React, { useCallback, useState } from 'react'
2+
import ClassNames from 'classnames'
3+
import Form from 'react-bootstrap/Form'
4+
5+
interface IIntInputControlProps {
6+
classNames?: string
7+
modifiedClassName?: string
8+
disabled?: boolean
9+
placeholder?: string
10+
11+
/** Call handleUpdate on every change, before focus is lost */
12+
updateOnKey?: boolean
13+
14+
value: number | undefined
15+
handleUpdate: (value: number) => void
16+
17+
min?: number
18+
max?: number
19+
multipleOf?: number
20+
}
21+
22+
const ALLOWED_KEYS = [
23+
'0',
24+
'1',
25+
'2',
26+
'3',
27+
'4',
28+
'5',
29+
'6',
30+
'7',
31+
'8',
32+
'9',
33+
'.',
34+
':',
35+
',',
36+
'Backspace',
37+
'Tab',
38+
'Enter',
39+
'Escape',
40+
'ArrowLeft',
41+
'ArrowRight',
42+
'Home',
43+
'End',
44+
]
45+
46+
function formatTime(time: number, opts?: { showZeroHours?: boolean; showZeroFrames?: boolean }): string {
47+
const frames = time % 1000
48+
const ms = String(frames).padStart(3, '0')
49+
const ss = String(Math.floor(time / 1000) % 60).padStart(2, '0')
50+
const mm = String(Math.floor(time / 60000) % 60).padStart(2, '0')
51+
const hours = Math.floor(time / 3600000)
52+
53+
let result = `${mm}:${ss}`
54+
if (frames > 0 || opts?.showZeroFrames) {
55+
result += `.${ms}`
56+
}
57+
if (hours > 0 || opts?.showZeroHours) {
58+
const hh = String(hours).padStart(2, '0')
59+
result = `${hh}:${result}`
60+
}
61+
62+
return result
63+
}
64+
65+
function parseTime(time: string): number {
66+
const parts = time.split(':').map((part) => part.trim())
67+
const partsCount = parts.length
68+
if (partsCount > 3) return Number.NaN
69+
70+
let ms = 0
71+
for (let i = 0; i < partsCount; i++) {
72+
const part = parts[partsCount - 1 - i]
73+
const number = parseInt(part, 10)
74+
if (i === 0 && part.includes('.')) {
75+
const number = parseFloat(part)
76+
if (isNaN(number) || number < 0) return Number.NaN
77+
ms += number * 1000
78+
} else if (isNaN(number) || number < 0) return Number.NaN
79+
else if (i === 0 && partsCount) ms += number * 1000
80+
else if (i === 1) ms += number * 60000
81+
else if (i === 2) ms += number * 3600000
82+
}
83+
84+
return ms
85+
}
86+
87+
export function TimeMsInputControl({
88+
classNames,
89+
modifiedClassName,
90+
value,
91+
disabled,
92+
placeholder,
93+
handleUpdate,
94+
updateOnKey,
95+
min,
96+
max,
97+
multipleOf,
98+
}: Readonly<IIntInputControlProps>): JSX.Element {
99+
const [editingValue, setEditingValue] = useState<string | null>(null)
100+
101+
const isValidValue = useCallback((value: number): boolean => {
102+
if (isNaN(value) || value < 0) return false
103+
if (min !== undefined && value < min) return false
104+
if (max !== undefined && value > max) return false
105+
if (multipleOf !== undefined && value % multipleOf !== 0) return false
106+
return true
107+
}, [min, max, multipleOf])
108+
109+
const handleChange = useCallback(
110+
(event: React.ChangeEvent<HTMLInputElement>) => {
111+
const number = parseTime(event.target.value)
112+
setEditingValue(event.target.value)
113+
114+
if (updateOnKey && !isNaN(number) && isValidValue(number)) {
115+
handleUpdate(number)
116+
}
117+
},
118+
[handleUpdate, updateOnKey, isValidValue]
119+
)
120+
const handleBlur = useCallback(
121+
(event: React.FocusEvent<HTMLInputElement>) => {
122+
const number = parseTime(event.currentTarget.value)
123+
if (!isNaN(number) && isValidValue(number)) {
124+
handleUpdate(number)
125+
}
126+
127+
setEditingValue(null)
128+
},
129+
[handleUpdate, isValidValue]
130+
)
131+
const handleFocus = useCallback((event: React.FocusEvent<HTMLInputElement>) => {
132+
setEditingValue(event.currentTarget.value)
133+
event.currentTarget.selectionStart = 0
134+
event.currentTarget.selectionEnd = event.currentTarget.value.length
135+
}, [])
136+
const handleKeyUp = useCallback(
137+
(event: React.KeyboardEvent<HTMLInputElement>) => {
138+
if (event.key === 'Escape') {
139+
setEditingValue(null)
140+
} else if (event.key === 'Enter') {
141+
const number = parseTime(event.currentTarget.value)
142+
if (!isNaN(number) && isValidValue(number)) {
143+
handleUpdate(number)
144+
}
145+
}
146+
},
147+
[handleUpdate, isValidValue]
148+
)
149+
const handleKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
150+
// allow ctrl/cmd + any key, to allow for shortcuts like ctrl+a, ctrl+c, ctrl+v, etc.
151+
if (!ALLOWED_KEYS.includes(event.key) && event.ctrlKey === false && event.metaKey === false) {
152+
event.preventDefault()
153+
}
154+
}, [])
155+
156+
let showValue: string | number | undefined = editingValue ?? undefined
157+
if (showValue === undefined && value !== undefined) {
158+
showValue = formatTime(value)
159+
}
160+
if (showValue === undefined) showValue = ''
161+
162+
return (
163+
<Form.Control
164+
type="text"
165+
className={ClassNames('form-control', classNames, editingValue !== null && modifiedClassName)}
166+
placeholder={placeholder}
167+
value={showValue ?? ''}
168+
onChange={handleChange}
169+
onBlur={handleBlur}
170+
onFocus={handleFocus}
171+
onKeyUp={handleKeyUp}
172+
onKeyDown={handleKeyDown}
173+
disabled={disabled}
174+
/>
175+
)
176+
}

0 commit comments

Comments
 (0)