@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,246 @@
|
|
|
1
|
+
/* A pill that opens a list, and fills up with faces as you assign them: one, two, four, and the pill grows to hold them
|
|
2
|
+
as an overlapping stack. The pill is the readout, so there is no count badge: the faces say who.
|
|
3
|
+
Ported from bencho.dev (MIT).
|
|
4
|
+
|
|
5
|
+
Two arrangements: "Row" (the faces overlap and the pill grows sideways) and "Grid" (the same four packed into the
|
|
6
|
+
footprint of one face, so the pill never grows). Every face is placed by its transform in both, so switching between
|
|
7
|
+
them is the same animation as arriving. */
|
|
8
|
+
|
|
9
|
+
import { type CSSProperties, useState } from "react";
|
|
10
|
+
import { AnimatePresence, motion } from "motion/react";
|
|
11
|
+
import { ArrowDown, Check } from "../icons";
|
|
12
|
+
import { VoiceAvatar } from "../VoiceAvatar";
|
|
13
|
+
|
|
14
|
+
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
|
15
|
+
|
|
16
|
+
/* the id is the avatar's seed */
|
|
17
|
+
const CAST = [
|
|
18
|
+
{ id: "kai", name: "Adriano Matos", role: "Design" },
|
|
19
|
+
{ id: "mara", name: "Priscila Ramos", role: "Pesquisa" },
|
|
20
|
+
{ id: "sofia", name: "Nora Vilela", role: "Engenharia" },
|
|
21
|
+
{ id: "ines", name: "Marcos Bellini", role: "Produto" },
|
|
22
|
+
];
|
|
23
|
+
|
|
24
|
+
/* the block's own box, reserved for the list open so closing it does not resize the card underneath */
|
|
25
|
+
const W = 264;
|
|
26
|
+
const H = 268;
|
|
27
|
+
|
|
28
|
+
/* the faces in the pill */
|
|
29
|
+
const FACE = 28;
|
|
30
|
+
const CORNER = 20;
|
|
31
|
+
/* how much of each face the next one covers, px */
|
|
32
|
+
const LAP = 10;
|
|
33
|
+
|
|
34
|
+
/* The Grid arrangement: no overlap, every face fully visible, the box is FACE across whatever the count. Three fills
|
|
35
|
+
reading order and leaves the last cell empty (centring the odd one would break the grid and shunt it sideways when
|
|
36
|
+
the fourth arrives). */
|
|
37
|
+
const quad = (n: number, gut: number) => {
|
|
38
|
+
const cell = (FACE - gut) / 2;
|
|
39
|
+
const s = cell / FACE;
|
|
40
|
+
const a = cell / 2; /* centre of the near cell */
|
|
41
|
+
const b = FACE - cell / 2; /* centre of the far one */
|
|
42
|
+
const m = FACE / 2;
|
|
43
|
+
if (n <= 1) return [{ cx: m, cy: m, s: 1 }];
|
|
44
|
+
if (n === 2)
|
|
45
|
+
return [
|
|
46
|
+
{ cx: a, cy: m, s },
|
|
47
|
+
{ cx: b, cy: m, s },
|
|
48
|
+
];
|
|
49
|
+
if (n === 3)
|
|
50
|
+
return [
|
|
51
|
+
{ cx: a, cy: a, s },
|
|
52
|
+
{ cx: b, cy: a, s },
|
|
53
|
+
{ cx: a, cy: b, s },
|
|
54
|
+
];
|
|
55
|
+
return [
|
|
56
|
+
{ cx: a, cy: a, s },
|
|
57
|
+
{ cx: b, cy: a, s },
|
|
58
|
+
{ cx: a, cy: b, s },
|
|
59
|
+
{ cx: b, cy: b, s },
|
|
60
|
+
];
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export function AssigneePicker({
|
|
64
|
+
corner = CORNER,
|
|
65
|
+
overlap = LAP,
|
|
66
|
+
stack = "Row",
|
|
67
|
+
}: {
|
|
68
|
+
/* the pill's corner and the card's, px */
|
|
69
|
+
corner?: number;
|
|
70
|
+
/* how far the faces overlap, px; 0 is a row of separate circles */
|
|
71
|
+
overlap?: number;
|
|
72
|
+
/* "Row" or "Grid" */
|
|
73
|
+
stack?: string;
|
|
74
|
+
}) {
|
|
75
|
+
/* drawn open with two already chosen: the pill doing its job and the list showing why */
|
|
76
|
+
const [open, setOpen] = useState(true);
|
|
77
|
+
const [picked, setPicked] = useState<string[]>(["kai", "mara"]);
|
|
78
|
+
|
|
79
|
+
const r = clamp(corner, 0, 26);
|
|
80
|
+
const lap = clamp(overlap, 0, 22);
|
|
81
|
+
const grid = stack === "Grid";
|
|
82
|
+
|
|
83
|
+
/* In the quad, overlap reads as packing: 4px of air at nought, 2px at full. It never reaches zero, "they never
|
|
84
|
+
touch" is the arrangement's one promise. */
|
|
85
|
+
const spots = quad(picked.length, 4 - (lap / 22) * 2);
|
|
86
|
+
|
|
87
|
+
/* The rail's width is computed from the count, not measured: it is the same number at any zoom and CSS can
|
|
88
|
+
transition it without knowing where the block is. */
|
|
89
|
+
const rail = !picked.length ? 0 : grid ? FACE : FACE + (picked.length - 1) * (FACE - lap);
|
|
90
|
+
|
|
91
|
+
/* It does not close on an outside press: the pill is the toggle, and a click on a different block must not put
|
|
92
|
+
this one away. A real interface wants a document pointerdown listener here. */
|
|
93
|
+
const toggle = (id: string) => {
|
|
94
|
+
setPicked(p => (p.includes(id) ? p.filter(x => x !== id) : [...p, id]));
|
|
95
|
+
};
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div
|
|
99
|
+
className="bn-pik"
|
|
100
|
+
style={
|
|
101
|
+
{
|
|
102
|
+
width: W,
|
|
103
|
+
height: H,
|
|
104
|
+
/* concentric: the card's radius less the gap between them (its 6px padding). Published from here because
|
|
105
|
+
the corner is a knob: at 0 both are square, at 26 both are as round as they go. */
|
|
106
|
+
"--pik-row-r": `${Math.max(0, r - 6)}px`,
|
|
107
|
+
} as CSSProperties
|
|
108
|
+
}
|
|
109
|
+
>
|
|
110
|
+
<button
|
|
111
|
+
type="button"
|
|
112
|
+
className="bn-pik-pill"
|
|
113
|
+
style={{ borderRadius: r }}
|
|
114
|
+
onClick={() => {
|
|
115
|
+
setOpen(v => !v);
|
|
116
|
+
}}
|
|
117
|
+
aria-expanded={open}
|
|
118
|
+
aria-haspopup="listbox"
|
|
119
|
+
>
|
|
120
|
+
{/* Faces are absolute inside a rail whose width is the arithmetic above, so the pill grows by one CSS
|
|
121
|
+
transition and nothing watches the DOM. Reversed z-order: the first face sits on top of the second, so
|
|
122
|
+
adding a fourth person does not look like losing the first three. */}
|
|
123
|
+
<span className="bn-pik-rail" style={{ width: rail }}>
|
|
124
|
+
<AnimatePresence initial={false}>
|
|
125
|
+
{picked.map((id, i) => {
|
|
126
|
+
/* one vector and one scale describe both layouts */
|
|
127
|
+
const spot = grid ? spots[Math.min(i, spots.length - 1)] : null;
|
|
128
|
+
const tx = spot ? spot.cx - FACE / 2 : i * (FACE - lap);
|
|
129
|
+
const ty = spot ? spot.cy - FACE / 2 : 0;
|
|
130
|
+
const sc = spot ? spot.s : 1;
|
|
131
|
+
return (
|
|
132
|
+
<motion.span
|
|
133
|
+
key={id}
|
|
134
|
+
className="bn-pik-face"
|
|
135
|
+
style={{ zIndex: CAST.length - i }}
|
|
136
|
+
/* A face lands, it does not fade in: it drops from slightly above with a turn on it and
|
|
137
|
+
overshoots on the way to rest, so adding somebody reads as a token being put down. Leaving is
|
|
138
|
+
the same move backwards and quicker. The drop lands at ty and the pop at sc, since in the quad a
|
|
139
|
+
face's rest is its cell. */
|
|
140
|
+
initial={{ scale: 0.2 * sc, opacity: 0, x: tx, y: ty - 10, rotate: -22 }}
|
|
141
|
+
animate={{ scale: sc, opacity: 1, x: tx, y: ty, rotate: 0 }}
|
|
142
|
+
exit={{ scale: 0.2 * sc, opacity: 0, x: tx, y: ty - 6, rotate: 14 }}
|
|
143
|
+
/* Two springs: the Overlap slider moves position and size, and a slider wants a follower, not a
|
|
144
|
+
bouncer (a 0.48-zeta spring wobbles a third of a second after the thumb stops). So x, y and
|
|
145
|
+
scale get a tight one; rotate keeps the loose spring that carries the character. */
|
|
146
|
+
transition={{
|
|
147
|
+
type: "spring",
|
|
148
|
+
stiffness: 500,
|
|
149
|
+
damping: 18,
|
|
150
|
+
mass: 0.8,
|
|
151
|
+
x: { type: "spring", stiffness: 380, damping: 34, mass: 0.7 },
|
|
152
|
+
y: { type: "spring", stiffness: 380, damping: 34, mass: 0.7 },
|
|
153
|
+
scale: { type: "spring", stiffness: 380, damping: 34, mass: 0.7 },
|
|
154
|
+
opacity: { duration: 0.16 },
|
|
155
|
+
}}
|
|
156
|
+
>
|
|
157
|
+
<VoiceAvatar seed={id} size={FACE} />
|
|
158
|
+
</motion.span>
|
|
159
|
+
);
|
|
160
|
+
})}
|
|
161
|
+
</AnimatePresence>
|
|
162
|
+
</span>
|
|
163
|
+
|
|
164
|
+
{/* The pill is a readout: with nobody in it, it reports that nobody is (the state, not the verb). */}
|
|
165
|
+
{picked.length === 0 && <span className="bn-pik-say">Sem responsável</span>}
|
|
166
|
+
|
|
167
|
+
<ArrowDown className="bn-pik-chev" size={16} weight="bold" aria-hidden />
|
|
168
|
+
</button>
|
|
169
|
+
|
|
170
|
+
<AnimatePresence>
|
|
171
|
+
{open && (
|
|
172
|
+
<motion.div
|
|
173
|
+
className="bn-pik-card"
|
|
174
|
+
style={{ borderRadius: r }}
|
|
175
|
+
role="listbox"
|
|
176
|
+
aria-multiselectable="true"
|
|
177
|
+
/* It unfolds out of the pill: the origin is the card's top-left, the pill's own left edge. Not a goo
|
|
178
|
+
morph, two objects joined by a bridge read as welded rather than as one opening. The scale starts high
|
|
179
|
+
(0.86 across, 0.72 down) because a scale drags the corner radius with it; the bounce is where the
|
|
180
|
+
character is, not the distance. */
|
|
181
|
+
initial={{ opacity: 0, y: -10, scaleX: 0.86, scaleY: 0.72 }}
|
|
182
|
+
animate={{ opacity: 1, y: 0, scaleX: 1, scaleY: 1 }}
|
|
183
|
+
exit={{ opacity: 0, y: -8, scaleX: 0.92, scaleY: 0.86 }}
|
|
184
|
+
transition={{
|
|
185
|
+
/* zeta about 0.63: it goes past and comes back */
|
|
186
|
+
type: "spring",
|
|
187
|
+
stiffness: 500,
|
|
188
|
+
damping: 18,
|
|
189
|
+
mass: 0.9,
|
|
190
|
+
opacity: { duration: 0.16 },
|
|
191
|
+
}}
|
|
192
|
+
>
|
|
193
|
+
{CAST.map(p => {
|
|
194
|
+
const on = picked.includes(p.id);
|
|
195
|
+
return (
|
|
196
|
+
<motion.button
|
|
197
|
+
type="button"
|
|
198
|
+
key={p.id}
|
|
199
|
+
className="bn-pik-row"
|
|
200
|
+
role="option"
|
|
201
|
+
aria-selected={on}
|
|
202
|
+
data-on={on || undefined}
|
|
203
|
+
onClick={() => toggle(p.id)}
|
|
204
|
+
/* the rows deal out under the card rather than arriving with it, 40ms apart */
|
|
205
|
+
initial={{ opacity: 0, y: -8 }}
|
|
206
|
+
animate={{ opacity: 1, y: 0 }}
|
|
207
|
+
transition={{
|
|
208
|
+
type: "spring",
|
|
209
|
+
stiffness: 380,
|
|
210
|
+
damping: 34,
|
|
211
|
+
mass: 0.7,
|
|
212
|
+
delay: 0.04 * CAST.indexOf(p) + 0.03,
|
|
213
|
+
}}
|
|
214
|
+
>
|
|
215
|
+
<span className="bn-pik-av">
|
|
216
|
+
<VoiceAvatar seed={p.id} size={32} />
|
|
217
|
+
</span>
|
|
218
|
+
<span className="bn-pik-who">
|
|
219
|
+
<span className="bn-pik-name">{p.name}</span>
|
|
220
|
+
<span className="bn-pik-role">{p.role}</span>
|
|
221
|
+
</span>
|
|
222
|
+
{/* the box is always drawn; only the tick arrives, so the row does not read as growing a control */}
|
|
223
|
+
<span className="bn-pik-mark">
|
|
224
|
+
<AnimatePresence initial={false}>
|
|
225
|
+
{on && (
|
|
226
|
+
<motion.span
|
|
227
|
+
className="bn-pik-tick"
|
|
228
|
+
initial={{ scale: 0.4, opacity: 0 }}
|
|
229
|
+
animate={{ scale: 1, opacity: 1 }}
|
|
230
|
+
exit={{ scale: 0.4, opacity: 0 }}
|
|
231
|
+
transition={{ type: "spring", stiffness: 500, damping: 18, mass: 0.6 }}
|
|
232
|
+
>
|
|
233
|
+
<Check size={14} weight="bold" />
|
|
234
|
+
</motion.span>
|
|
235
|
+
)}
|
|
236
|
+
</AnimatePresence>
|
|
237
|
+
</span>
|
|
238
|
+
</motion.button>
|
|
239
|
+
);
|
|
240
|
+
})}
|
|
241
|
+
</motion.div>
|
|
242
|
+
)}
|
|
243
|
+
</AnimatePresence>
|
|
244
|
+
</div>
|
|
245
|
+
);
|
|
246
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/* Wraps a ported Bencho component inside a real studio screen: it brings Bencho's palette (styles/components/bencho/
|
|
2
|
+
tokens.css) and the element reset the component needs, and adds no box of its own. */
|
|
3
|
+
|
|
4
|
+
import type { ReactNode } from "react";
|
|
5
|
+
|
|
6
|
+
export function BenchoScope({ children }: { children: ReactNode }) {
|
|
7
|
+
return <span className="bn-scope">{children}</span>;
|
|
8
|
+
}
|
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
/* Three tasks, and the only thing this block does is cross one out. Tick the last open one and the list falls into a
|
|
2
|
+
heap on the bottom edge of the card, lies there three seconds and puts itself away.
|
|
3
|
+
Ported from bencho.dev (MIT).
|
|
4
|
+
|
|
5
|
+
One spring per row, and everything is read off it: the box filling, the tick drawing, the rule crossing the words and
|
|
6
|
+
the words giving up their ink are four readings of a single number, so none of them can arrive early and break the
|
|
7
|
+
illusion of one event (which is why the stylesheet carries no transition of its own for them). The fill takes the raw
|
|
8
|
+
value, so the box swells past full and settles. The tick and the rule take it clamped: a tick that overshoots draws
|
|
9
|
+
past its own end and pulls back, a rule that overshoots runs off the word. */
|
|
10
|
+
|
|
11
|
+
import { type CSSProperties, useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
12
|
+
import { AnimatePresence, motion } from "motion/react";
|
|
13
|
+
|
|
14
|
+
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
|
15
|
+
const mix = (a: number, b: number, t: number) => a + (b - a) * t;
|
|
16
|
+
|
|
17
|
+
/* 0..100 into the two numbers a spring has, chosen by damping ratio (zeta = -ln(d) / (2 * sqrt(k))) because the ratio
|
|
18
|
+
is what a person sets: 0 is zeta ~0.85 (heavy, arrives without a ring), 50 ~0.41, 100 ~0.20 (two visible rebounds) */
|
|
19
|
+
const springOf = (tune: number) => ({
|
|
20
|
+
/* stiffness: how hard it is pulled toward the target */
|
|
21
|
+
k: 0.08 + (tune / 100) * 0.16,
|
|
22
|
+
/* decay, per frame: how much of the velocity survives */
|
|
23
|
+
d: 0.62 + (tune / 100) * 0.2,
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
/* Frames, not milliseconds: `dt` is in sixtieths of a second and the damping is raised to it rather than multiplied by
|
|
27
|
+
it, so a dropped frame decays the energy of the two it replaced. The snap threshold is absolute, so the caller works
|
|
28
|
+
in 0..100 or pixels. The loop parks itself the moment the value has settled. */
|
|
29
|
+
function useSpring(target: number, tune = 50, instant = false) {
|
|
30
|
+
const [at, setAt] = useState(target);
|
|
31
|
+
const cur = useRef(target);
|
|
32
|
+
const vel = useRef(0);
|
|
33
|
+
const raf = useRef(0);
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (instant) {
|
|
37
|
+
cur.current = target;
|
|
38
|
+
vel.current = 0;
|
|
39
|
+
setAt(target);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const { k, d } = springOf(tune);
|
|
43
|
+
let prev = 0;
|
|
44
|
+
const tick = (t: number) => {
|
|
45
|
+
const dt = prev ? clamp((t - prev) / 16.67, 0, 2.5) : 1;
|
|
46
|
+
prev = t;
|
|
47
|
+
vel.current += (target - cur.current) * k * dt;
|
|
48
|
+
vel.current *= d ** dt;
|
|
49
|
+
cur.current += vel.current * dt;
|
|
50
|
+
if (Math.abs(target - cur.current) < 0.02 && Math.abs(vel.current) < 0.02) {
|
|
51
|
+
cur.current = target;
|
|
52
|
+
vel.current = 0;
|
|
53
|
+
setAt(target);
|
|
54
|
+
raf.current = 0;
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
setAt(cur.current);
|
|
58
|
+
raf.current = requestAnimationFrame(tick);
|
|
59
|
+
};
|
|
60
|
+
raf.current = requestAnimationFrame(tick);
|
|
61
|
+
return () => {
|
|
62
|
+
cancelAnimationFrame(raf.current);
|
|
63
|
+
raf.current = 0;
|
|
64
|
+
};
|
|
65
|
+
/* `tune` is a dependency because the loop closes over it; restarting picks up from the refs, so it continues
|
|
66
|
+
rather than snapping */
|
|
67
|
+
}, [target, tune, instant]);
|
|
68
|
+
|
|
69
|
+
return at;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/* read once: a preference, not a live input */
|
|
73
|
+
const stillness = () =>
|
|
74
|
+
typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
75
|
+
|
|
76
|
+
const W = 300;
|
|
77
|
+
/* the inset, the same on all four sides (see padV) */
|
|
78
|
+
const PAD = 14;
|
|
79
|
+
/* the gap between two tasks is what the row has left after the box, twice: 40 makes it a list, 52 made three things */
|
|
80
|
+
const ROW = 40;
|
|
81
|
+
/* sized against the line it sits beside: at 22 the box was half again the cap height of the 14px words */
|
|
82
|
+
const BOX = 18;
|
|
83
|
+
const CORNER = 20;
|
|
84
|
+
const BOUNCE = 50;
|
|
85
|
+
|
|
86
|
+
/* the rule follows the tick, it does not race it: a window on the same number that starts a tenth in and finishes a
|
|
87
|
+
little early, so the box answers first and the words are crossed out after (cause and effect, not one wipe) */
|
|
88
|
+
const LAG = 0.12;
|
|
89
|
+
const RUN = 0.72;
|
|
90
|
+
|
|
91
|
+
/* The finish is a collapse, not a drop. The card stays as tall as its rows, so there is nowhere to fall to: the distance
|
|
92
|
+
comes from the rows closing up, by twice the slack of a row (40px tall, ~20px of it visible) each. The bottom row
|
|
93
|
+
barely moves, the top one travels the whole compaction, and each lands where the one below it stopped.
|
|
94
|
+
|
|
95
|
+
The row's body is the taller of its checkbox and its line of type, both measured, with no margin added: the slip is
|
|
96
|
+
the card's own colour, so its edge is invisible, and every pixel of slack it kept would be a pixel the heap cannot
|
|
97
|
+
close up. */
|
|
98
|
+
const BODY = (side: number, line: number) => Math.max(side, line);
|
|
99
|
+
|
|
100
|
+
/* how long the furthest fall takes; the others are shorter in proportion (one gravity, t ~ sqrt of distance) */
|
|
101
|
+
const DROP_MS = 0.46;
|
|
102
|
+
/* how far it comes back up off the floor, at most: capped against the distance travelled, or it reads as a trampoline */
|
|
103
|
+
const REBOUND = 8;
|
|
104
|
+
const FALL_EASE = ["easeIn", "easeOut", "easeIn"] as const;
|
|
105
|
+
|
|
106
|
+
/* how long the heap lies there before the list comes back */
|
|
107
|
+
const HOLD = 3000;
|
|
108
|
+
|
|
109
|
+
/* The tilt is the overhang. A slip lying on another is held up by as much of it as there is underneath; the rest hangs,
|
|
110
|
+
and what hangs, tips. Each row is measured (box, gap and type, to the end of the text) and one longer than the one it
|
|
111
|
+
lands on leans by the difference. It accumulates from the floor up (the bottom one lies flat, every one above inherits
|
|
112
|
+
the lean it lies on and adds its own), so long-then-short reads differently from short-then-long. The response
|
|
113
|
+
saturates: two degrees for the 16px those tasks differ by, never six, where a slip stops resting and looks dropped. */
|
|
114
|
+
const MAX_LEAN = 6;
|
|
115
|
+
const REACH = 40;
|
|
116
|
+
const leanOf = (over: number) => MAX_LEAN * (1 - Math.exp(-over / REACH));
|
|
117
|
+
/* the sideways untidiness, which the words have no opinion about: the one thing that is just chosen */
|
|
118
|
+
const DRIFT = [-5, 4, -2, 5, -3];
|
|
119
|
+
|
|
120
|
+
/* The daylight between two slips is computed from the leans, not picked: tilting moves their ends, and if two converge
|
|
121
|
+
by more than the gap the upper edge covers the lower words. Measured where the ink is (the far end of the longer
|
|
122
|
+
text), not across the invisible row, which inflated the gap to 5.7px for a collision that could not happen.
|
|
123
|
+
Rotation is about the row's middle, so a point at x is displaced by (x - middle) * sin. */
|
|
124
|
+
const SPAN = W - PAD * 2;
|
|
125
|
+
const MID = SPAN / 2;
|
|
126
|
+
const sin = (deg: number) => Math.sin((deg * Math.PI) / 180);
|
|
127
|
+
const gapFor = (leans: number[], runs: number[]) =>
|
|
128
|
+
1 +
|
|
129
|
+
Math.max(
|
|
130
|
+
0,
|
|
131
|
+
...leans.slice(1).map((below, i) => {
|
|
132
|
+
const ink = Math.max(runs[i] ?? MID, runs[i + 1] ?? MID);
|
|
133
|
+
return Math.max(0, (ink - MID) * (sin(leans[i]) - sin(below)));
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
/* different enough in length that the heap can be seen measuring the words rather than the row */
|
|
138
|
+
const TASKS = ["Reservar o estúdio", "Enviar o orçamento", "Escolher a fonte"];
|
|
139
|
+
|
|
140
|
+
/* three that come with it and two of your own: a cap says when the card is full, kinder than a scrollbar inside a
|
|
141
|
+
block. The spare row goes when the list is full, since an "add" that cannot add is a control lying about itself. */
|
|
142
|
+
const MAX = 5;
|
|
143
|
+
|
|
144
|
+
export function Checklist({
|
|
145
|
+
corner = CORNER,
|
|
146
|
+
/* how far the box swells past full before it settles, 0..100 (0 is a spring that arrives dead) */
|
|
147
|
+
bounce = BOUNCE,
|
|
148
|
+
/* the box, px */
|
|
149
|
+
box = BOX,
|
|
150
|
+
}: {
|
|
151
|
+
corner?: number;
|
|
152
|
+
bounce?: number;
|
|
153
|
+
box?: number;
|
|
154
|
+
} = {}) {
|
|
155
|
+
/* one array, not two: the list can change, and two arrays that must stay the same length will not */
|
|
156
|
+
const [items, setItems] = useState(() => TASKS.map(text => ({ text, done: false })));
|
|
157
|
+
/* the row being typed into; the draft is separate from the list so an abandoned one leaves nothing behind */
|
|
158
|
+
const [adding, setAdding] = useState(false);
|
|
159
|
+
const [draft, setDraft] = useState("");
|
|
160
|
+
/* how long each row is and how tall a line of it sets, reported up from the rows because only they can see it (the
|
|
161
|
+
width of type is a question for the font). offsetWidth rather than a rect: the block may be drawn under a scale. */
|
|
162
|
+
const [runs, setRuns] = useState<number[]>([]);
|
|
163
|
+
const [line, setLine] = useState(20);
|
|
164
|
+
const measure = useCallback((i: number, px: number, tall: number) => {
|
|
165
|
+
setRuns(v => (v[i] === px ? v : Object.assign([...v], { [i]: px })));
|
|
166
|
+
setLine(v => (v === tall ? v : tall));
|
|
167
|
+
}, []);
|
|
168
|
+
|
|
169
|
+
const still = stillness();
|
|
170
|
+
|
|
171
|
+
const r = clamp(corner, 0, 40);
|
|
172
|
+
const side = clamp(Math.round(box), 14, 28);
|
|
173
|
+
|
|
174
|
+
/* the whole finish, as one derived boolean and not a state: unticking a task during the three seconds makes this
|
|
175
|
+
false on the same render and the rows climb back, so "undo while it is on the floor" is free */
|
|
176
|
+
const fell = items.length > 0 && items.every(t => t.done);
|
|
177
|
+
/* the spare row holds its space while the heap is down: fading is a paint, removing it would change the height in
|
|
178
|
+
the middle of a fall */
|
|
179
|
+
const spare = items.length < MAX;
|
|
180
|
+
|
|
181
|
+
/* three seconds on the floor, then all the way back, added tasks included (a demo left by a visitor should not hand
|
|
182
|
+
the next one a state nobody chose). Cleared if `fell` goes false first, so unticking cancels the reset. Going back
|
|
183
|
+
to three also gives the closing animation for free: the height transition that opened the card runs in reverse. */
|
|
184
|
+
useEffect(() => {
|
|
185
|
+
if (!fell) return;
|
|
186
|
+
const id = window.setTimeout(() => setItems(TASKS.map(text => ({ text, done: false }))), HOLD);
|
|
187
|
+
return () => window.clearTimeout(id);
|
|
188
|
+
}, [fell]);
|
|
189
|
+
|
|
190
|
+
/* The vertical padding is derived, not declared. A row is taller than the box in it (the words are the part anybody
|
|
191
|
+
points at), and that slack sits between the first box and the card's edge, so a uniform padding reads as 14 at the
|
|
192
|
+
sides and 23 at the top. Taking the slack off, from the box's current size, keeps the four insets looking equal. */
|
|
193
|
+
const slack = (ROW - side) / 2;
|
|
194
|
+
const padV = Math.max(0, PAD - slack);
|
|
195
|
+
|
|
196
|
+
/* Where the heap goes, worked out from the bottom up: the last row's slip comes to rest on the card's inner edge and
|
|
197
|
+
every row above lands on the one below. `inset` is the air inside a row that the heap gives up. */
|
|
198
|
+
const body = BODY(side, line);
|
|
199
|
+
const inset = (ROW - body) / 2;
|
|
200
|
+
/* every row the card holds, the spare one included */
|
|
201
|
+
const slots = items.length + (spare ? 1 : 0);
|
|
202
|
+
const floor = padV + ROW * slots;
|
|
203
|
+
/* the lean of each slip from the floor up: zero until the rows have reported their widths, which is one frame */
|
|
204
|
+
const leans = items.map(() => 0);
|
|
205
|
+
for (let i = items.length - 2; i >= 0; i--) {
|
|
206
|
+
const over = Math.max(0, (runs[i] ?? 0) - (runs[i + 1] ?? 0));
|
|
207
|
+
leans[i] = clamp(leans[i + 1] + leanOf(over), 0, MAX_LEAN);
|
|
208
|
+
}
|
|
209
|
+
const touch = gapFor(leans, runs);
|
|
210
|
+
/* the bottom slip is flat, so its lowest point is its own edge and the seat needs no correction for a tilt */
|
|
211
|
+
const seat = floor - inset - body;
|
|
212
|
+
const restTop = (i: number) => padV + i * ROW;
|
|
213
|
+
const pileTop = (i: number) => seat - (items.length - 1 - i) * (body + touch);
|
|
214
|
+
const drops = items.map((_, i) => Math.max(0, pileTop(i) - restTop(i)));
|
|
215
|
+
/* the top row travels furthest, so it sets the clock; the cascade is the bottom row having less far to go */
|
|
216
|
+
const longest = Math.max(...drops, 1);
|
|
217
|
+
|
|
218
|
+
const add = () => {
|
|
219
|
+
const text = draft.trim();
|
|
220
|
+
setAdding(false);
|
|
221
|
+
setDraft("");
|
|
222
|
+
if (!text || items.length >= MAX) return;
|
|
223
|
+
setItems(v => [...v, { text, done: false }]);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
return (
|
|
227
|
+
<div
|
|
228
|
+
className="bn-chk"
|
|
229
|
+
style={{
|
|
230
|
+
width: W,
|
|
231
|
+
/* stated, not summed from the rows, so CSS can animate it: adding a task used to snap the card 40px taller */
|
|
232
|
+
height: padV * 2 + ROW * slots,
|
|
233
|
+
padding: `${padV}px ${PAD}px`,
|
|
234
|
+
borderRadius: r,
|
|
235
|
+
}}
|
|
236
|
+
>
|
|
237
|
+
{/* the two rows that leave on a reset from five back to three are held a moment and faded, so the card shuts over
|
|
238
|
+
them instead of them vanishing out from under it */}
|
|
239
|
+
<AnimatePresence initial={false}>
|
|
240
|
+
{items.map((task, i) => (
|
|
241
|
+
<Row
|
|
242
|
+
/* text and position: two tasks can have the same words, and nothing reorders */
|
|
243
|
+
key={`${i}-${task.text}`}
|
|
244
|
+
label={task.text}
|
|
245
|
+
on={task.done}
|
|
246
|
+
side={side}
|
|
247
|
+
bounce={bounce}
|
|
248
|
+
still={still}
|
|
249
|
+
fell={fell}
|
|
250
|
+
drop={drops[i]}
|
|
251
|
+
secs={DROP_MS * Math.sqrt(drops[i] / longest)}
|
|
252
|
+
/* the slip is the part of the row you can see, and the part that comes to rest on the one below */
|
|
253
|
+
inset={inset}
|
|
254
|
+
drift={DRIFT[i]}
|
|
255
|
+
tilt={leans[i]}
|
|
256
|
+
index={i}
|
|
257
|
+
onRun={measure}
|
|
258
|
+
/* the last thing to land is the top of the heap and is painted like it; DOM order would put the bottom
|
|
259
|
+
row over everything */
|
|
260
|
+
layer={fell ? items.length - i : undefined}
|
|
261
|
+
onToggle={() => {
|
|
262
|
+
setItems(v => v.map((t, k) => (k === i ? { ...t, done: !t.done } : t)));
|
|
263
|
+
}}
|
|
264
|
+
/>
|
|
265
|
+
))}
|
|
266
|
+
</AnimatePresence>
|
|
267
|
+
|
|
268
|
+
{/* The spare row is quiet on purpose, the one thing here that is not a task: at full strength a fourth row of ink
|
|
269
|
+
reads as a fourth task you have not ticked. It comes up on hover and while you type in it. */}
|
|
270
|
+
{spare && (
|
|
271
|
+
<div className="bn-chk-add" data-hide={fell || undefined} style={{ height: ROW }}>
|
|
272
|
+
<span
|
|
273
|
+
className="bn-chk-ghost"
|
|
274
|
+
aria-hidden="true"
|
|
275
|
+
style={{ width: side, height: side, borderRadius: side * 0.32 }}
|
|
276
|
+
/>
|
|
277
|
+
{adding ? (
|
|
278
|
+
<input
|
|
279
|
+
className="bn-chk-field"
|
|
280
|
+
autoFocus
|
|
281
|
+
value={draft}
|
|
282
|
+
placeholder="Nova tarefa"
|
|
283
|
+
maxLength={40}
|
|
284
|
+
onChange={e => setDraft(e.target.value)}
|
|
285
|
+
/* Enter commits, Escape abandons, and neither is allowed past this block */
|
|
286
|
+
onKeyDown={e => {
|
|
287
|
+
if (e.key === "Enter") {
|
|
288
|
+
e.stopPropagation();
|
|
289
|
+
add();
|
|
290
|
+
}
|
|
291
|
+
if (e.key === "Escape") {
|
|
292
|
+
e.stopPropagation();
|
|
293
|
+
setAdding(false);
|
|
294
|
+
setDraft("");
|
|
295
|
+
}
|
|
296
|
+
}}
|
|
297
|
+
onBlur={add}
|
|
298
|
+
/>
|
|
299
|
+
) : (
|
|
300
|
+
<button
|
|
301
|
+
type="button"
|
|
302
|
+
className="bn-chk-new"
|
|
303
|
+
onClick={() => {
|
|
304
|
+
setAdding(true);
|
|
305
|
+
}}
|
|
306
|
+
>
|
|
307
|
+
Adicionar tarefa
|
|
308
|
+
</button>
|
|
309
|
+
)}
|
|
310
|
+
</div>
|
|
311
|
+
)}
|
|
312
|
+
</div>
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function Row({
|
|
317
|
+
label,
|
|
318
|
+
on,
|
|
319
|
+
side,
|
|
320
|
+
bounce,
|
|
321
|
+
still,
|
|
322
|
+
fell,
|
|
323
|
+
drop,
|
|
324
|
+
secs,
|
|
325
|
+
inset,
|
|
326
|
+
drift,
|
|
327
|
+
tilt,
|
|
328
|
+
layer,
|
|
329
|
+
index,
|
|
330
|
+
onRun,
|
|
331
|
+
onToggle,
|
|
332
|
+
}: {
|
|
333
|
+
label: string;
|
|
334
|
+
on: boolean;
|
|
335
|
+
side: number;
|
|
336
|
+
bounce: number;
|
|
337
|
+
still: boolean;
|
|
338
|
+
/* the list is finished and the floor has gone */
|
|
339
|
+
fell: boolean;
|
|
340
|
+
drop: number;
|
|
341
|
+
secs: number;
|
|
342
|
+
inset: number;
|
|
343
|
+
drift: number;
|
|
344
|
+
tilt: number;
|
|
345
|
+
layer?: number;
|
|
346
|
+
index: number;
|
|
347
|
+
onRun: (i: number, px: number, tall: number) => void;
|
|
348
|
+
onToggle: () => void;
|
|
349
|
+
}) {
|
|
350
|
+
/* it measures its own line and says how long it is: the span is sized to the words, so its right edge is where this
|
|
351
|
+
slip actually ends. A layout effect, because the parent leans the heap on this number and a frame of the wrong
|
|
352
|
+
answer is a frame of the wrong heap. */
|
|
353
|
+
const say = useRef<HTMLSpanElement | null>(null);
|
|
354
|
+
useLayoutEffect(() => {
|
|
355
|
+
const el = say.current;
|
|
356
|
+
if (el) onRun(index, el.offsetLeft + el.offsetWidth, el.offsetHeight);
|
|
357
|
+
}, [label, side, index, onRun]);
|
|
358
|
+
|
|
359
|
+
/* the one number; a component per row because a hook cannot be called three times from the parent */
|
|
360
|
+
const t = useSpring(on ? 1 : 0, clamp(bounce, 0, 100), still);
|
|
361
|
+
const held = clamp(t, 0, 1);
|
|
362
|
+
/* the rule's own window on the same number */
|
|
363
|
+
const cut = clamp((held - LAG) / RUN, 0, 1);
|
|
364
|
+
|
|
365
|
+
/* Falling and climbing are not the same motion. Down is a duration with gravity's shape (starts at nothing and
|
|
366
|
+
gains, which is easeIn) and a small rebound as the third keyframe, the difference between landing and being placed.
|
|
367
|
+
Up is a spring, the list reasserting itself, and a spring interrupts cleanly: unticking mid-fall picks the row up
|
|
368
|
+
from wherever it got to. With motion turned down there is no quieter version of a thing falling over, so it does
|
|
369
|
+
not happen; the reset still runs. */
|
|
370
|
+
const run = still ? 0 : secs;
|
|
371
|
+
const up = Math.min(REBOUND, drop * 0.22);
|
|
372
|
+
const land = {
|
|
373
|
+
duration: run,
|
|
374
|
+
times: [0, 0.66, 0.84, 1],
|
|
375
|
+
ease: [...FALL_EASE],
|
|
376
|
+
};
|
|
377
|
+
/* the tilt and the sideways drift arrive with the landing and do not bounce */
|
|
378
|
+
const lean = { duration: run, ease: "easeIn" as const };
|
|
379
|
+
|
|
380
|
+
return (
|
|
381
|
+
<motion.button
|
|
382
|
+
type="button"
|
|
383
|
+
className="bn-chk-row"
|
|
384
|
+
role="checkbox"
|
|
385
|
+
aria-checked={on}
|
|
386
|
+
data-fell={fell || undefined}
|
|
387
|
+
onClick={onToggle}
|
|
388
|
+
style={{ height: ROW, zIndex: layer, "--slip": `${inset}px` } as CSSProperties}
|
|
389
|
+
/* the row is drawn where it has always been drawn and moved from there, so the card's layout never changes */
|
|
390
|
+
initial={false}
|
|
391
|
+
exit={{ opacity: 0, transition: { duration: 0.16 } }}
|
|
392
|
+
animate={fell ? { y: [0, drop, drop - up, drop], x: drift, rotate: tilt } : { y: 0, x: 0, rotate: 0 }}
|
|
393
|
+
transition={
|
|
394
|
+
fell ? { y: land, x: lean, rotate: lean } : { type: "spring", stiffness: 380, damping: 34, mass: 0.9 }
|
|
395
|
+
}
|
|
396
|
+
>
|
|
397
|
+
{/* the box is two layers and neither is a border being recoloured: the ring is always there and the fill grows
|
|
398
|
+
inside it, so it reads as a box being filled in rather than one colour swapped for another */}
|
|
399
|
+
<span className="bn-chk-box" style={{ width: side, height: side, borderRadius: side * 0.32 }}>
|
|
400
|
+
{/* raw, so it goes past full and settles: the one place the overshoot belongs */}
|
|
401
|
+
<span className="bn-chk-fill" style={{ borderRadius: side * 0.32, transform: `scale(${t.toFixed(4)})` }} />
|
|
402
|
+
{/* the tick draws: pathLength="1" normalises the dash to the stroke's own length, so the offset is a fraction
|
|
403
|
+
and changing the checkmark needs nothing here to know. Clamped. */}
|
|
404
|
+
<svg className="bn-chk-tick" viewBox="0 0 24 24" aria-hidden="true">
|
|
405
|
+
<path d="M6 12.4 L10.3 16.7 L18 7.6" pathLength={1} strokeDasharray={1} strokeDashoffset={1 - held} />
|
|
406
|
+
</svg>
|
|
407
|
+
</span>
|
|
408
|
+
|
|
409
|
+
<span className="bn-chk-say" ref={say}>
|
|
410
|
+
{/* the words give up their ink as the rule crosses them, off the same number, so a half-crossed line is half
|
|
411
|
+
faded and the two can never disagree */}
|
|
412
|
+
<span className="bn-chk-word" style={{ opacity: mix(1, 0.42, held) }}>
|
|
413
|
+
{label}
|
|
414
|
+
</span>
|
|
415
|
+
{/* the rule is scaled, not grown: scaleX from the left needs no measured word, and a 1.5px line has no corner
|
|
416
|
+
for a scale to distort */}
|
|
417
|
+
<span className="bn-chk-rule" aria-hidden="true" style={{ transform: `scaleX(${cut.toFixed(4)})` }} />
|
|
418
|
+
</span>
|
|
419
|
+
</motion.button>
|
|
420
|
+
);
|
|
421
|
+
}
|