@crievo/omni 0.1.3 → 0.1.5
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/omnichat.mjs +19 -6
- package/tools/sound.mjs +67 -3
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
/* A card for one step of an automation, with its controls folded into the corner until the pointer asks for them.
|
|
2
|
+
Ported from bencho.dev (MIT).
|
|
3
|
+
|
|
4
|
+
The controls are the component; the card is what they fold into. They come out of the card's own top-right corner
|
|
5
|
+
rather than fading in above it: each starts AT the corner, hidden behind the card, and travels out along a path
|
|
6
|
+
that is the card's outline pushed out by a constant distance (straight along the top, round the corner on an arc
|
|
7
|
+
concentric with it, straight down the right edge). Every point on it is the same distance from the card at every
|
|
8
|
+
corner radius, which a circle struck from one point is not.
|
|
9
|
+
One spring each, and the stagger is a delay on the target, not an ease: four springs always exist (hooks cannot be
|
|
10
|
+
conditional; `count` only decides how many are drawn), each told to open a beat after the one before it. Closing
|
|
11
|
+
runs the other way, last out first back, so the fan folds into the corner. */
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
type CSSProperties,
|
|
15
|
+
type ComponentType,
|
|
16
|
+
type ReactElement,
|
|
17
|
+
type ReactNode,
|
|
18
|
+
useEffect,
|
|
19
|
+
useRef,
|
|
20
|
+
useState,
|
|
21
|
+
} from "react";
|
|
22
|
+
import { Copy, Gear2, GitCommit, LinkSimple, Plus } from "../icons";
|
|
23
|
+
import { VoiceAvatar } from "../VoiceAvatar";
|
|
24
|
+
|
|
25
|
+
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
|
|
26
|
+
|
|
27
|
+
/* 0..100 into the two numbers a spring has: stiffness and per-frame decay. Chosen by damping ratio
|
|
28
|
+
(zeta = -ln(d) / (2 * sqrt(k))): 0 is ~0.85 (heavy, arrives without a ring), 50 is ~0.41, 100 is ~0.20 (two
|
|
29
|
+
visible rebounds). Both ends shippable. */
|
|
30
|
+
const springOf = (tune: number) => ({
|
|
31
|
+
k: 0.08 + (tune / 100) * 0.16,
|
|
32
|
+
d: 0.62 + (tune / 100) * 0.2,
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
/* A hand-rolled spring, in frames not milliseconds: `dt` is in sixtieths of a second and the decay is raised to it
|
|
36
|
+
(not multiplied), so a dropped frame sheds the same energy as the two it replaced. The snap threshold is absolute,
|
|
37
|
+
so callers work in 0..100. The loop parks itself once settled, and a restart picks up from the refs. */
|
|
38
|
+
function useSpring(target: number, tune = 50, instant = false) {
|
|
39
|
+
const [at, setAt] = useState(target);
|
|
40
|
+
const cur = useRef(target);
|
|
41
|
+
const vel = useRef(0);
|
|
42
|
+
const raf = useRef(0);
|
|
43
|
+
|
|
44
|
+
useEffect(() => {
|
|
45
|
+
if (instant) {
|
|
46
|
+
cur.current = target;
|
|
47
|
+
vel.current = 0;
|
|
48
|
+
setAt(target);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const { k, d } = springOf(tune);
|
|
52
|
+
let prev = 0;
|
|
53
|
+
const tick = (t: number) => {
|
|
54
|
+
const dt = prev ? clamp((t - prev) / 16.67, 0, 2.5) : 1;
|
|
55
|
+
prev = t;
|
|
56
|
+
vel.current += (target - cur.current) * k * dt;
|
|
57
|
+
vel.current *= d ** dt;
|
|
58
|
+
cur.current += vel.current * dt;
|
|
59
|
+
if (Math.abs(target - cur.current) < 0.02 && Math.abs(vel.current) < 0.02) {
|
|
60
|
+
cur.current = target;
|
|
61
|
+
vel.current = 0;
|
|
62
|
+
setAt(target);
|
|
63
|
+
raf.current = 0;
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
setAt(cur.current);
|
|
67
|
+
raf.current = requestAnimationFrame(tick);
|
|
68
|
+
};
|
|
69
|
+
raf.current = requestAnimationFrame(tick);
|
|
70
|
+
return () => {
|
|
71
|
+
cancelAnimationFrame(raf.current);
|
|
72
|
+
raf.current = 0;
|
|
73
|
+
};
|
|
74
|
+
}, [target, tune, instant]);
|
|
75
|
+
|
|
76
|
+
return at;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/* read once: a preference, not a live input */
|
|
80
|
+
const stillness = () =>
|
|
81
|
+
typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
82
|
+
|
|
83
|
+
/* the button and half of it; everything else with a size is measured off this one */
|
|
84
|
+
const DOT = 36;
|
|
85
|
+
const R = DOT / 2;
|
|
86
|
+
|
|
87
|
+
/* centre to centre along the path: a real distance, so twelve pixels of air stay twelve whatever the corner does */
|
|
88
|
+
const SPACING = DOT + 12;
|
|
89
|
+
|
|
90
|
+
/* A point `s` along the offset path, measured from where the corner arc meets the top edge (the corner occupies
|
|
91
|
+
0..len; outside that it has run onto a straight), and the direction out of the card there. The arc is concentric
|
|
92
|
+
with the card's, so its radius is the card's plus the offset, floored just above zero for a square card. */
|
|
93
|
+
function ride(s: number, corner: number, off: number) {
|
|
94
|
+
const arc = Math.max(0.001, corner + off);
|
|
95
|
+
const len = (arc * Math.PI) / 2;
|
|
96
|
+
if (s <= 0) {
|
|
97
|
+
/* along the top edge, going left */
|
|
98
|
+
return { x: -corner + s, y: -off, nx: 0, ny: -1 };
|
|
99
|
+
}
|
|
100
|
+
if (s >= len) {
|
|
101
|
+
/* down the right edge */
|
|
102
|
+
return { x: off, y: corner + (s - len), nx: 1, ny: 0 };
|
|
103
|
+
}
|
|
104
|
+
const th = Math.PI / 2 - s / arc;
|
|
105
|
+
return {
|
|
106
|
+
x: -corner + arc * Math.cos(th),
|
|
107
|
+
y: corner - arc * Math.sin(th),
|
|
108
|
+
nx: Math.cos(th),
|
|
109
|
+
ny: -Math.sin(th),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/* Where they hide, provably inside: a point on the card's diagonal far enough in that a whole button fits behind the
|
|
114
|
+
card. From corner >= R + 2 the corner's own centre works (at least R inside everything, with 2px to spare; at
|
|
115
|
+
exactly R the button is tangent and draws a hairline). Below that the arc has eaten the room near the corner, so go
|
|
116
|
+
past the corner's disc until both edges are clear. One number along the diagonal. */
|
|
117
|
+
const hideAt = (corner: number) =>
|
|
118
|
+
corner >= R + 2 ? corner * Math.SQRT2 : Math.max(R * Math.SQRT2 + 2, corner * Math.SQRT2 + corner + 2);
|
|
119
|
+
|
|
120
|
+
/* Reach is the air between the card and the fan, on the original 0..100 scale and allowed a narrow window of it
|
|
121
|
+
(20 to 40: 11px of gap to 16). Tighter fouls the corner, wider stops reading as attached. Not a pixel count: the
|
|
122
|
+
scale a value was judged on is part of the value. */
|
|
123
|
+
const gapPx = (reach: number) => 6 + (clamp(reach, 20, 40) / 100) * 24;
|
|
124
|
+
|
|
125
|
+
/* The air the block keeps above and below the card for the fan, measured at the top of the Reach window so the box
|
|
126
|
+
does not resize while the knob moves. Never beside: the width stays the card's. */
|
|
127
|
+
const AIR = Math.ceil(gapPx(100) + DOT);
|
|
128
|
+
|
|
129
|
+
/* the beat between one button and the next, at the top of the stagger range */
|
|
130
|
+
const BEAT = 90;
|
|
131
|
+
|
|
132
|
+
/* One corner button. `onClick` is optional because the gallery's demo buttons do nothing. */
|
|
133
|
+
export type NodeAction = {
|
|
134
|
+
id: string;
|
|
135
|
+
label: string;
|
|
136
|
+
icon: ComponentType<{ size?: number; weight?: "bold" }>;
|
|
137
|
+
onClick?: () => void;
|
|
138
|
+
disabled?: boolean;
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
export type NodePerson = { id: string; name: string };
|
|
142
|
+
|
|
143
|
+
/* What a custom card element receives (`render`): spread it on a link or a button and the card stays one element */
|
|
144
|
+
export type NodeCardProps = {
|
|
145
|
+
className: string;
|
|
146
|
+
children: ReactNode;
|
|
147
|
+
title?: string;
|
|
148
|
+
"data-on"?: true;
|
|
149
|
+
"data-selected"?: true;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/* connect, add a step, duplicate, settings: the four things you do to a node without opening it. Deliberately not
|
|
153
|
+
delete: the one action that cannot be undone does not belong in a row that appears under your cursor. */
|
|
154
|
+
const ACTS: NodeAction[] = [
|
|
155
|
+
{ id: "link", label: "Conectar", icon: LinkSimple },
|
|
156
|
+
{ id: "add", label: "Adicionar etapa", icon: Plus },
|
|
157
|
+
{ id: "copy", label: "Duplicar", icon: Copy },
|
|
158
|
+
{ id: "settings", label: "Configurações", icon: Gear2 },
|
|
159
|
+
];
|
|
160
|
+
|
|
161
|
+
/* who is on the step: three pictured (the voice avatars stand in for the faces), the rest counted. The chip is the
|
|
162
|
+
difference, not a typed number, so the two halves of one fact cannot drift */
|
|
163
|
+
const CREW: NodePerson[] = [
|
|
164
|
+
{ id: "mara", name: "Mara Quinn" },
|
|
165
|
+
{ id: "ines", name: "Tomás Oliveira" },
|
|
166
|
+
{ id: "kai", name: "Lars Andersen" },
|
|
167
|
+
];
|
|
168
|
+
const ON_STEP = 6;
|
|
169
|
+
|
|
170
|
+
/* a node is identified by its place in the flow, not by the service it calls */
|
|
171
|
+
const NODE = "Nó 07";
|
|
172
|
+
const SAY = "Lê a conversa, escreve um resumo e posta de volta no canal de onde veio.";
|
|
173
|
+
|
|
174
|
+
export function ActionNode({
|
|
175
|
+
/* the air between the card and the buttons, 20..40 */
|
|
176
|
+
reach = 25,
|
|
177
|
+
/* the spring, 0..100 */
|
|
178
|
+
bounce = 20,
|
|
179
|
+
/* how far apart the buttons open, 0..100 */
|
|
180
|
+
stagger = 55,
|
|
181
|
+
/* how many of the demo buttons are drawn, 2..4 (a controlled `actions` list sets its own count) */
|
|
182
|
+
count = 4,
|
|
183
|
+
/* the card's own corner, in px; the buttons keep theirs */
|
|
184
|
+
corner = 20,
|
|
185
|
+
/* The controlled side. Each is the demo's content when left out; pass an empty list to show none.
|
|
186
|
+
title / description: the two lines of the card. actions: the corner buttons, at most four, none = no fan.
|
|
187
|
+
people / more: the face stack and the "+N" chip. */
|
|
188
|
+
title = NODE,
|
|
189
|
+
description = SAY,
|
|
190
|
+
actions,
|
|
191
|
+
people = CREW,
|
|
192
|
+
more = people === CREW ? ON_STEP - CREW.length : 0,
|
|
193
|
+
/* the glyph box before the title, as the caller's own marker (a status, a number); the box is dropped */
|
|
194
|
+
mark,
|
|
195
|
+
/* the card is a button that calls this; with `render` the caller supplies the element instead (a router link) */
|
|
196
|
+
onOpen,
|
|
197
|
+
render,
|
|
198
|
+
/* native tooltip and the "this one is picked" ring */
|
|
199
|
+
hint,
|
|
200
|
+
selected = false,
|
|
201
|
+
/* the card's width: a number is px, a string any CSS length ("100%" fills its cell). The original is 272 */
|
|
202
|
+
width,
|
|
203
|
+
/* tighter padding and type, for cards that sit in a row of cells instead of on their own */
|
|
204
|
+
dense = false,
|
|
205
|
+
/* the room kept above and below the card for the fan; 0 lets it overlay what is next to the card instead */
|
|
206
|
+
air = AIR,
|
|
207
|
+
/* "down": the fan comes out of the bottom-right corner (the same geometry, mirrored), for a card whose top
|
|
208
|
+
edge has no room */
|
|
209
|
+
fan = "up",
|
|
210
|
+
className,
|
|
211
|
+
}: {
|
|
212
|
+
reach?: number;
|
|
213
|
+
bounce?: number;
|
|
214
|
+
stagger?: number;
|
|
215
|
+
count?: number;
|
|
216
|
+
corner?: number;
|
|
217
|
+
title?: string;
|
|
218
|
+
description?: ReactNode;
|
|
219
|
+
actions?: NodeAction[];
|
|
220
|
+
people?: NodePerson[];
|
|
221
|
+
more?: number;
|
|
222
|
+
mark?: ReactNode;
|
|
223
|
+
onOpen?: () => void;
|
|
224
|
+
render?: (props: NodeCardProps) => ReactElement;
|
|
225
|
+
hint?: string;
|
|
226
|
+
selected?: boolean;
|
|
227
|
+
width?: number | string;
|
|
228
|
+
dense?: boolean;
|
|
229
|
+
air?: number;
|
|
230
|
+
fan?: "up" | "down";
|
|
231
|
+
className?: string;
|
|
232
|
+
} = {}) {
|
|
233
|
+
const acts = (actions ?? ACTS.slice(0, clamp(Math.round(count), 2, 4))).slice(0, 4);
|
|
234
|
+
const n = acts.length;
|
|
235
|
+
const still = stillness();
|
|
236
|
+
|
|
237
|
+
/* `on` is the pointer; `lit` is what each spring aims at, which is the same thing a moment later (the stagger).
|
|
238
|
+
Four flags rather than a count, so the reverse fold is the same mechanism read backwards. */
|
|
239
|
+
const [on, setOn] = useState(false);
|
|
240
|
+
const [lit, setLit] = useState([false, false, false, false]);
|
|
241
|
+
const timers = useRef<number[]>([]);
|
|
242
|
+
|
|
243
|
+
useEffect(() => {
|
|
244
|
+
timers.current.forEach(clearTimeout);
|
|
245
|
+
timers.current = [];
|
|
246
|
+
const gap = still ? 0 : (clamp(stagger, 0, 100) / 100) * BEAT;
|
|
247
|
+
for (let i = 0; i < 4; i += 1) {
|
|
248
|
+
/* opening runs first to last; closing runs last to first */
|
|
249
|
+
const place = on ? i : n - 1 - i;
|
|
250
|
+
const at = gap * place;
|
|
251
|
+
const set = () =>
|
|
252
|
+
setLit(prev => {
|
|
253
|
+
if (prev[i] === on) return prev;
|
|
254
|
+
const next = [...prev];
|
|
255
|
+
next[i] = on;
|
|
256
|
+
return next;
|
|
257
|
+
});
|
|
258
|
+
if (at <= 0) set();
|
|
259
|
+
else timers.current.push(window.setTimeout(set, at));
|
|
260
|
+
}
|
|
261
|
+
return () => {
|
|
262
|
+
timers.current.forEach(clearTimeout);
|
|
263
|
+
timers.current = [];
|
|
264
|
+
};
|
|
265
|
+
}, [on, n, stagger, still]);
|
|
266
|
+
|
|
267
|
+
/* four, always, whatever `count` says: a hook cannot be drawn conditionally */
|
|
268
|
+
const p0 = useSpring(lit[0] ? 100 : 0, bounce, still);
|
|
269
|
+
const p1 = useSpring(lit[1] ? 100 : 0, bounce, still);
|
|
270
|
+
const p2 = useSpring(lit[2] ? 100 : 0, bounce, still);
|
|
271
|
+
const p3 = useSpring(lit[3] ? 100 : 0, bounce, still);
|
|
272
|
+
const at = [p0, p1, p2, p3];
|
|
273
|
+
|
|
274
|
+
/* Where each button ends up. The path is offset from the card by the gap plus a button's own half, so the gap is
|
|
275
|
+
measured to the button's edge. Centred on the corner's midpoint and spread by arc length, so the fan is
|
|
276
|
+
symmetrical about the diagonal however many are on it and however square the card is. */
|
|
277
|
+
const c = clamp(corner, 0, 32);
|
|
278
|
+
const off = gapPx(reach) + R;
|
|
279
|
+
const mid = ((c + off) * Math.PI) / 4;
|
|
280
|
+
const spots = Array.from({ length: n }, (_, i) => ride(mid + (i - (n - 1) / 2) * SPACING, c, off));
|
|
281
|
+
|
|
282
|
+
/* the one place they all fold back to */
|
|
283
|
+
const deep = hideAt(c) / Math.SQRT2;
|
|
284
|
+
const hid = { x: -deep, y: deep };
|
|
285
|
+
|
|
286
|
+
/* The air between the card and the fan is a hole: the wrapper's box is the card, so moving from the card toward a
|
|
287
|
+
button would leave .bn-nod on the way across and fold the fan from under the cursor. The fan carries a hit area:
|
|
288
|
+
the box its buttons occupy, bled a little, hung off the same corner anchor, measured off `spots` so there is no
|
|
289
|
+
second copy of the geometry. Live only while the fan is open, so empty space beside the card never opens it. */
|
|
290
|
+
const BLEED = 6;
|
|
291
|
+
const box = spots.reduce(
|
|
292
|
+
(b, s) => ({
|
|
293
|
+
l: Math.min(b.l, s.x - R - BLEED),
|
|
294
|
+
r: Math.max(b.r, s.x + R + BLEED),
|
|
295
|
+
t: Math.min(b.t, s.y - R - BLEED),
|
|
296
|
+
d: Math.max(b.d, s.y + R + BLEED),
|
|
297
|
+
}),
|
|
298
|
+
/* seeded on the anchor, so the pad reaches back to the corner however far out the fan has been sent */
|
|
299
|
+
{ l: 0, r: 0, t: 0, d: 0 },
|
|
300
|
+
);
|
|
301
|
+
const pw = box.r - box.l;
|
|
302
|
+
const ph = box.d - box.t;
|
|
303
|
+
/* the corner itself, in the pad's own coordinates */
|
|
304
|
+
const px = -box.l;
|
|
305
|
+
const py = -box.t;
|
|
306
|
+
|
|
307
|
+
/* The bite taken out of the pad is inside the card: the quadrant x < 0, y > 0 is too greedy (just outside a
|
|
308
|
+
rounded corner the material has pulled back but the quadrant has not, right where the middle button's path
|
|
309
|
+
crosses). x < -c with y > c is card at every radius, so that rectangle is the one to lose. */
|
|
310
|
+
const bx = Math.max(0, px - c);
|
|
311
|
+
const by = Math.min(ph, py + c);
|
|
312
|
+
|
|
313
|
+
const cardProps: NodeCardProps = {
|
|
314
|
+
className: "bn-nod-card",
|
|
315
|
+
title: hint,
|
|
316
|
+
"data-on": on || undefined,
|
|
317
|
+
"data-selected": selected || undefined,
|
|
318
|
+
children: (
|
|
319
|
+
<>
|
|
320
|
+
<span className="bn-nod-top">
|
|
321
|
+
{mark ? (
|
|
322
|
+
<span className="bn-nod-mark" data-bare="">
|
|
323
|
+
{mark}
|
|
324
|
+
</span>
|
|
325
|
+
) : (
|
|
326
|
+
<span className="bn-nod-mark" aria-hidden="true">
|
|
327
|
+
{/* a ring with a wire out of either side: one step with a flow arriving and a flow leaving. The glyph
|
|
328
|
+
spans its full width, so 18 gives the ring its weight back without the wires reaching the edge */}
|
|
329
|
+
<GitCommit size={18} weight="bold" />
|
|
330
|
+
</span>
|
|
331
|
+
)}
|
|
332
|
+
<span className="bn-nod-name">{title}</span>
|
|
333
|
+
</span>
|
|
334
|
+
|
|
335
|
+
<span className="bn-nod-say">{description}</span>
|
|
336
|
+
|
|
337
|
+
{/* the faces come after the sentence: the least urgent of the three and the heaviest to look at */}
|
|
338
|
+
{(people.length > 0 || more > 0) && (
|
|
339
|
+
<span className="bn-nod-chain" aria-hidden="true">
|
|
340
|
+
{people.map(p => (
|
|
341
|
+
<span key={p.id} className="bn-nod-link">
|
|
342
|
+
<VoiceAvatar seed={p.name} size={24} />
|
|
343
|
+
</span>
|
|
344
|
+
))}
|
|
345
|
+
{more > 0 && <span className="bn-nod-more">+{more}</span>}
|
|
346
|
+
</span>
|
|
347
|
+
)}
|
|
348
|
+
</>
|
|
349
|
+
),
|
|
350
|
+
};
|
|
351
|
+
|
|
352
|
+
/* The card is a span-built block so it can be a button or a link without invalid nesting: `render` hands the
|
|
353
|
+
caller its props, `onOpen` makes it a button, and with neither it is a plain box. */
|
|
354
|
+
const card = render ? (
|
|
355
|
+
render(cardProps)
|
|
356
|
+
) : onOpen ? (
|
|
357
|
+
<button type="button" onClick={onOpen} {...cardProps} />
|
|
358
|
+
) : (
|
|
359
|
+
<div {...cardProps} />
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
/* the fan hangs off one corner; "down" is the same arrangement flipped about the card's horizontal axis */
|
|
363
|
+
const down = fan === "down";
|
|
364
|
+
|
|
365
|
+
return (
|
|
366
|
+
<div
|
|
367
|
+
className={className ? `bn-nod ${className}` : "bn-nod"}
|
|
368
|
+
data-dense={dense || undefined}
|
|
369
|
+
data-fan={down ? "down" : undefined}
|
|
370
|
+
/* the corner rides on the wrapper as a variable so the Stroke rule can cut its ring to the same radius; the
|
|
371
|
+
mark is cut with the card (a share of its radius, capped at the 15 that makes the old disc), and the
|
|
372
|
+
vertical padding is the fan's own reach */
|
|
373
|
+
style={
|
|
374
|
+
{
|
|
375
|
+
"--nod-r": `${c}px`,
|
|
376
|
+
"--nod-mark-r": `${Math.min(15, c * 0.47).toFixed(2)}px`,
|
|
377
|
+
...(width != null ? { "--nod-w": typeof width === "number" ? `${width}px` : width } : null),
|
|
378
|
+
paddingBlock: air,
|
|
379
|
+
} as CSSProperties
|
|
380
|
+
}
|
|
381
|
+
onPointerEnter={() => setOn(true)}
|
|
382
|
+
/* `out` with a containment test, not `leave`: a scripted pointer walked off the card carries
|
|
383
|
+
`relatedTarget: null`, which React never turns into a leave */
|
|
384
|
+
onPointerOut={e => {
|
|
385
|
+
const to = e.relatedTarget as Node | null;
|
|
386
|
+
if (!to || !e.currentTarget.contains(to)) setOn(false);
|
|
387
|
+
}}
|
|
388
|
+
onPointerCancel={() => setOn(false)}
|
|
389
|
+
/* the keyboard opens the fan the way the pointer does: focus on the card, then tab runs along the buttons */
|
|
390
|
+
onFocus={() => setOn(true)}
|
|
391
|
+
onBlur={e => {
|
|
392
|
+
if (!e.relatedTarget || !e.currentTarget.contains(e.relatedTarget as Node)) setOn(false);
|
|
393
|
+
}}
|
|
394
|
+
>
|
|
395
|
+
{card}
|
|
396
|
+
|
|
397
|
+
{/* The fan, anchored ON the card's own top-right corner so every spot is measured from one origin. It hangs
|
|
398
|
+
outside the box on purpose (the box is the card and nothing else). `top: AIR`, not 0: an absolute child is
|
|
399
|
+
laid against the padding box, so the air above would leave the anchor behind at the top of the block. */}
|
|
400
|
+
{n > 0 && (
|
|
401
|
+
<div className="bn-nod-fan" style={down ? { bottom: air, right: 0 } : { top: air, right: 0 }}>
|
|
402
|
+
<i
|
|
403
|
+
className="bn-nod-reach"
|
|
404
|
+
aria-hidden="true"
|
|
405
|
+
data-on={on || undefined}
|
|
406
|
+
style={{
|
|
407
|
+
left: box.l,
|
|
408
|
+
top: box.t,
|
|
409
|
+
width: pw,
|
|
410
|
+
height: ph,
|
|
411
|
+
clipPath: `polygon(0 0, ${pw}px 0, ${pw}px ${ph}px, ${bx}px ${ph}px, ${bx}px ${by}px, 0 ${by}px)`,
|
|
412
|
+
}}
|
|
413
|
+
/>
|
|
414
|
+
{acts.map((a, i) => {
|
|
415
|
+
const u = clamp(at[i] / 100, 0, 1.4);
|
|
416
|
+
const spot = spots[i];
|
|
417
|
+
/* out of the corner in a straight line, from the hidden point to its place on the path. `u` is not
|
|
418
|
+
clamped, so the spring's overshoot carries each button a little past its place and back */
|
|
419
|
+
const put = (a: number, b: number) => a + (b - a) * u;
|
|
420
|
+
return (
|
|
421
|
+
<button
|
|
422
|
+
key={a.id}
|
|
423
|
+
className="bn-nod-act"
|
|
424
|
+
type="button"
|
|
425
|
+
aria-label={a.label}
|
|
426
|
+
title={a.label}
|
|
427
|
+
/* a control you cannot see should not be the next thing tab lands on */
|
|
428
|
+
tabIndex={on ? 0 : -1}
|
|
429
|
+
disabled={a.disabled}
|
|
430
|
+
onClick={() => a.onClick?.()}
|
|
431
|
+
style={
|
|
432
|
+
{
|
|
433
|
+
width: DOT,
|
|
434
|
+
height: DOT,
|
|
435
|
+
/* its own centre on the anchor before anything moves it: exactly half a button */
|
|
436
|
+
marginTop: -R,
|
|
437
|
+
marginLeft: -R,
|
|
438
|
+
translate: `${put(hid.x, spot.x)}px ${put(hid.y, spot.y)}px`,
|
|
439
|
+
/* barely any scale and no fade: a button hidden under the card does not need to be transparent,
|
|
440
|
+
and the 0.18 left is weight, not concealment. A variable rather than `scale`, because the
|
|
441
|
+
hover grows the button too and an inline `scale` would beat any rule; the stylesheet
|
|
442
|
+
multiplies the two. */
|
|
443
|
+
"--pop": 0.82 + 0.18 * Math.min(u, 1),
|
|
444
|
+
} as CSSProperties
|
|
445
|
+
}
|
|
446
|
+
>
|
|
447
|
+
{/* 16 in a 36 disc, in proportion to the button */}
|
|
448
|
+
<a.icon size={16} weight="bold" />
|
|
449
|
+
</button>
|
|
450
|
+
);
|
|
451
|
+
})}
|
|
452
|
+
</div>
|
|
453
|
+
)}
|
|
454
|
+
</div>
|
|
455
|
+
);
|
|
456
|
+
}
|