@crievo/omni 0.1.3 → 0.1.4
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/package.json +2 -1
- package/reference/learnings.md +12 -0
- package/studio/api/app.ts +2 -2
- package/studio/api/routes/sound.ts +22 -3
- package/studio/api/services/sound.ts +45 -0
- package/studio/studio.ts +1 -0
- package/studio/web/components/AppShell.tsx +3 -1
- package/studio/web/components/CloneVoiceDialog/RecordStep.tsx +3 -0
- package/studio/web/components/CloneVoiceDialog/RecorderBar.tsx +37 -20
- package/studio/web/components/CloneVoiceDialog/useCloneVoice.ts +39 -12
- package/studio/web/components/MissingDmsPanel/BatchSend.tsx +13 -4
- package/studio/web/components/Omni/OmniWidget.tsx +14 -5
- package/studio/web/components/Omni/PendingActions.tsx +25 -6
- package/studio/web/components/OmnichatRulePanel/RuleConfig.tsx +13 -3
- package/studio/web/components/OmnichatRulePanel/RuleEnableConfirm.tsx +14 -4
- package/studio/web/components/ResultMessage.tsx +12 -3
- package/studio/web/components/Sidebar.tsx +63 -24
- package/studio/web/components/StageStepper.tsx +105 -34
- package/studio/web/components/Toaster.tsx +25 -0
- package/studio/web/components/VoiceCarousel.tsx +19 -16
- package/studio/web/components/VoiceProfilesSettings.tsx +8 -3
- package/studio/web/components/bencho/ActionNode.tsx +456 -0
- package/studio/web/components/bencho/AssigneePicker.tsx +246 -0
- package/studio/web/components/bencho/BenchoScope.tsx +8 -0
- package/studio/web/components/bencho/Checklist.tsx +421 -0
- package/studio/web/components/bencho/CreateMenu.tsx +258 -0
- package/studio/web/components/bencho/GenerateImage.tsx +290 -0
- package/studio/web/components/bencho/ImageAccordion.tsx +273 -0
- package/studio/web/components/bencho/LiquidToggle.tsx +289 -0
- package/studio/web/components/bencho/MagnifyDock.tsx +200 -0
- package/studio/web/components/bencho/NowPlaying.tsx +403 -0
- package/studio/web/components/bencho/OneTimeCode.tsx +374 -0
- package/studio/web/components/bencho/PullToRefresh.tsx +527 -0
- package/studio/web/components/bencho/SlideToConfirm.tsx +385 -0
- package/studio/web/components/bencho/SloshSlider.tsx +394 -0
- package/studio/web/components/bencho/StackedBars.tsx +342 -0
- package/studio/web/components/bencho/ToastStack.tsx +263 -0
- package/studio/web/components/bencho/UploadDropzone.tsx +394 -0
- package/studio/web/components/bencho/VoiceNote.tsx +705 -0
- package/studio/web/components/icons.tsx +27 -0
- package/studio/web/components/ui/slider.tsx +34 -1
- package/studio/web/components/ui/switch.tsx +18 -5
- package/studio/web/index.html +1 -1
- package/studio/web/lib/omni-panel.ts +4 -1
- package/studio/web/lib/toast.ts +196 -0
- package/studio/web/lib/upload.ts +24 -0
- package/studio/web/pages/BenchoPage/index.tsx +74 -0
- package/studio/web/pages/BenchoPage/registry.ts +174 -0
- package/studio/web/pages/NewVideoPage/index.tsx +6 -2
- package/studio/web/pages/ReferencesPage/index.tsx +4 -1
- package/studio/web/pages/VideoPage/components/ChangedVoices.tsx +14 -17
- package/studio/web/pages/VideoPage/components/LineTextEditor.tsx +1 -1
- package/studio/web/pages/VideoPage/components/PublishForm.tsx +24 -17
- package/studio/web/pages/VideoPage/components/ScheduledInfo.tsx +2 -1
- package/studio/web/pages/VideoPage/components/SoundTimeline/AddSound.tsx +12 -14
- package/studio/web/pages/VideoPage/components/SoundTimeline/BedRow.tsx +27 -11
- package/studio/web/pages/VideoPage/components/SoundTimeline/CaptionSaveBar.tsx +22 -18
- package/studio/web/pages/VideoPage/components/SoundTimeline/TimelineFooter.tsx +3 -4
- package/studio/web/pages/VideoPage/components/SoundTimeline/UploadTrack.tsx +59 -0
- package/studio/web/pages/VideoPage/components/SoundTimeline/useSoundTimeline.ts +1 -0
- package/studio/web/pages/VideoPage/components/VoicePauseBanner.tsx +4 -6
- package/studio/web/pages/VideoPage/index.tsx +4 -0
- package/studio/web/pages/VideosPage/components/VideoRow.tsx +2 -4
- package/studio/web/router.tsx +8 -0
- package/studio/web/styles/components/bencho/action-node.css +227 -0
- package/studio/web/styles/components/bencho/checklist.css +182 -0
- package/studio/web/styles/components/bencho/dock.css +188 -0
- package/studio/web/styles/components/bencho/gallery.css +109 -0
- package/studio/web/styles/components/bencho/generate.css +151 -0
- package/studio/web/styles/components/bencho/image-accordion.css +37 -0
- package/studio/web/styles/components/bencho/liq-create.css +279 -0
- package/studio/web/styles/components/bencho/liq-toggle.css +106 -0
- package/studio/web/styles/components/bencho/one-time-code.css +148 -0
- package/studio/web/styles/components/bencho/picker.css +204 -0
- package/studio/web/styles/components/bencho/pull.css +313 -0
- package/studio/web/styles/components/bencho/scope.css +42 -0
- package/studio/web/styles/components/bencho/slide-confirm.css +139 -0
- package/studio/web/styles/components/bencho/slosh.css +137 -0
- package/studio/web/styles/components/bencho/sound.css +260 -0
- package/studio/web/styles/components/bencho/stacked-bars.css +334 -0
- package/studio/web/styles/components/bencho/toast-stack.css +192 -0
- package/studio/web/styles/components/bencho/tokens.css +243 -0
- package/studio/web/styles/components/bencho/upload-dropzone.css +352 -0
- package/studio/web/styles/components/bencho/voice-note.css +337 -0
- package/studio/web/styles/components/clone-voice.css +12 -2
- package/studio/web/styles/components/omni.css +6 -1
- package/studio/web/styles/components/stepper.css +123 -7
- package/studio/web/styles/components/toaster.css +10 -0
- package/studio/web/styles/components/ui/slider.css +6 -16
- package/studio/web/styles/components/ui/switch.css +2 -41
- package/studio/web/styles/components/voice-carousel.css +46 -4
- package/studio/web/styles/index.css +23 -0
- package/studio/web/styles/pages/video/layout.css +1 -4
- package/studio/web/styles/pages/video/sound-timeline.css +8 -0
- package/studio/web/styles/theme/identity/shell.css +10 -26
- package/tools/sound.mjs +67 -3
|
@@ -0,0 +1,385 @@
|
|
|
1
|
+
/* Slide to confirm: a handle you push across a track. It follows the pointer exactly, and past the mark it takes over
|
|
2
|
+
and finishes the journey itself.
|
|
3
|
+
Ported from bencho.dev (MIT).
|
|
4
|
+
|
|
5
|
+
The handle becomes the answer. On commit it unfurls leftward and fills the track it was crossing, and the arrow it
|
|
6
|
+
carried becomes a check: one object changing shape, so no second element is needed to say "done".
|
|
7
|
+
The right edge must not move meanwhile: the width grows by exactly what the offset loses (`GRIP + (anchor - x)`
|
|
8
|
+
against `PAD + x`), so the edge is stationary by arithmetic and not by two springs agreeing (two springs drifted
|
|
9
|
+
6.8px apart). */
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
type KeyboardEvent as ReactKeyboardEvent,
|
|
13
|
+
type PointerEvent as ReactPointerEvent,
|
|
14
|
+
useEffect,
|
|
15
|
+
useLayoutEffect,
|
|
16
|
+
useRef,
|
|
17
|
+
useState,
|
|
18
|
+
} from "react";
|
|
19
|
+
import { animate, motion, useMotionValue, useTransform } from "motion/react";
|
|
20
|
+
import { ArrowRight, Check } from "../icons";
|
|
21
|
+
|
|
22
|
+
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
|
23
|
+
|
|
24
|
+
const SPAN = 280;
|
|
25
|
+
const H = 56;
|
|
26
|
+
/* the inset the handle keeps from the track, all four sides */
|
|
27
|
+
const PAD = 4;
|
|
28
|
+
/* the handle is sized by the height; widening the track buys travel, not a longer handle */
|
|
29
|
+
const GRIP = H - PAD * 2;
|
|
30
|
+
/* below this the centred label starts to sit under the resting handle */
|
|
31
|
+
const MIN = 220;
|
|
32
|
+
const MAX = 380;
|
|
33
|
+
|
|
34
|
+
/* a pill, and the top of the corner knob */
|
|
35
|
+
const CORNER = H / 2;
|
|
36
|
+
const SPEED = 50;
|
|
37
|
+
|
|
38
|
+
/* the dot sits 4px inside the track, so the ring around it is the whole budget for a hover: 3% of 48 is 0.7 a side */
|
|
39
|
+
const SWELL = 1.03;
|
|
40
|
+
|
|
41
|
+
/* how long the finished state stands before it resets */
|
|
42
|
+
const HOLD = 1500;
|
|
43
|
+
/* a refused action does not celebrate: the handle stays long enough to land, then goes home so the slide can be tried again */
|
|
44
|
+
const REFUSED = 450;
|
|
45
|
+
/* a fluid track still needs room for the label beside the resting handle, but no ceiling: it fills what it is given */
|
|
46
|
+
const FLUID_MIN = 200;
|
|
47
|
+
|
|
48
|
+
export function SlideToConfirm({
|
|
49
|
+
/* the track's corner, px; the handle's is this less the inset, which keeps the two curves parallel */
|
|
50
|
+
corner = CORNER,
|
|
51
|
+
/* how quickly it takes over once you let go, 0..100 */
|
|
52
|
+
speed = SPEED,
|
|
53
|
+
/* the track's length, px; the mark and the label and arrow fades are fractions of the travel, so all follow it.
|
|
54
|
+
"fill" takes the width of the container instead (measured, so the same maths holds at any size) */
|
|
55
|
+
width = SPAN,
|
|
56
|
+
/* the words on the track, and the verb in the label tells what leaves the studio: it is the whole warning */
|
|
57
|
+
label = "Deslize para confirmar",
|
|
58
|
+
/* what the finished handle says, and what it says while the action is still running */
|
|
59
|
+
doneLabel = "Confirmado",
|
|
60
|
+
busyLabel = "Enviando…",
|
|
61
|
+
/* the action is still running after the slide (the owner's own flag, for a request this component did not start) */
|
|
62
|
+
busy = false,
|
|
63
|
+
disabled = false,
|
|
64
|
+
/* runs once, only when the handle reaches the mark. A rejection, or a resolved `false`, means it did not go through:
|
|
65
|
+
the handle goes home without the check, ready for another try */
|
|
66
|
+
onConfirm,
|
|
67
|
+
className,
|
|
68
|
+
}: {
|
|
69
|
+
corner?: number;
|
|
70
|
+
speed?: number;
|
|
71
|
+
width?: number | "fill";
|
|
72
|
+
label?: string;
|
|
73
|
+
doneLabel?: string;
|
|
74
|
+
busyLabel?: string;
|
|
75
|
+
busy?: boolean;
|
|
76
|
+
disabled?: boolean;
|
|
77
|
+
onConfirm?: () => Promise<unknown> | void;
|
|
78
|
+
className?: string;
|
|
79
|
+
} = {}) {
|
|
80
|
+
const [done, setDone] = useState(false);
|
|
81
|
+
const [held, setHeld] = useState(false);
|
|
82
|
+
const [hot, setHot] = useState(false);
|
|
83
|
+
/* the action started by the commit: running, then how it ended (null until it has) */
|
|
84
|
+
const [running, setRunning] = useState(false);
|
|
85
|
+
const [outcome, setOutcome] = useState<boolean | null>(null);
|
|
86
|
+
const [measured, setMeasured] = useState(SPAN);
|
|
87
|
+
const root = useRef<HTMLDivElement | null>(null);
|
|
88
|
+
const alive = useRef(true);
|
|
89
|
+
const track = useRef<HTMLDivElement | null>(null);
|
|
90
|
+
/* the live grab: a ref, since it changes on every frame of a gesture */
|
|
91
|
+
const grip = useRef<{ id: number; grab: number | null; moved: boolean } | null>(null);
|
|
92
|
+
const beat = useRef(0);
|
|
93
|
+
/* the keyboard's slide is under way */
|
|
94
|
+
const sliding = useRef(false);
|
|
95
|
+
|
|
96
|
+
const x = useMotionValue(0);
|
|
97
|
+
/* where the handle planted itself: zero except while unfurling, then the offset it had when it committed */
|
|
98
|
+
const anchor = useMotionValue(0);
|
|
99
|
+
/* the settle: a dip of about 3% and back, so the unfurl lands instead of stopping. On the track, so the whole block
|
|
100
|
+
gives at once. Its own clock, not the spring's (the spring is nearly there for most of its run) */
|
|
101
|
+
const pulse = useMotionValue(1);
|
|
102
|
+
/* the arrow's own fade, so it leaves on the commit rather than on x */
|
|
103
|
+
const shown = useMotionValue(1);
|
|
104
|
+
|
|
105
|
+
/* the width is read every render: the derived values below re-run with it */
|
|
106
|
+
const fluid = width === "fill";
|
|
107
|
+
const span = fluid ? Math.max(FLUID_MIN, measured) : clamp(Math.round(width), MIN, MAX);
|
|
108
|
+
const working = running || busy;
|
|
109
|
+
const idle = !disabled || done;
|
|
110
|
+
/* a refused action leaves the handle blank on its way home: neither the check nor the spinner would be true */
|
|
111
|
+
const said = done && outcome !== false;
|
|
112
|
+
const TRAVEL = span - PAD * 2 - GRIP;
|
|
113
|
+
|
|
114
|
+
const r = clamp(corner, 0, CORNER);
|
|
115
|
+
/* `r - PAD`, floored at zero: at r = 0 both are square together */
|
|
116
|
+
const gripR = Math.max(0, r - PAD);
|
|
117
|
+
/* the end is the commit and there is no knob: firing at 60% would let a knock on the handle confirm */
|
|
118
|
+
const mark = TRAVEL;
|
|
119
|
+
|
|
120
|
+
/* speed, deliberately not bounce: the shape has a hard wall at both ends, and on commit the handle exactly fills the
|
|
121
|
+
track, so an overshoot came out of the left edge (42px outside at bounce 50). Damping is derived to sit on
|
|
122
|
+
critical at any stiffness: c = 2 * sqrt(k * m) */
|
|
123
|
+
const stiff = 260 + (clamp(speed, 0, 100) / 100) * 640;
|
|
124
|
+
const spring = {
|
|
125
|
+
type: "spring" as const,
|
|
126
|
+
stiffness: stiff,
|
|
127
|
+
damping: 2 * Math.sqrt(stiff * 0.9),
|
|
128
|
+
mass: 0.9,
|
|
129
|
+
};
|
|
130
|
+
/* the return is 0.62 of critical so it arrives with something left over; the overshoot never shows as movement
|
|
131
|
+
(`seen` clamps at the wall), it shows as the squash below */
|
|
132
|
+
const home = { ...spring, damping: 2 * Math.sqrt(stiff * 0.9) * 0.62 };
|
|
133
|
+
|
|
134
|
+
/* offsetWidth, not a rect: a rect is scaled by any transform above, and the maths below wants the laid-out width.
|
|
135
|
+
Before paint, so a fluid track never shows once at the demo width */
|
|
136
|
+
useLayoutEffect(() => {
|
|
137
|
+
const el = root.current;
|
|
138
|
+
if (!fluid || !el) return;
|
|
139
|
+
const read = () => setMeasured(Math.round(el.offsetWidth));
|
|
140
|
+
read();
|
|
141
|
+
const ro = new ResizeObserver(read);
|
|
142
|
+
ro.observe(el);
|
|
143
|
+
return () => ro.disconnect();
|
|
144
|
+
}, [fluid]);
|
|
145
|
+
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
alive.current = true;
|
|
148
|
+
return () => {
|
|
149
|
+
alive.current = false;
|
|
150
|
+
window.clearTimeout(beat.current);
|
|
151
|
+
loose.current?.();
|
|
152
|
+
};
|
|
153
|
+
}, []);
|
|
154
|
+
|
|
155
|
+
/* the finished state stands once the action has ended, not from the commit: while it runs the handle says so. The
|
|
156
|
+
reset waits for the owner's own busy flag too, so a request this component did not start is not cut short */
|
|
157
|
+
useEffect(() => {
|
|
158
|
+
if (!done || working || outcome === null) return;
|
|
159
|
+
beat.current = window.setTimeout(rewind, outcome ? HOLD : REFUSED);
|
|
160
|
+
return () => window.clearTimeout(beat.current);
|
|
161
|
+
}, [done, working, outcome]);
|
|
162
|
+
|
|
163
|
+
/* The drag is followed on the window. Pointer capture is best-effort (it throws on a synthetic id and a browser can
|
|
164
|
+
drop it mid-gesture), and a release landing elsewhere left the handle held at the far end forever. Listening on
|
|
165
|
+
the window means a release anywhere ends the drag. Bound at the press, not in an effect: an effect waits for the
|
|
166
|
+
commit, and a fast flick can put its first pointermove, the one that sets the grab offset, in that gap. */
|
|
167
|
+
const loose = useRef<(() => void) | null>(null);
|
|
168
|
+
/* handlers are rebuilt every render and the listeners are not, so they are reached through a ref */
|
|
169
|
+
const live = useRef<{
|
|
170
|
+
move: (e: PointerEvent) => void;
|
|
171
|
+
up: (e: PointerEvent) => void;
|
|
172
|
+
}>({ move: () => {}, up: () => {} });
|
|
173
|
+
|
|
174
|
+
const watch = () => {
|
|
175
|
+
loose.current?.();
|
|
176
|
+
const onMove = (e: PointerEvent) => live.current.move(e);
|
|
177
|
+
const onUp = (e: PointerEvent) => {
|
|
178
|
+
live.current.up(e);
|
|
179
|
+
loose.current?.();
|
|
180
|
+
};
|
|
181
|
+
window.addEventListener("pointermove", onMove);
|
|
182
|
+
window.addEventListener("pointerup", onUp);
|
|
183
|
+
window.addEventListener("pointercancel", onUp);
|
|
184
|
+
loose.current = () => {
|
|
185
|
+
window.removeEventListener("pointermove", onMove);
|
|
186
|
+
window.removeEventListener("pointerup", onUp);
|
|
187
|
+
window.removeEventListener("pointercancel", onUp);
|
|
188
|
+
loose.current = null;
|
|
189
|
+
};
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
/* everything else is read off x: the wash behind the handle, the label giving up its ink and the arrow fading near
|
|
193
|
+
the end are three readings of one number. Both readings clamp: `seen` is what the transform uses, `x` stays the
|
|
194
|
+
honest value the drag wrote */
|
|
195
|
+
const seen = useTransform(x, v => clamp(v, 0, TRAVEL));
|
|
196
|
+
const wide = useTransform([seen, anchor], ([v, a]: number[]) => GRIP + clamp(a - v, 0, TRAVEL));
|
|
197
|
+
|
|
198
|
+
/* the overshoot becomes a squash: at zero the handle is already against the end of its track, so the energy that
|
|
199
|
+
would carry it past turns into compression (the two scales multiply to keep area). Origin at the left edge, the
|
|
200
|
+
wall it hit. Capped at 8%: the return overshoots by about 18 units, so the cap is the squash (14 read as a squish) */
|
|
201
|
+
const over = useTransform(x, v => Math.max(0, -v));
|
|
202
|
+
const squash = useTransform(over, o => 1 - Math.min(0.08, o / 110));
|
|
203
|
+
const wash = useTransform(seen, v => v + GRIP);
|
|
204
|
+
const say = useTransform(seen, [0, TRAVEL * 0.55], [1, 0]);
|
|
205
|
+
/* read off x alone the arrow faded back in during the unfurl (x returns to zero), under the word that replaced it;
|
|
206
|
+
`shown` is switched by the commit itself */
|
|
207
|
+
const arrow = useTransform(
|
|
208
|
+
[seen, shown],
|
|
209
|
+
([v, on]: number[]) => on * clamp(1 - (v - TRAVEL * 0.55) / (TRAVEL * 0.4), 0, 1),
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
/* the swell is multiplied into the squash transform instead of set as its own `scale` property, which applies first
|
|
213
|
+
and would move the handle along the track it sits on */
|
|
214
|
+
const sx = useTransform(squash, q => q * (hot && !held && !done ? SWELL : 1));
|
|
215
|
+
const sy = useTransform(squash, q => (1 / q) * (hot && !held && !done ? SWELL : 1));
|
|
216
|
+
|
|
217
|
+
const local = (clientX: number) => {
|
|
218
|
+
const box = track.current?.getBoundingClientRect();
|
|
219
|
+
if (!box) return 0;
|
|
220
|
+
/* the block can be drawn at a fraction of its own size: the rect's width against the laid-out width is that scale */
|
|
221
|
+
const k = box.width / span;
|
|
222
|
+
return (clientX - box.left) / (k || 1);
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const finish = () => {
|
|
226
|
+
setDone(true);
|
|
227
|
+
/* x goes to zero and that is the whole morph: x loses TRAVEL while width gains it, so the right edge stays put.
|
|
228
|
+
Sending x to TRAVEL instead left the handle at the end AND grown to full width, spilling over its frame. Both
|
|
229
|
+
values ride the same spring, which keeps them in step frame by frame */
|
|
230
|
+
anchor.set(x.get());
|
|
231
|
+
animate(shown, 0, { duration: 0.16 });
|
|
232
|
+
animate(x, 0, spring);
|
|
233
|
+
animate(pulse, [1, 0.974, 1], {
|
|
234
|
+
duration: 0.42,
|
|
235
|
+
times: [0, 0.62, 1],
|
|
236
|
+
ease: [0.33, 0.55, 0.2, 1],
|
|
237
|
+
/* a beat behind the unfurl, so the landing dips rather than the take-off */
|
|
238
|
+
delay: 0.1,
|
|
239
|
+
});
|
|
240
|
+
setOutcome(null);
|
|
241
|
+
setRunning(true);
|
|
242
|
+
Promise.resolve()
|
|
243
|
+
.then(onConfirm)
|
|
244
|
+
.then(
|
|
245
|
+
r => r !== false,
|
|
246
|
+
() => false,
|
|
247
|
+
)
|
|
248
|
+
.then(ok => {
|
|
249
|
+
if (!alive.current) return;
|
|
250
|
+
setRunning(false);
|
|
251
|
+
setOutcome(ok);
|
|
252
|
+
});
|
|
253
|
+
};
|
|
254
|
+
|
|
255
|
+
/* the reset is the anchor coming home with x already at zero: the right edge sweeps left, the unfurl backwards */
|
|
256
|
+
const rewind = () => {
|
|
257
|
+
setDone(false);
|
|
258
|
+
setOutcome(null);
|
|
259
|
+
animate(shown, 1, { duration: 0.22, delay: 0.12 });
|
|
260
|
+
animate(anchor, 0, { type: "spring", stiffness: 380, damping: 34, mass: 0.9 });
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
const down = (e: ReactPointerEvent) => {
|
|
264
|
+
if (done || disabled) return;
|
|
265
|
+
e.stopPropagation();
|
|
266
|
+
/* no offset yet: it is taken at the first move, so a press away from the handle does not teleport it; that move
|
|
267
|
+
asks for the position the handle already has and every later one is a delta */
|
|
268
|
+
grip.current = { id: e.pointerId, grab: null, moved: false };
|
|
269
|
+
setHeld(true);
|
|
270
|
+
/* throws on a pointer id that is not live (a synthetic event); the drag works without it */
|
|
271
|
+
try {
|
|
272
|
+
track.current?.setPointerCapture(e.pointerId);
|
|
273
|
+
} catch {
|
|
274
|
+
/* not live */
|
|
275
|
+
}
|
|
276
|
+
watch();
|
|
277
|
+
};
|
|
278
|
+
|
|
279
|
+
/* both handlers take the native event too, since the ones that matter arrive from the window */
|
|
280
|
+
const move = (e: PointerEvent | ReactPointerEvent) => {
|
|
281
|
+
const g = grip.current;
|
|
282
|
+
if (!g || g.id !== e.pointerId) return;
|
|
283
|
+
const at = local(e.clientX);
|
|
284
|
+
if (g.grab === null) {
|
|
285
|
+
g.grab = at - x.get();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const next = clamp(at - g.grab, 0, TRAVEL);
|
|
289
|
+
if (Math.abs(next - x.get()) > 0.5) g.moved = true;
|
|
290
|
+
x.set(next);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
const up = (e: PointerEvent | ReactPointerEvent) => {
|
|
294
|
+
const g = grip.current;
|
|
295
|
+
if (!g) return;
|
|
296
|
+
grip.current = null;
|
|
297
|
+
/* the release throws when the pointer was never captured; unguarded it left the handle stuck where the drag ended */
|
|
298
|
+
try {
|
|
299
|
+
track.current?.releasePointerCapture?.(e.pointerId);
|
|
300
|
+
} catch {
|
|
301
|
+
/* never captured */
|
|
302
|
+
}
|
|
303
|
+
setHeld(false);
|
|
304
|
+
if (x.get() >= mark) finish();
|
|
305
|
+
else animate(x, 0, home);
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
live.current = { move, up };
|
|
309
|
+
|
|
310
|
+
/* Enter and Space on the handle: the same journey, taken by the code. It runs to the end of the track and commits from
|
|
311
|
+
there, so the unfurl starts from the same place as a drag's. (A click is not used: a drag that ends on the handle
|
|
312
|
+
clicks too.) */
|
|
313
|
+
const key = (e: ReactKeyboardEvent) => {
|
|
314
|
+
if (e.key !== "Enter" && e.key !== " ") return;
|
|
315
|
+
e.preventDefault();
|
|
316
|
+
if (done || disabled || e.repeat || grip.current || sliding.current) return;
|
|
317
|
+
sliding.current = true;
|
|
318
|
+
animate(x, TRAVEL, spring).then(() => {
|
|
319
|
+
sliding.current = false;
|
|
320
|
+
if (alive.current) finish();
|
|
321
|
+
});
|
|
322
|
+
};
|
|
323
|
+
|
|
324
|
+
return (
|
|
325
|
+
<div
|
|
326
|
+
className={className ? `bn-sld ${className}` : "bn-sld"}
|
|
327
|
+
ref={root}
|
|
328
|
+
style={{ width: fluid ? "100%" : span, height: H }}
|
|
329
|
+
data-disabled={!idle || undefined}
|
|
330
|
+
>
|
|
331
|
+
<motion.div
|
|
332
|
+
className="bn-sld-track"
|
|
333
|
+
ref={track}
|
|
334
|
+
style={{ borderRadius: r, scale: pulse }}
|
|
335
|
+
data-held={held || undefined}
|
|
336
|
+
data-done={done || undefined}
|
|
337
|
+
onPointerDown={down}
|
|
338
|
+
>
|
|
339
|
+
{/* the ground already covered: it ends at the handle, not under it */}
|
|
340
|
+
<motion.i className="bn-sld-wash" aria-hidden="true" style={{ width: wash, borderRadius: gripR }} />
|
|
341
|
+
|
|
342
|
+
<motion.span className="bn-sld-say" style={{ opacity: say }}>
|
|
343
|
+
{label}
|
|
344
|
+
</motion.span>
|
|
345
|
+
|
|
346
|
+
<motion.button
|
|
347
|
+
type="button"
|
|
348
|
+
className="bn-sld-grip"
|
|
349
|
+
disabled={!idle}
|
|
350
|
+
aria-busy={(done && working) || undefined}
|
|
351
|
+
onKeyDown={key}
|
|
352
|
+
onPointerEnter={() => setHot(true)}
|
|
353
|
+
onPointerLeave={() => setHot(false)}
|
|
354
|
+
style={{
|
|
355
|
+
x: seen,
|
|
356
|
+
scaleX: sx,
|
|
357
|
+
scaleY: sy,
|
|
358
|
+
/* the morph is one number: `wide` is read off x and the anchor, so the width is arithmetic on the value
|
|
359
|
+
the spring already moves. A scale would drag the corner radius with it */
|
|
360
|
+
width: wide,
|
|
361
|
+
borderRadius: gripR,
|
|
362
|
+
}}
|
|
363
|
+
transition={{ type: "spring", stiffness: 380, damping: 34, mass: 0.7 }}
|
|
364
|
+
aria-label={done ? (working ? busyLabel : doneLabel) : label}
|
|
365
|
+
>
|
|
366
|
+
<motion.span className="bn-sld-arrow" style={{ opacity: arrow }} aria-hidden="true">
|
|
367
|
+
<ArrowRight size={20} weight="bold" />
|
|
368
|
+
</motion.span>
|
|
369
|
+
|
|
370
|
+
{/* the word only exists once there is room for it, and arrives with the width rather than after it */}
|
|
371
|
+
<motion.span
|
|
372
|
+
className="bn-sld-done"
|
|
373
|
+
aria-hidden="true"
|
|
374
|
+
initial={false}
|
|
375
|
+
animate={{ opacity: said ? 1 : 0, scale: said ? 1 : 0.7 }}
|
|
376
|
+
transition={{ duration: 0.16, ease: [0.33, 0.55, 0.2, 1] }}
|
|
377
|
+
>
|
|
378
|
+
{working ? <i className="bn-sld-spin" /> : <Check size={20} weight="bold" />}
|
|
379
|
+
{working ? busyLabel : doneLabel}
|
|
380
|
+
</motion.span>
|
|
381
|
+
</motion.button>
|
|
382
|
+
</motion.div>
|
|
383
|
+
</div>
|
|
384
|
+
);
|
|
385
|
+
}
|