@charcuterie/ui 3.33.0 → 3.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +30 -0
- package/dist/Board/Board.d.ts.map +1 -1
- package/dist/Board/Board.js +2 -2
- package/dist/Board/Board.js.map +1 -1
- package/dist/RangeSlider/RangeSlider.d.ts +117 -0
- package/dist/RangeSlider/RangeSlider.d.ts.map +1 -0
- package/dist/RangeSlider/RangeSlider.js +242 -0
- package/dist/RangeSlider/RangeSlider.js.map +1 -0
- package/dist/RangeSlider/rangeSliderValue.d.ts +102 -0
- package/dist/RangeSlider/rangeSliderValue.d.ts.map +1 -0
- package/dist/RangeSlider/rangeSliderValue.js +116 -0
- package/dist/RangeSlider/rangeSliderValue.js.map +1 -0
- package/dist/SegmentedControl/SegmentedControl.d.ts +12 -1
- package/dist/SegmentedControl/SegmentedControl.d.ts.map +1 -1
- package/dist/SegmentedControl/SegmentedControl.js +3 -3
- package/dist/SegmentedControl/SegmentedControl.js.map +1 -1
- package/dist/SegmentedControl/SegmentedOption.d.ts +2 -1
- package/dist/SegmentedControl/SegmentedOption.d.ts.map +1 -1
- package/dist/SegmentedControl/SegmentedOption.js +7 -2
- package/dist/SegmentedControl/SegmentedOption.js.map +1 -1
- package/dist/Slider/Slider.d.ts +6 -1
- package/dist/Slider/Slider.d.ts.map +1 -1
- package/dist/Slider/Slider.js +8 -22
- package/dist/Slider/Slider.js.map +1 -1
- package/dist/TimecodeInput/TimecodeInput.d.ts +88 -0
- package/dist/TimecodeInput/TimecodeInput.d.ts.map +1 -0
- package/dist/TimecodeInput/TimecodeInput.js +258 -0
- package/dist/TimecodeInput/TimecodeInput.js.map +1 -0
- package/dist/TimecodeInput/timecode.d.ts +132 -0
- package/dist/TimecodeInput/timecode.d.ts.map +1 -0
- package/dist/TimecodeInput/timecode.js +246 -0
- package/dist/TimecodeInput/timecode.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/sliderStyles.d.ts +39 -0
- package/dist/sliderStyles.d.ts.map +1 -0
- package/dist/sliderStyles.js +50 -0
- package/dist/sliderStyles.js.map +1 -0
- package/package.json +1 -1
- package/src/Board/Board.test.tsx +8 -0
- package/src/Board/Board.tsx +7 -1
- package/src/RangeSlider/RangeSlider.mdx +85 -0
- package/src/RangeSlider/RangeSlider.stories.tsx +346 -0
- package/src/RangeSlider/RangeSlider.test.tsx +452 -0
- package/src/RangeSlider/RangeSlider.tsx +595 -0
- package/src/RangeSlider/rangeSliderValue.test.ts +217 -0
- package/src/RangeSlider/rangeSliderValue.ts +185 -0
- package/src/SegmentedControl/SegmentedControl.stories.tsx +50 -0
- package/src/SegmentedControl/SegmentedControl.test.tsx +36 -1
- package/src/SegmentedControl/SegmentedControl.tsx +14 -0
- package/src/SegmentedControl/SegmentedOption.tsx +7 -0
- package/src/Slider/Slider.tsx +23 -29
- package/src/TimecodeInput/TimecodeInput.mdx +155 -0
- package/src/TimecodeInput/TimecodeInput.stories.tsx +304 -0
- package/src/TimecodeInput/TimecodeInput.test.tsx +279 -0
- package/src/TimecodeInput/TimecodeInput.tsx +493 -0
- package/src/TimecodeInput/timecode.test.ts +236 -0
- package/src/TimecodeInput/timecode.ts +391 -0
- package/src/index.ts +18 -0
- package/src/sliderStyles.ts +69 -0
- package/src/sourceRules.test.ts +25 -1
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The typed-timecode grammar.
|
|
3
|
+
*
|
|
4
|
+
* The interesting half of this file is the refusals. A parser that
|
|
5
|
+
* only proves it accepts `1:02:03` is a parser nobody has checked for
|
|
6
|
+
* the thing that actually hurts: quietly accepting something as the
|
|
7
|
+
* wrong position, which in this domain means a clip that starts a
|
|
8
|
+
* minute late and a rip nobody rewatches until it is too late.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { expect, test } from "vitest"
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
clampTimecode,
|
|
15
|
+
formatTimecode,
|
|
16
|
+
parseTimecodeInput,
|
|
17
|
+
} from "./timecode.ts"
|
|
18
|
+
|
|
19
|
+
const parsedMilliseconds = (text: string) => {
|
|
20
|
+
const result = parseTimecodeInput(text)
|
|
21
|
+
|
|
22
|
+
return result.kind === "timecode"
|
|
23
|
+
? result.milliseconds
|
|
24
|
+
: result
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const refusalReason = (text: string) => {
|
|
28
|
+
const result = parseTimecodeInput(text)
|
|
29
|
+
|
|
30
|
+
return result.kind === "unparsed" ? result.reason : ""
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
test("a bare number is SECONDS, because seconds is the smallest field the form prints", () => {
|
|
34
|
+
expect(parsedMilliseconds("0")).toBe(0)
|
|
35
|
+
expect(parsedMilliseconds("9")).toBe(9_000)
|
|
36
|
+
expect(parsedMilliseconds("90")).toBe(90_000)
|
|
37
|
+
expect(parsedMilliseconds("5400")).toBe(5_400_000)
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
test("mm:ss and hh:mm:ss both resolve, and the largest field is unbounded", () => {
|
|
41
|
+
expect(parsedMilliseconds("1:30")).toBe(90_000)
|
|
42
|
+
expect(parsedMilliseconds("0:09")).toBe(9_000)
|
|
43
|
+
expect(parsedMilliseconds("1:02:03")).toBe(3_723_000)
|
|
44
|
+
|
|
45
|
+
// 90 minutes. The largest field present carries no 0–59 bound, so
|
|
46
|
+
// this is the same position as `1:30:00` and both are accepted.
|
|
47
|
+
expect(parsedMilliseconds("90:00")).toBe(5_400_000)
|
|
48
|
+
expect(parsedMilliseconds("1:30:00")).toBe(5_400_000)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
test("a fraction is a decimal fraction of a second, so it pads on the right", () => {
|
|
52
|
+
expect(parsedMilliseconds("1.5")).toBe(1_500)
|
|
53
|
+
expect(parsedMilliseconds("1.05")).toBe(1_050)
|
|
54
|
+
expect(parsedMilliseconds("1.005")).toBe(1_005)
|
|
55
|
+
expect(parsedMilliseconds("1:02:03.500")).toBe(3_723_500)
|
|
56
|
+
|
|
57
|
+
// The decimal comma resolves identically. It is not a locale
|
|
58
|
+
// switch: a timecode has no thousands separator, so there is
|
|
59
|
+
// nothing for the comma to be confused with.
|
|
60
|
+
expect(parsedMilliseconds("1,5")).toBe(1_500)
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
test("a leading + is tolerated, and changes nothing", () => {
|
|
64
|
+
expect(parsedMilliseconds("+90")).toBe(90_000)
|
|
65
|
+
expect(parsedMilliseconds("+1:02:03")).toBe(3_723_000)
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
test("surrounding whitespace is not something a person should have to notice", () => {
|
|
69
|
+
expect(parsedMilliseconds(" 1:30 ")).toBe(90_000)
|
|
70
|
+
expect(parsedMilliseconds("1 : 30")).toBe(90_000)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
test("empty is its own outcome, because clearing a timecode is legitimate", () => {
|
|
74
|
+
expect(parseTimecodeInput("").kind).toBe("empty")
|
|
75
|
+
expect(parseTimecodeInput(" ").kind).toBe("empty")
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
test("a seconds field of 60 or more is refused by name, and never carried", () => {
|
|
79
|
+
// The `1:60` trap mux-magic wrote down. A parser that carries this
|
|
80
|
+
// moves the position by a whole minute and nothing ever says so.
|
|
81
|
+
expect(parseTimecodeInput("1:90").kind).toBe("unparsed")
|
|
82
|
+
|
|
83
|
+
const reason = refusalReason("1:90")
|
|
84
|
+
|
|
85
|
+
expect(reason).toContain("A minute has 60 seconds")
|
|
86
|
+
|
|
87
|
+
// Both legal spellings are offered and neither is taken.
|
|
88
|
+
expect(reason).toContain("02:30")
|
|
89
|
+
expect(reason).toContain("90")
|
|
90
|
+
|
|
91
|
+
expect(parseTimecodeInput("1:60").kind).toBe("unparsed")
|
|
92
|
+
expect(parseTimecodeInput("1:02:60").kind).toBe(
|
|
93
|
+
"unparsed",
|
|
94
|
+
)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
test("a minutes field of 60 or more is refused whenever an hour sits beside it", () => {
|
|
98
|
+
expect(parseTimecodeInput("1:90:00").kind).toBe(
|
|
99
|
+
"unparsed",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
expect(refusalReason("1:90:00")).toContain(
|
|
103
|
+
"An hour has 60 minutes",
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
// …and is accepted when it is the largest field, which is the
|
|
107
|
+
// whole of the rule in one pair of cases.
|
|
108
|
+
expect(parsedMilliseconds("90:00")).toBe(5_400_000)
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
test("a negative is refused as out of bounds, not as unreadable", () => {
|
|
112
|
+
expect(parseTimecodeInput("-5").kind).toBe("unparsed")
|
|
113
|
+
|
|
114
|
+
expect(refusalReason("-5")).toContain("never negative")
|
|
115
|
+
|
|
116
|
+
expect(parseTimecodeInput("-1:30").kind).toBe("unparsed")
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
test("more than three parts is refused, and says how many there are", () => {
|
|
120
|
+
expect(parseTimecodeInput("1:2:3:4").kind).toBe(
|
|
121
|
+
"unparsed",
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
expect(refusalReason("1:2:3:4")).toContain(
|
|
125
|
+
"three parts at most",
|
|
126
|
+
)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
test("a fraction longer than three digits is refused rather than truncated", () => {
|
|
130
|
+
expect(parseTimecodeInput("1.5000").kind).toBe("unparsed")
|
|
131
|
+
|
|
132
|
+
expect(refusalReason("1.5000")).toContain(
|
|
133
|
+
"three digits at most",
|
|
134
|
+
)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
test("a partial or nonsense input never becomes a nearest guess", () => {
|
|
138
|
+
for (const text of [
|
|
139
|
+
"1:",
|
|
140
|
+
":30",
|
|
141
|
+
"1::30",
|
|
142
|
+
"abc",
|
|
143
|
+
"1m30s",
|
|
144
|
+
"1:30pm",
|
|
145
|
+
"--",
|
|
146
|
+
".",
|
|
147
|
+
"1.",
|
|
148
|
+
]) {
|
|
149
|
+
expect(parseTimecodeInput(text).kind, text).toBe(
|
|
150
|
+
"unparsed",
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
test("formatTimecode prints the canonical hh:mm:ss.mmm by default", () => {
|
|
156
|
+
expect(formatTimecode(0)).toBe("00:00:00.000")
|
|
157
|
+
expect(formatTimecode(90_000)).toBe("00:01:30.000")
|
|
158
|
+
expect(formatTimecode(3_723_500)).toBe("01:02:03.500")
|
|
159
|
+
expect(formatTimecode(36_000_000)).toBe("10:00:00.000")
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
test("what formatTimecode prints, parseTimecodeInput reads back unchanged", () => {
|
|
163
|
+
for (const milliseconds of [
|
|
164
|
+
0, 1, 999, 1_000, 90_000, 3_723_500, 5_400_000,
|
|
165
|
+
36_000_000,
|
|
166
|
+
]) {
|
|
167
|
+
expect(
|
|
168
|
+
parsedMilliseconds(formatTimecode(milliseconds)),
|
|
169
|
+
String(milliseconds),
|
|
170
|
+
).toBe(milliseconds)
|
|
171
|
+
}
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
test("isHoursShown false rolls the hours into the minutes, and still round-trips", () => {
|
|
175
|
+
expect(
|
|
176
|
+
formatTimecode(5_400_000, { isHoursShown: false }),
|
|
177
|
+
).toBe("90:00.000")
|
|
178
|
+
|
|
179
|
+
expect(parsedMilliseconds("90:00.000")).toBe(5_400_000)
|
|
180
|
+
|
|
181
|
+
expect(
|
|
182
|
+
formatTimecode(90_000, { isHoursShown: false }),
|
|
183
|
+
).toBe("01:30.000")
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
test("millisecondDigits truncates rather than rounding, because a position is not a measurement", () => {
|
|
187
|
+
// 1999 ms into a file is second 1. Rounding would print a seek
|
|
188
|
+
// point the media has not reached.
|
|
189
|
+
expect(
|
|
190
|
+
formatTimecode(1_999, { millisecondDigits: 0 }),
|
|
191
|
+
).toBe("00:00:01")
|
|
192
|
+
|
|
193
|
+
expect(
|
|
194
|
+
formatTimecode(1_999, { millisecondDigits: 1 }),
|
|
195
|
+
).toBe("00:00:01.9")
|
|
196
|
+
|
|
197
|
+
expect(
|
|
198
|
+
formatTimecode(1_999, { millisecondDigits: 2 }),
|
|
199
|
+
).toBe("00:00:01.99")
|
|
200
|
+
|
|
201
|
+
// Out-of-range digit counts are held rather than thrown, so a
|
|
202
|
+
// caller's off-by-one cannot produce `00:00:01.` with a trailing
|
|
203
|
+
// separator and nothing after it.
|
|
204
|
+
expect(
|
|
205
|
+
formatTimecode(1_999, { millisecondDigits: 9 }),
|
|
206
|
+
).toBe("00:00:01.999")
|
|
207
|
+
|
|
208
|
+
expect(
|
|
209
|
+
formatTimecode(1_999, { millisecondDigits: -1 }),
|
|
210
|
+
).toBe("00:00:01")
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
test("formatTimecode refuses to print a position that does not exist", () => {
|
|
214
|
+
expect(formatTimecode(-5_000)).toBe("00:00:00.000")
|
|
215
|
+
expect(formatTimecode(Number.NaN)).toBe("00:00:00.000")
|
|
216
|
+
|
|
217
|
+
expect(formatTimecode(Number.POSITIVE_INFINITY)).toBe(
|
|
218
|
+
"00:00:00.000",
|
|
219
|
+
)
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
test("clampTimecode floors at zero even when no minimum is given", () => {
|
|
223
|
+
expect(clampTimecode(-1)).toBe(0)
|
|
224
|
+
expect(clampTimecode(5_000)).toBe(5_000)
|
|
225
|
+
expect(clampTimecode(Number.NaN)).toBe(0)
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
test("clampTimecode holds a position inside the media", () => {
|
|
229
|
+
expect(clampTimecode(5_000, 10_000, 60_000)).toBe(10_000)
|
|
230
|
+
expect(clampTimecode(90_000, 10_000, 60_000)).toBe(60_000)
|
|
231
|
+
expect(clampTimecode(30_000, 10_000, 60_000)).toBe(30_000)
|
|
232
|
+
|
|
233
|
+
// An inverted pair of bounds resolves to the floor rather than
|
|
234
|
+
// silently widening the window a caller asked for.
|
|
235
|
+
expect(clampTimecode(30_000, 60_000, 10_000)).toBe(60_000)
|
|
236
|
+
})
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a human types into a timecode field, turned into a position
|
|
3
|
+
* in a piece of media — or into a stated reason it was not one.
|
|
4
|
+
*
|
|
5
|
+
* ### Milliseconds, and only milliseconds
|
|
6
|
+
*
|
|
7
|
+
* A media position is a **duration from the start of the file**, so
|
|
8
|
+
* the value is an integer count of milliseconds and nothing else. It
|
|
9
|
+
* is not a `Date`, it is not a clock time, and it is not a string:
|
|
10
|
+
* the only arithmetic anybody does with it is subtraction, and a
|
|
11
|
+
* number is the type that supports it. The props that carry one keep
|
|
12
|
+
* the `Ms` suffix, because a duration's unit is genuinely ambiguous
|
|
13
|
+
* and the fleet has both seconds and milliseconds in use
|
|
14
|
+
* ([decision](../../../../docs/decisions/2026-08-25-an-outbound-http-cache-names-its-fields-fetchedat-and-expiresat.md)).
|
|
15
|
+
*
|
|
16
|
+
* ### The fleet writes the printing half five times and the reading
|
|
17
|
+
* half never
|
|
18
|
+
*
|
|
19
|
+
* `formatTimecode` replaces five hand-rolled `h:mm:ss` printers —
|
|
20
|
+
* queuepilot's `clock` and `toClock`, castkit's `formatTime`, and
|
|
21
|
+
* mux-magic's two modal formatters — each with its own answer to
|
|
22
|
+
* whether the hour is shown and whether the minute is padded.
|
|
23
|
+
* `parseTimecodeInput` has no counterpart at all: nothing in the
|
|
24
|
+
* fleet *reads* a timecode a person typed, which is why the grammar
|
|
25
|
+
* gets the care here rather than being rediscovered per app.
|
|
26
|
+
*
|
|
27
|
+
* ### The rule that governs the grammar: never guess in silence
|
|
28
|
+
*
|
|
29
|
+
* The same three mechanisms `parseDateInput.ts` states, for the same
|
|
30
|
+
* reason:
|
|
31
|
+
*
|
|
32
|
+
* 1. **The grammar is ordered and total.** Each accepting rule is
|
|
33
|
+
* anchored on the *whole* input, the first match wins, and if
|
|
34
|
+
* none matches the answer is a named failure. `1:` is not one
|
|
35
|
+
* minute, and `1:90` is not `2:30`.
|
|
36
|
+
* 2. **An overflow is a failure with a name, not a carry.** `1:90`
|
|
37
|
+
* is refused, and the message offers both legal spellings —
|
|
38
|
+
* `2:30` and `90` — without committing to either. Silently
|
|
39
|
+
* carrying 90 seconds into the minute is the `1:60` trap
|
|
40
|
+
* mux-magic wrote down in
|
|
41
|
+
* `docs/audits/2026-06-29-pre-rename-domain-decisions.md`.
|
|
42
|
+
* 3. **Nothing commits without an echo.** `TimecodeInput` renders
|
|
43
|
+
* the resolved position in full underneath the field, in a live
|
|
44
|
+
* region, before anything commits.
|
|
45
|
+
*
|
|
46
|
+
* ### The largest field present is unbounded; every smaller one is
|
|
47
|
+
* 0–59
|
|
48
|
+
*
|
|
49
|
+
* One sentence covers every bound in the grammar. `90` is ninety
|
|
50
|
+
* **seconds**, `90:00` is ninety **minutes**, and `1:90` is refused
|
|
51
|
+
* because the minute beside it makes 90 a seconds field. That is why
|
|
52
|
+
* a bare number is seconds rather than minutes: seconds is the
|
|
53
|
+
* smallest unit the field prints, so the reading that needs no
|
|
54
|
+
* explanation is the one where the typist appended nothing.
|
|
55
|
+
*/
|
|
56
|
+
|
|
57
|
+
const MILLISECONDS_PER_SECOND = 1_000
|
|
58
|
+
|
|
59
|
+
const MILLISECONDS_PER_MINUTE = 60_000
|
|
60
|
+
|
|
61
|
+
const MILLISECONDS_PER_HOUR = 3_600_000
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* A clip window, in milliseconds. Keys alphabetised, matching
|
|
65
|
+
* `DateRange`.
|
|
66
|
+
*
|
|
67
|
+
* **Both ends are independently optional**, and the four
|
|
68
|
+
* combinations are four real states rather than three plus an error:
|
|
69
|
+
* no window at all, from here to the end of the media, from the
|
|
70
|
+
* beginning up to here, and the window between two points. An open
|
|
71
|
+
* start is not zero — it is the absence of a choice — which is what
|
|
72
|
+
* lets a half-open window be clamped without being compared.
|
|
73
|
+
*/
|
|
74
|
+
export type TimecodeRange = {
|
|
75
|
+
end: null | number
|
|
76
|
+
start: null | number
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export type TimecodeInputResult =
|
|
80
|
+
| { kind: "empty" }
|
|
81
|
+
| { kind: "timecode"; milliseconds: number }
|
|
82
|
+
| { kind: "unparsed"; reason: string }
|
|
83
|
+
|
|
84
|
+
export type FormatTimecodeOptions = {
|
|
85
|
+
/**
|
|
86
|
+
* `true` — the default — prints the hours field even when it is
|
|
87
|
+
* `00`, so every value the field writes has the same shape and the
|
|
88
|
+
* same width. `false` rolls the hours into the minutes
|
|
89
|
+
* (`5400000` → `90:00.000`), which the grammar reads back
|
|
90
|
+
* unchanged because the largest field present is unbounded.
|
|
91
|
+
*/
|
|
92
|
+
isHoursShown?: boolean
|
|
93
|
+
/** `0` through `3`. `0` drops the fraction and its separator. */
|
|
94
|
+
millisecondDigits?: number
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const padTwo = (value: number) =>
|
|
98
|
+
String(value).padStart(2, "0")
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A position in a piece of media, printed.
|
|
102
|
+
*
|
|
103
|
+
* Zero-padded, and **truncating** rather than rounding: at
|
|
104
|
+
* `millisecondDigits: 0` a position 1999 ms into a file is second 1,
|
|
105
|
+
* because it has not reached second 2 yet. Rounding would print a
|
|
106
|
+
* seek point the media has not played.
|
|
107
|
+
*
|
|
108
|
+
* The default is the canonical `hh:mm:ss.mmm`, and that is what the
|
|
109
|
+
* field writes back on commit. One spelling means a re-read of the
|
|
110
|
+
* field parses to the number that produced it, and a screenshot of
|
|
111
|
+
* the same value is the same picture — neither of which is true of
|
|
112
|
+
* the fleet's five printers, three of which hide the hour and two of
|
|
113
|
+
* which do not pad the minute.
|
|
114
|
+
*/
|
|
115
|
+
export const formatTimecode = (
|
|
116
|
+
milliseconds: number,
|
|
117
|
+
{
|
|
118
|
+
isHoursShown = true,
|
|
119
|
+
millisecondDigits = 3,
|
|
120
|
+
}: FormatTimecodeOptions = {},
|
|
121
|
+
): string => {
|
|
122
|
+
const total = Number.isFinite(milliseconds)
|
|
123
|
+
? Math.max(0, Math.floor(milliseconds))
|
|
124
|
+
: 0
|
|
125
|
+
|
|
126
|
+
const digits = Math.min(
|
|
127
|
+
3,
|
|
128
|
+
Math.max(0, Math.floor(millisecondDigits)),
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
const hours = isHoursShown
|
|
132
|
+
? Math.floor(total / MILLISECONDS_PER_HOUR)
|
|
133
|
+
: 0
|
|
134
|
+
|
|
135
|
+
const minutes = Math.floor(
|
|
136
|
+
(total - hours * MILLISECONDS_PER_HOUR) /
|
|
137
|
+
MILLISECONDS_PER_MINUTE,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
const seconds = Math.floor(
|
|
141
|
+
(total % MILLISECONDS_PER_MINUTE) /
|
|
142
|
+
MILLISECONDS_PER_SECOND,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
const fraction =
|
|
146
|
+
digits === 0
|
|
147
|
+
? ""
|
|
148
|
+
: `.${String(total % MILLISECONDS_PER_SECOND)
|
|
149
|
+
.padStart(3, "0")
|
|
150
|
+
.slice(0, digits)}`
|
|
151
|
+
|
|
152
|
+
return `${isHoursShown ? `${padTwo(hours)}:` : ""}${padTwo(minutes)}:${padTwo(seconds)}${fraction}`
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Hold a position inside the media.
|
|
157
|
+
*
|
|
158
|
+
* The floor is `0` when no `minimum` is given, because a media
|
|
159
|
+
* position before the start of the file does not exist — the same
|
|
160
|
+
* reason the grammar refuses a negative rather than clamping one
|
|
161
|
+
* silently. An inverted pair of bounds resolves to the floor: a
|
|
162
|
+
* caller whose `maximum` sits below its `minimum` has a bug, and
|
|
163
|
+
* quietly widening the window would hide it somewhere nobody looks.
|
|
164
|
+
*/
|
|
165
|
+
export const clampTimecode = (
|
|
166
|
+
milliseconds: number,
|
|
167
|
+
minimum?: number,
|
|
168
|
+
maximum?: number,
|
|
169
|
+
): number => {
|
|
170
|
+
const floor = Math.max(0, minimum ?? 0)
|
|
171
|
+
|
|
172
|
+
if (!Number.isFinite(milliseconds)) {
|
|
173
|
+
return floor
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const ceiling =
|
|
177
|
+
maximum === undefined || !Number.isFinite(maximum)
|
|
178
|
+
? Number.POSITIVE_INFINITY
|
|
179
|
+
: Math.max(floor, maximum)
|
|
180
|
+
|
|
181
|
+
return Math.min(Math.max(milliseconds, floor), ceiling)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* The three accepting rules, in the order they are tried.
|
|
186
|
+
*
|
|
187
|
+
* They are disjoint by colon count, so the order is a statement of
|
|
188
|
+
* discipline rather than a tie-breaker — but the discipline is what
|
|
189
|
+
* keeps a fourth rule from being appended as a nearest guess.
|
|
190
|
+
*
|
|
191
|
+
* The fraction separator may be a `.` or a `,`: half of Europe types
|
|
192
|
+
* the second one, both are unambiguous here because a timecode has
|
|
193
|
+
* no thousands separator, and the echo prints the canonical `.` back
|
|
194
|
+
* either way.
|
|
195
|
+
*/
|
|
196
|
+
const HOURS_MINUTES_SECONDS =
|
|
197
|
+
/^\+?(\d{1,5}):(\d{1,2}):(\d{1,2})(?:[.,](\d{1,3}))?$/
|
|
198
|
+
|
|
199
|
+
const MINUTES_SECONDS =
|
|
200
|
+
/^\+?(\d{1,7}):(\d{1,2})(?:[.,](\d{1,3}))?$/
|
|
201
|
+
|
|
202
|
+
const SECONDS = /^\+?(\d{1,9})(?:[.,](\d{1,3}))?$/
|
|
203
|
+
|
|
204
|
+
/** Anchored diagnostics, tried only after every accepting rule has failed. */
|
|
205
|
+
const NEGATIVE = /^-/
|
|
206
|
+
|
|
207
|
+
const TOO_MANY_PARTS = /^\+?\d+(?::\d+){3,}(?:[.,]\d+)?$/
|
|
208
|
+
|
|
209
|
+
const LONG_FRACTION = /^\+?\d+(?::\d+){0,2}[.,]\d{4,}$/
|
|
210
|
+
|
|
211
|
+
const unparsed = (reason: string): TimecodeInputResult => ({
|
|
212
|
+
kind: "unparsed",
|
|
213
|
+
reason,
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* A field that overflowed, named rather than carried.
|
|
218
|
+
*
|
|
219
|
+
* The message OFFERS both legal spellings and commits to neither,
|
|
220
|
+
* which is the whole difference between this and a parser that
|
|
221
|
+
* "just works": carrying the overflow moves a clip start by a whole
|
|
222
|
+
* minute and nothing ever says so. Offering the carried form is not
|
|
223
|
+
* the same as taking it — the typist reads two options and picks,
|
|
224
|
+
* and either pick is one keystroke away.
|
|
225
|
+
*/
|
|
226
|
+
const describeOverflow = ({
|
|
227
|
+
carriedMilliseconds,
|
|
228
|
+
dropped,
|
|
229
|
+
isHoursShown,
|
|
230
|
+
larger,
|
|
231
|
+
typed,
|
|
232
|
+
unit,
|
|
233
|
+
}: {
|
|
234
|
+
carriedMilliseconds: number
|
|
235
|
+
dropped: string
|
|
236
|
+
isHoursShown: boolean
|
|
237
|
+
larger: string
|
|
238
|
+
typed: string
|
|
239
|
+
unit: "minute" | "second"
|
|
240
|
+
}) =>
|
|
241
|
+
`${larger} has 60 ${unit}s, so "${typed}" is not a ${unit}s field. Type ${formatTimecode(
|
|
242
|
+
carriedMilliseconds,
|
|
243
|
+
{ isHoursShown, millisecondDigits: 0 },
|
|
244
|
+
)} to carry it, or ${dropped} to mean ${typed} ${unit}s.`
|
|
245
|
+
|
|
246
|
+
const toMilliseconds = ({
|
|
247
|
+
fraction,
|
|
248
|
+
hours,
|
|
249
|
+
minutes,
|
|
250
|
+
seconds,
|
|
251
|
+
}: {
|
|
252
|
+
fraction: string | undefined
|
|
253
|
+
hours?: string
|
|
254
|
+
minutes?: string
|
|
255
|
+
seconds: string
|
|
256
|
+
}): TimecodeInputResult => {
|
|
257
|
+
const total =
|
|
258
|
+
Number(hours ?? 0) * MILLISECONDS_PER_HOUR +
|
|
259
|
+
Number(minutes ?? 0) * MILLISECONDS_PER_MINUTE +
|
|
260
|
+
Number(seconds) * MILLISECONDS_PER_SECOND
|
|
261
|
+
|
|
262
|
+
// The largest field present is unbounded; every smaller one is
|
|
263
|
+
// 0–59. Checked outermost-first, so `1:90:90` reports the minute —
|
|
264
|
+
// the field a reader fixes first.
|
|
265
|
+
if (
|
|
266
|
+
hours !== undefined &&
|
|
267
|
+
minutes !== undefined &&
|
|
268
|
+
Number(minutes) > 59
|
|
269
|
+
) {
|
|
270
|
+
return unparsed(
|
|
271
|
+
describeOverflow({
|
|
272
|
+
carriedMilliseconds: total,
|
|
273
|
+
dropped: `${minutes}:${seconds}`,
|
|
274
|
+
isHoursShown: true,
|
|
275
|
+
larger: "An hour",
|
|
276
|
+
typed: minutes,
|
|
277
|
+
unit: "minute",
|
|
278
|
+
}),
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (minutes !== undefined && Number(seconds) > 59) {
|
|
283
|
+
return unparsed(
|
|
284
|
+
describeOverflow({
|
|
285
|
+
carriedMilliseconds: total,
|
|
286
|
+
dropped: seconds,
|
|
287
|
+
isHoursShown: hours !== undefined,
|
|
288
|
+
larger: "A minute",
|
|
289
|
+
typed: seconds,
|
|
290
|
+
unit: "second",
|
|
291
|
+
}),
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// `.5` is half a second, not five milliseconds — a fraction is a
|
|
296
|
+
// decimal fraction of a second, so it pads on the RIGHT.
|
|
297
|
+
return {
|
|
298
|
+
kind: "timecode",
|
|
299
|
+
milliseconds:
|
|
300
|
+
total +
|
|
301
|
+
(fraction === undefined
|
|
302
|
+
? 0
|
|
303
|
+
: Number(fraction.padEnd(3, "0"))),
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Read a typed timecode, or say why it is not one.
|
|
309
|
+
*
|
|
310
|
+
* Takes no options, deliberately. Every knob considered belongs
|
|
311
|
+
* somewhere else: a maximum is the field's clamp at commit rather
|
|
312
|
+
* than a parse failure (two sources for one fact is how a control
|
|
313
|
+
* ends up invalid with nothing saying why), the decimal comma is
|
|
314
|
+
* accepted unconditionally instead of being a locale switch, and a
|
|
315
|
+
* frame rate makes this a different widget. An options bag that
|
|
316
|
+
* means nothing is API a consumer has to read before learning there
|
|
317
|
+
* is nothing in it.
|
|
318
|
+
*/
|
|
319
|
+
export const parseTimecodeInput = (
|
|
320
|
+
rawText: string,
|
|
321
|
+
): TimecodeInputResult => {
|
|
322
|
+
const text = rawText.replace(/\s/g, "")
|
|
323
|
+
|
|
324
|
+
if (text === "") {
|
|
325
|
+
return { kind: "empty" }
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// A leading `-` is refused before anything else. Every accepting
|
|
329
|
+
// rule below would otherwise fail on it and report the generic
|
|
330
|
+
// message, which tells a typist that `-5` is unreadable rather
|
|
331
|
+
// than that it is out of bounds.
|
|
332
|
+
if (NEGATIVE.test(text)) {
|
|
333
|
+
return unparsed(
|
|
334
|
+
"A timecode is never negative. The start of the media is 0.",
|
|
335
|
+
)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const hoursMinutesSeconds =
|
|
339
|
+
HOURS_MINUTES_SECONDS.exec(text)
|
|
340
|
+
|
|
341
|
+
if (
|
|
342
|
+
hoursMinutesSeconds?.[1] !== undefined &&
|
|
343
|
+
hoursMinutesSeconds[2] !== undefined &&
|
|
344
|
+
hoursMinutesSeconds[3] !== undefined
|
|
345
|
+
) {
|
|
346
|
+
return toMilliseconds({
|
|
347
|
+
fraction: hoursMinutesSeconds[4],
|
|
348
|
+
hours: hoursMinutesSeconds[1],
|
|
349
|
+
minutes: hoursMinutesSeconds[2],
|
|
350
|
+
seconds: hoursMinutesSeconds[3],
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const minutesSeconds = MINUTES_SECONDS.exec(text)
|
|
355
|
+
|
|
356
|
+
if (
|
|
357
|
+
minutesSeconds?.[1] !== undefined &&
|
|
358
|
+
minutesSeconds[2] !== undefined
|
|
359
|
+
) {
|
|
360
|
+
return toMilliseconds({
|
|
361
|
+
fraction: minutesSeconds[3],
|
|
362
|
+
minutes: minutesSeconds[1],
|
|
363
|
+
seconds: minutesSeconds[2],
|
|
364
|
+
})
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const seconds = SECONDS.exec(text)
|
|
368
|
+
|
|
369
|
+
if (seconds?.[1] !== undefined) {
|
|
370
|
+
return toMilliseconds({
|
|
371
|
+
fraction: seconds[2],
|
|
372
|
+
seconds: seconds[1],
|
|
373
|
+
})
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (TOO_MANY_PARTS.test(text)) {
|
|
377
|
+
return unparsed(
|
|
378
|
+
"A timecode has three parts at most — hours, minutes, seconds. Try 1:02:03.",
|
|
379
|
+
)
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (LONG_FRACTION.test(text)) {
|
|
383
|
+
return unparsed(
|
|
384
|
+
"The fraction is milliseconds — three digits at most. Try 1:02.500.",
|
|
385
|
+
)
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
return unparsed(
|
|
389
|
+
"Not a timecode. Try 90, 1:30, 1:02:03, or 1:02:03.500.",
|
|
390
|
+
)
|
|
391
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -349,6 +349,12 @@ export type {
|
|
|
349
349
|
RailSide,
|
|
350
350
|
} from "./Rail/Rail.tsx"
|
|
351
351
|
export { Rail } from "./Rail/Rail.tsx"
|
|
352
|
+
export type {
|
|
353
|
+
RangeSliderProps,
|
|
354
|
+
RangeSliderTick,
|
|
355
|
+
} from "./RangeSlider/RangeSlider.tsx"
|
|
356
|
+
export { RangeSlider } from "./RangeSlider/RangeSlider.tsx"
|
|
357
|
+
export type { RangeSliderValue } from "./RangeSlider/rangeSliderValue.ts"
|
|
352
358
|
export type {
|
|
353
359
|
ReorderListItem,
|
|
354
360
|
ReorderListProps,
|
|
@@ -448,6 +454,18 @@ export type {
|
|
|
448
454
|
TextLinkProps,
|
|
449
455
|
} from "./TextLink/TextLink.tsx"
|
|
450
456
|
export { TextLink } from "./TextLink/TextLink.tsx"
|
|
457
|
+
export type { TimecodeInputProps } from "./TimecodeInput/TimecodeInput.tsx"
|
|
458
|
+
export { TimecodeInput } from "./TimecodeInput/TimecodeInput.tsx"
|
|
459
|
+
export type {
|
|
460
|
+
FormatTimecodeOptions,
|
|
461
|
+
TimecodeInputResult,
|
|
462
|
+
TimecodeRange,
|
|
463
|
+
} from "./TimecodeInput/timecode.ts"
|
|
464
|
+
export {
|
|
465
|
+
clampTimecode,
|
|
466
|
+
formatTimecode,
|
|
467
|
+
parseTimecodeInput,
|
|
468
|
+
} from "./TimecodeInput/timecode.ts"
|
|
451
469
|
export type { ToastProps } from "./Toast/Toast.tsx"
|
|
452
470
|
export { Toast } from "./Toast/Toast.tsx"
|
|
453
471
|
export type {
|