penseat 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,676 @@
1
+ "use client";
2
+
3
+ import { useState, useCallback, useRef, useEffect, createContext, useContext, type ReactNode } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { Undo2, Trash2, X, Copy, Check } from "lucide-react";
6
+ import { Button } from "./ui/button";
7
+
8
+ const TOOLTIP_DELAY = 360;
9
+ const TOOLTIP_GAP = 10;
10
+
11
+ const TooltipBelowCtx = createContext(false);
12
+
13
+ const SHIFT_ICON = (
14
+ <svg height="11" width="11" strokeLinejoin="round" style={{ color: "currentColor" }} viewBox="0 0 16 16">
15
+ <path fillRule="evenodd" clipRule="evenodd" d="M8.70711 1.39644C8.31659 1.00592 7.68342 1.00592 7.2929 1.39644L2.21968 6.46966L1.68935 6.99999L2.75001 8.06065L3.28034 7.53032L7.25001 3.56065V14.25V15H8.75001V14.25V3.56065L12.7197 7.53032L13.25 8.06065L14.3107 6.99999L13.7803 6.46966L8.70711 1.39644Z" fill="currentColor" />
16
+ </svg>
17
+ );
18
+
19
+ const CMD_ICON = (
20
+ <svg height="11" width="11" strokeLinejoin="round" style={{ color: "currentColor" }} viewBox="0 0 16 16">
21
+ <path fillRule="evenodd" clipRule="evenodd" d="M1 3.75C1 2.23122 2.23122 1 3.75 1C5.26878 1 6.5 2.23122 6.5 3.75V5H9.5V3.75C9.5 2.23122 10.7312 1 12.25 1C13.7688 1 15 2.23122 15 3.75C15 5.26878 13.7688 6.5 12.25 6.5H11V9.5H12.25C13.7688 9.5 15 10.7312 15 12.25C15 13.7688 13.7688 15 12.25 15C10.7312 15 9.5 13.7688 9.5 12.25V11H6.5V12.25C6.5 13.7688 5.26878 15 3.75 15C2.23122 15 1 13.7688 1 12.25C1 10.7312 2.23122 9.5 3.75 9.5H5V6.5H3.75C2.23122 6.5 1 5.26878 1 3.75ZM11 5H12.25C12.9404 5 13.5 4.44036 13.5 3.75C13.5 3.05964 12.9404 2.5 12.25 2.5C11.5596 2.5 11 3.05964 11 3.75V5ZM9.5 6.5H6.5V9.5H9.5V6.5ZM11 12.25V11H12.25C12.9404 11 13.5 11.5596 13.5 12.25C13.5 12.9404 12.9404 13.5 12.25 13.5C11.5596 13.5 11 12.9404 11 12.25ZM5 11H3.75C3.05964 11 2.5 11.5596 2.5 12.25C2.5 12.9404 3.05964 13.5 3.75 13.5C4.44036 13.5 5 12.9404 5 12.25V11ZM5 3.75V5H3.75C3.05964 5 2.5 4.44036 2.5 3.75C2.5 3.05964 3.05964 2.5 3.75 2.5C4.44036 2.5 5 3.05964 5 3.75Z" fill="currentColor" />
22
+ </svg>
23
+ );
24
+
25
+ function Keycap({ children }: { children: ReactNode }) {
26
+ const content = children === "command" ? CMD_ICON : children === "shift" ? SHIFT_ICON : children;
27
+ return (
28
+ <kbd className="inline-flex items-center justify-center size-5 rounded bg-zinc-700 border border-zinc-600 text-[13px] font-mono leading-none text-zinc-300">
29
+ {content}
30
+ </kbd>
31
+ );
32
+ }
33
+
34
+ function WithTooltip({ label, shortcut, children, pressed }: { label: string; shortcut?: string[]; children: ReactNode; pressed?: boolean }) {
35
+ const below = useContext(TooltipBelowCtx);
36
+ const [hoverShow, setHoverShow] = useState(false);
37
+ const [pos, setPos] = useState<{ x: number; top: number; bottom: number } | null>(null);
38
+ const wrapRef = useRef<HTMLDivElement>(null);
39
+ const timerRef = useRef<ReturnType<typeof setTimeout>>(null);
40
+
41
+ const computePos = useCallback(() => {
42
+ if (wrapRef.current) {
43
+ const rect = wrapRef.current.getBoundingClientRect();
44
+ setPos({ x: rect.left + rect.width / 2, top: rect.top, bottom: rect.bottom });
45
+ }
46
+ }, []);
47
+
48
+ const onEnter = () => {
49
+ timerRef.current = setTimeout(() => {
50
+ computePos();
51
+ setHoverShow(true);
52
+ }, TOOLTIP_DELAY);
53
+ };
54
+ const onLeave = () => {
55
+ if (timerRef.current) clearTimeout(timerRef.current);
56
+ setHoverShow(false);
57
+ };
58
+
59
+ useEffect(() => () => { if (timerRef.current) clearTimeout(timerRef.current); }, []);
60
+
61
+ return (
62
+ <div
63
+ ref={wrapRef}
64
+ className="flex items-center self-stretch transition-transform duration-100"
65
+ style={{ transform: pressed ? "scale(0.95)" : undefined }}
66
+ onPointerEnter={onEnter}
67
+ onPointerLeave={onLeave}
68
+ >
69
+ {children}
70
+ {hoverShow && pos && createPortal(
71
+ <div
72
+ data-penseat="tooltip"
73
+ className="fixed z-[99999] pointer-events-none"
74
+ style={{
75
+ left: pos.x,
76
+ top: below ? pos.bottom + TOOLTIP_GAP : pos.top - TOOLTIP_GAP,
77
+ translate: below ? "-50% 0" : "-50% -100%",
78
+ opacity: 1,
79
+ animation: below ? "tooltip-in-below 150ms ease-out" : "tooltip-in-above 150ms ease-out",
80
+ }}
81
+ >
82
+ <div className={`flex items-center gap-2 rounded-lg bg-[#27272A] pl-3 py-1.5 ${shortcut && shortcut.length > 0 ? "pr-1.5" : "pr-3"}`}>
83
+ <span className="text-xs font-mono text-zinc-200">{label}</span>
84
+ {shortcut && shortcut.length > 0 && (
85
+ <div className="flex items-center gap-0.5">
86
+ {shortcut.map((key, i) => (
87
+ <Keycap key={i}>{key}</Keycap>
88
+ ))}
89
+ </div>
90
+ )}
91
+ </div>
92
+ <style>{`
93
+ @keyframes tooltip-in-above { from { opacity: 0; translate: -50% calc(-100% + 4px); } }
94
+ @keyframes tooltip-in-below { from { opacity: 0; translate: -50% -4px; } }
95
+ `}</style>
96
+ </div>,
97
+ document.body
98
+ )}
99
+ </div>
100
+ );
101
+ }
102
+
103
+ const COLORS = [
104
+ { name: "Red", value: "#ef4444" },
105
+ { name: "Yellow", value: "#eab308" },
106
+ { name: "Blue", value: "#3b82f6" },
107
+ { name: "Green", value: "#22c55e" },
108
+ { name: "Eraser", value: "#ffffff" },
109
+ ];
110
+
111
+ const BAR_HEIGHT = 44;
112
+ const OFFSET = 16;
113
+ const MIN_THROW_SPEED = 800; // px/s — needs a real flick to trigger directional throw
114
+ const FLIGHT_DURATION = 350; // ms
115
+
116
+ export type Corner = "lb" | "rb" | "lt" | "rt";
117
+
118
+ interface PenseatBarProps {
119
+ expanded: boolean;
120
+ corner: Corner;
121
+ onCornerChange: (corner: Corner) => void;
122
+ color: string;
123
+ onColorChange: (color: string) => void;
124
+ onToggle: () => void;
125
+ onUndo: () => void;
126
+ onClear: () => void;
127
+ onDone: () => void;
128
+ capturing: boolean;
129
+ copied?: boolean;
130
+ shake?: boolean;
131
+ }
132
+
133
+ function EraserDot({ active, onClick }: { active: boolean; onClick: () => void }) {
134
+ return (
135
+ <div
136
+ onClick={onClick}
137
+ className="size-5 rounded-full cursor-pointer transition-transform hover:scale-110 shrink-0 flex items-center justify-center"
138
+ style={{
139
+ boxShadow: active ? "0 0 0 2px #18181b, 0 0 0 3px #a1a1aa" : "none",
140
+ }}
141
+ >
142
+ <svg viewBox="0 0 20 20" className="size-5">
143
+ <defs>
144
+ <clipPath id="eraser-clip">
145
+ <circle cx="10" cy="10" r="8.5" />
146
+ </clipPath>
147
+ </defs>
148
+ <circle cx="10" cy="10" r="8.5" fill="none" stroke="#71717a" strokeWidth="1.5" />
149
+ <g clipPath="url(#eraser-clip)">
150
+ <line x1="-2" y1="16.34" x2="16.34" y2="-2" stroke="#71717a" strokeWidth="1.2" />
151
+ <line x1="-2" y1="22" x2="22" y2="-2" stroke="#71717a" strokeWidth="1.2" />
152
+ <line x1="3.66" y1="22" x2="22" y2="3.66" stroke="#71717a" strokeWidth="1.2" />
153
+ </g>
154
+ </svg>
155
+ </div>
156
+ );
157
+ }
158
+
159
+ function ColorDots({ colors, active, onChange, pressedId }: { colors: typeof COLORS; active: string; onChange: (c: string) => void; pressedId: string | null }) {
160
+ return (
161
+ <div className="flex items-center gap-2 px-3 shrink-0">
162
+ {colors.map((c, i) => {
163
+ if (c.name === "Eraser") {
164
+ return (
165
+ <WithTooltip key={c.value} label={c.name} shortcut={["E"]} pressed={pressedId === "eraser"}>
166
+ <EraserDot active={active === c.value} onClick={() => onChange(c.value)} />
167
+ </WithTooltip>
168
+ );
169
+ }
170
+ return (
171
+ <WithTooltip key={c.value} label={c.name} shortcut={[String(i + 1)]} pressed={pressedId === `color-${i + 1}`}>
172
+ <div
173
+ onClick={() => onChange(c.value)}
174
+ className="size-5 rounded-full cursor-pointer transition-transform hover:scale-110 shrink-0"
175
+ style={{
176
+ backgroundColor: c.value,
177
+ boxShadow: active === c.value ? `0 0 0 2px #18181b, 0 0 0 3px ${c.value}` : "none",
178
+ }}
179
+ />
180
+ </WithTooltip>
181
+ );
182
+ })}
183
+ </div>
184
+ );
185
+ }
186
+
187
+ function Actions({ onUndo, onClear, pressedId }: { onUndo: () => void; onClear: () => void; pressedId: string | null }) {
188
+ return (
189
+ <div className="flex items-center gap-1 px-1 animate-in fade-in duration-200">
190
+ <WithTooltip label="Undo" shortcut={["command", "Z"]} pressed={pressedId === "undo"}>
191
+ <Button tabIndex={-1} variant="ghost" size="icon" aria-label="Undo" className={`size-8 text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 shrink-0 cursor-pointer active:scale-[0.9] active:translate-y-0 transition-all ${pressedId === "undo" ? "bg-zinc-800 text-zinc-100" : ""}`} onClick={onUndo}>
192
+ <Undo2 className="size-4" />
193
+ </Button>
194
+ </WithTooltip>
195
+ <WithTooltip label="Clear all" shortcut={["X"]} pressed={pressedId === "clear"}>
196
+ <Button tabIndex={-1} variant="ghost" size="icon" aria-label="Clear all" className={`size-8 text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800 shrink-0 cursor-pointer active:scale-[0.9] active:translate-y-0 transition-all ${pressedId === "clear" ? "bg-zinc-800 text-zinc-100" : ""}`} onClick={onClear}>
197
+ <Trash2 className="size-4" />
198
+ </Button>
199
+ </WithTooltip>
200
+ </div>
201
+ );
202
+ }
203
+
204
+ const PEN_ICON = (
205
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" className="size-5" fill="none">
206
+ <path fillRule="evenodd" clipRule="evenodd" d="M20.1507 2.76256C19.4673 2.07914 18.3593 2.07915 17.6759 2.76256L16.0006 4.43796L16.0001 4.4375L14.9395 5.49816L18.9091 9.46783C19.202 9.76072 19.202 10.2356 18.9091 10.5285L16.9698 12.4678C16.6769 12.7607 16.6769 13.2356 16.9698 13.5285C17.2627 13.8214 17.7375 13.8214 18.0304 13.5285L19.9698 11.5892C20.8485 10.7105 20.8485 9.28585 19.9698 8.40717L19.5612 7.99862L21.2365 6.32322C21.9199 5.63981 21.9199 4.53177 21.2365 3.84835L20.1507 2.76256ZM17.6159 9.94413L14.0552 6.38347L8.49985 11.9392L12.0605 15.4999L17.6159 9.94413ZM3.83613 16.6032L7.43923 12.9999L10.9999 16.5605L7.39683 20.1639C7.06636 20.4943 6.65711 20.7351 6.20775 20.8635L3.20606 21.7211C2.94416 21.796 2.66229 21.7229 2.46969 21.5303C2.27709 21.3377 2.20405 21.0559 2.27888 20.794L3.1365 17.7923C3.26489 17.3429 3.50567 16.9337 3.83613 16.6032Z" fill="currentColor" />
207
+ </svg>
208
+ );
209
+
210
+ // Morph icon slot: pen and close overlap, crossfade with scale+blur
211
+ function AnchorIcon({ expanded, onToggle, onPointerDown, wasDragRef, pressed, rightExpand }: {
212
+ expanded: boolean;
213
+ onToggle: () => void;
214
+ onPointerDown: (e: React.PointerEvent) => void;
215
+ wasDragRef: React.RefObject<boolean>;
216
+ pressed?: boolean;
217
+ rightExpand: boolean;
218
+ }) {
219
+ // Spin toward the toolbar: clockwise when expanding right, counter-clockwise when left
220
+ const spinDeg = rightExpand ? 90 : -90;
221
+ return (
222
+ <button
223
+ tabIndex={-1}
224
+ data-penseat="trigger"
225
+ onClick={(e) => { (e.currentTarget as HTMLElement).blur(); if (!wasDragRef.current) onToggle(); }}
226
+ onPointerDown={expanded ? undefined : onPointerDown}
227
+ className="relative flex items-center justify-center shrink-0 cursor-pointer transition-colors [&:hover]:bg-zinc-800 rounded-full"
228
+ style={{
229
+ width: BAR_HEIGHT - 4,
230
+ height: BAR_HEIGHT - 4,
231
+ transform: pressed ? "scale(0.9)" : undefined,
232
+ transition: "transform 100ms",
233
+ }}
234
+ >
235
+ {/* Pen icon — visible when collapsed */}
236
+ <span
237
+ className="absolute inset-0 flex items-center justify-center text-white transition-all duration-200 ease-in-out"
238
+ style={{
239
+ opacity: expanded ? 0 : 1,
240
+ transform: expanded ? "scale(0.5)" : "scale(1)",
241
+ filter: expanded ? "blur(4px)" : "blur(0px)",
242
+ pointerEvents: expanded ? "none" : "auto",
243
+ transitionDelay: expanded ? "0ms" : "60ms",
244
+ }}
245
+ >
246
+ {PEN_ICON}
247
+ </span>
248
+ {/* Close icon — spins in from the toolbar direction */}
249
+ <span
250
+ className="absolute inset-0 flex items-center justify-center text-zinc-400 transition-all duration-300 ease-in-out"
251
+ style={{
252
+ opacity: expanded ? 1 : 0,
253
+ transform: expanded ? "scale(1) rotate(0deg)" : `scale(0.5) rotate(${-spinDeg}deg)`,
254
+ filter: expanded ? "blur(0px)" : "blur(4px)",
255
+ pointerEvents: expanded ? "auto" : "none",
256
+ transitionDelay: expanded ? "0ms" : "60ms",
257
+ }}
258
+ >
259
+ <X className="size-5" />
260
+ </span>
261
+ </button>
262
+ );
263
+ }
264
+
265
+ function DoneBtn({ onClick, disabled, capturing, copied, pressed, shake }: { onClick: () => void; disabled: boolean; capturing: boolean; copied: boolean; pressed: boolean; shake?: boolean }) {
266
+ const anim = copied ? "copy-bounce 600ms ease-out" : shake ? "nope-shake 320ms ease-out" : undefined;
267
+ return (
268
+ <div className="flex items-center self-stretch" style={anim ? { animation: anim } : undefined}>
269
+ <WithTooltip key={copied ? "copied" : "copy"} label={copied ? "Copied!" : "Copy"} shortcut={copied ? undefined : ["command", "C"]} pressed={pressed}>
270
+ <Button
271
+ tabIndex={-1}
272
+ variant="ghost"
273
+ size="icon"
274
+ onClick={onClick}
275
+ disabled={disabled && !copied}
276
+ aria-label="Done"
277
+ className={`size-8 shrink-0 animate-in fade-in duration-200 cursor-pointer active:scale-[0.9] active:translate-y-0 transition-all ${
278
+ copied
279
+ ? "bg-blue-500 text-white hover:bg-blue-500 hover:text-white"
280
+ : pressed
281
+ ? "bg-zinc-800 text-zinc-100"
282
+ : "text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800"
283
+ } disabled:opacity-50`}
284
+ style={{ borderRadius: copied ? 9999 : undefined }}
285
+ >
286
+ {capturing ? (
287
+ <span className="size-3 animate-spin rounded-full border-2 border-zinc-400/30 border-t-zinc-400" />
288
+ ) : (
289
+ <span className="relative size-4">
290
+ <Copy className="size-4 absolute inset-0 transition-all duration-200" style={{ opacity: copied ? 0 : 1, transform: copied ? "scale(0.5)" : "scale(1)" }} />
291
+ <Check className="size-4 absolute inset-0 transition-all duration-200" strokeWidth={3} style={{ opacity: copied ? 1 : 0, transform: copied ? "scale(1)" : "scale(0.5)" }} />
292
+ </span>
293
+ )}
294
+ </Button>
295
+ </WithTooltip>
296
+ {(shake || copied) && (
297
+ <style>{`
298
+ @keyframes nope-shake {
299
+ 0% { transform: translateX(0); }
300
+ 20% { transform: translateX(-3px); }
301
+ 50% { transform: translateX(3px); }
302
+ 80% { transform: translateX(-2px); }
303
+ 100% { transform: translateX(0); }
304
+ }
305
+ @keyframes copy-bounce {
306
+ 0% { transform: translateY(0); }
307
+ 30% { transform: translateY(-6px); }
308
+ 50% { transform: translateY(0); }
309
+ 70% { transform: translateY(-2px); }
310
+ 100% { transform: translateY(0); }
311
+ }
312
+ `}</style>
313
+ )}
314
+ </div>
315
+ );
316
+ }
317
+
318
+ // Clamp center position so the full button stays inside the viewport
319
+ function clampCenter(x: number, y: number): { x: number; y: number } {
320
+ const half = BAR_HEIGHT / 2;
321
+ return {
322
+ x: Math.max(half, Math.min(window.innerWidth - half, x)),
323
+ y: Math.max(half, Math.min(window.innerHeight - half, y)),
324
+ };
325
+ }
326
+
327
+ // Corner pixel positions (center of button)
328
+ function cornerCenter(c: Corner): { x: number; y: number } {
329
+ const half = BAR_HEIGHT / 2;
330
+ const w = window.innerWidth;
331
+ const h = window.innerHeight;
332
+ switch (c) {
333
+ case "lt": return { x: OFFSET + half, y: OFFSET + half };
334
+ case "rt": return { x: w - OFFSET - half, y: OFFSET + half };
335
+ case "lb": return { x: OFFSET + half, y: h - OFFSET - half };
336
+ case "rb": return { x: w - OFFSET - half, y: h - OFFSET - half };
337
+ }
338
+ }
339
+
340
+
341
+ function expandsRight(corner: Corner) {
342
+ return corner === "lb" || corner === "lt";
343
+ }
344
+
345
+ // Pick the corner most aligned with the drag direction from current position
346
+ function cornerFromDirection(fromX: number, fromY: number, dx: number, dy: number): Corner {
347
+ const dragAngle = Math.atan2(dy, dx);
348
+ let best: Corner = "rb";
349
+ let bestDot = -Infinity;
350
+
351
+ for (const c of CORNERS) {
352
+ const cc = cornerCenter(c);
353
+ const toX = cc.x - fromX;
354
+ const toY = cc.y - fromY;
355
+ const dist = Math.hypot(toX, toY);
356
+ if (dist < 1) continue; // already at this corner
357
+ // Dot product of normalized vectors = cos(angle between)
358
+ const dot = (dx * toX + dy * toY) / (Math.hypot(dx, dy) * dist);
359
+ if (dot > bestDot) {
360
+ bestDot = dot;
361
+ best = c;
362
+ }
363
+ }
364
+ return best;
365
+ }
366
+
367
+ // All 4 corners
368
+ const CORNERS: Corner[] = ["lt", "rt", "lb", "rb"];
369
+
370
+ // Closest corner to a point
371
+ function closestCorner(x: number, y: number): Corner {
372
+ let best: Corner = "rb";
373
+ let bestDist = Infinity;
374
+ for (const c of CORNERS) {
375
+ const cc = cornerCenter(c);
376
+ const d = Math.hypot(cc.x - x, cc.y - y);
377
+ if (d < bestDist) {
378
+ bestDist = d;
379
+ best = c;
380
+ }
381
+ }
382
+ return best;
383
+ }
384
+
385
+ export default function PenseatBar({
386
+ expanded,
387
+ corner,
388
+ onCornerChange,
389
+ color,
390
+ onColorChange,
391
+ onToggle,
392
+ onUndo,
393
+ onClear,
394
+ onDone,
395
+ capturing,
396
+ copied,
397
+ shake,
398
+ }: PenseatBarProps) {
399
+ const [dragPos, setDragPos] = useState<{ x: number; y: number } | null>(null);
400
+ // Flying state: animating from release to target corner
401
+ const [flyPos, setFlyPos] = useState<{ x: number; y: number } | null>(null);
402
+ // Projectile preview: target corner while dragging
403
+ const [targetPreview, setTargetPreview] = useState<Corner | null>(null);
404
+
405
+ // Keyboard shortcut flash effect
406
+ const [pressedId, setPressedId] = useState<string | null>(null);
407
+ const pressTimerRef = useRef<ReturnType<typeof setTimeout>>(null);
408
+
409
+ useEffect(() => {
410
+ if (!expanded) return;
411
+
412
+ function handleKeyDown(e: KeyboardEvent) {
413
+ let id: string | null = null;
414
+ const isMeta = e.metaKey || e.ctrlKey;
415
+
416
+ if (!isMeta && !e.shiftKey) {
417
+ if (["1", "2", "3", "4"].includes(e.key)) id = `color-${e.key}`;
418
+ else if (e.key === "e" || e.key === "5") id = "eraser";
419
+ else if (e.key === "x") id = "clear";
420
+ }
421
+ if (e.key === "Escape") id = "close";
422
+ if (isMeta && e.key === "z") id = "undo";
423
+ if (isMeta && e.key === "c") id = "copy";
424
+
425
+ if (id) {
426
+ if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
427
+ if (pressTimerRef.current) clearTimeout(pressTimerRef.current);
428
+ setPressedId(id);
429
+ pressTimerRef.current = setTimeout(() => setPressedId(null), 80);
430
+ }
431
+ }
432
+
433
+ window.addEventListener("keydown", handleKeyDown);
434
+ return () => {
435
+ window.removeEventListener("keydown", handleKeyDown);
436
+ if (pressTimerRef.current) clearTimeout(pressTimerRef.current);
437
+ };
438
+ }, [expanded]);
439
+ const isDragging = dragPos !== null;
440
+ const isFlying = flyPos !== null;
441
+ const wasDragRef = useRef(false);
442
+ const flyRafRef = useRef<number>(0);
443
+
444
+ // Measure toolbar content width for smooth expansion
445
+ const contentRef = useRef<HTMLDivElement>(null);
446
+ const [contentWidth, setContentWidth] = useState(0);
447
+
448
+ // Measure content scrollWidth (works even when parent clips)
449
+ useEffect(() => {
450
+ if (contentRef.current) {
451
+ setContentWidth(contentRef.current.scrollWidth);
452
+ }
453
+ });
454
+ // Also measure on window resize
455
+ useEffect(() => {
456
+ function measure() {
457
+ if (contentRef.current) setContentWidth(contentRef.current.scrollWidth);
458
+ }
459
+ window.addEventListener("resize", measure);
460
+ return () => window.removeEventListener("resize", measure);
461
+ }, []);
462
+
463
+
464
+ // Track velocity from recent pointer positions
465
+ const historyRef = useRef<{ x: number; y: number; t: number }[]>([]);
466
+
467
+ const handlePointerDown = useCallback((e: React.PointerEvent) => {
468
+ if (expanded) return;
469
+ const startX = e.clientX;
470
+ const startY = e.clientY;
471
+ const el = (e.currentTarget as HTMLElement).closest("[data-penseat='bar']") as HTMLElement;
472
+ const rect = el.getBoundingClientRect();
473
+ const offsetX = startX - (rect.left + rect.width / 2);
474
+ const offsetY = startY - (rect.top + rect.height / 2);
475
+ let moved = false;
476
+ historyRef.current = [{ x: startX, y: startY, t: performance.now() }];
477
+
478
+ function onMove(ev: PointerEvent) {
479
+ if (!moved && (Math.abs(ev.clientX - startX) > 6 || Math.abs(ev.clientY - startY) > 6)) {
480
+ moved = true;
481
+ }
482
+ if (moved) {
483
+ const clamped = clampCenter(ev.clientX - offsetX, ev.clientY - offsetY);
484
+ const cx = clamped.x;
485
+ const cy = clamped.y;
486
+ setDragPos({ x: cx, y: cy });
487
+
488
+ // Track movement history (keep last 200ms for direction)
489
+ const now = performance.now();
490
+ historyRef.current.push({ x: ev.clientX, y: ev.clientY, t: now });
491
+ while (historyRef.current.length > 2 && now - historyRef.current[0].t > 200) {
492
+ historyRef.current.shift();
493
+ }
494
+
495
+ // Direction = displacement over the last 200ms window
496
+ const h = historyRef.current;
497
+ if (h.length >= 2) {
498
+ const oldest = h[0];
499
+ const newest = h[h.length - 1];
500
+ const dt = (newest.t - oldest.t) / 1000;
501
+ if (dt > 0.016) {
502
+ const dx = newest.x - oldest.x;
503
+ const dy = newest.y - oldest.y;
504
+ const speed = Math.hypot(dx, dy) / dt;
505
+ if (speed > MIN_THROW_SPEED) {
506
+ setTargetPreview(cornerFromDirection(cx, cy, dx, dy));
507
+ } else {
508
+ setTargetPreview(null);
509
+ }
510
+ }
511
+ }
512
+ }
513
+ }
514
+
515
+ function onUp(ev: PointerEvent) {
516
+ window.removeEventListener("pointermove", onMove);
517
+ window.removeEventListener("pointerup", onUp);
518
+ setDragPos(null);
519
+ setTargetPreview(null);
520
+
521
+ if (!moved) return;
522
+
523
+ wasDragRef.current = true;
524
+ requestAnimationFrame(() => { wasDragRef.current = false; });
525
+
526
+ // Compute release direction from displacement over 200ms window
527
+ const h = historyRef.current;
528
+ let dx = 0, dy = 0, speed = 0;
529
+ if (h.length >= 2) {
530
+ const oldest = h[0];
531
+ const newest = h[h.length - 1];
532
+ const dt = (newest.t - oldest.t) / 1000;
533
+ if (dt > 0.016) {
534
+ dx = newest.x - oldest.x;
535
+ dy = newest.y - oldest.y;
536
+ speed = Math.hypot(dx, dy) / dt;
537
+ }
538
+ }
539
+
540
+ // Determine target from movement direction
541
+ const releaseX = ev.clientX - offsetX;
542
+ const releaseY = ev.clientY - offsetY;
543
+ const isThrow = speed > MIN_THROW_SPEED;
544
+ const target = isThrow
545
+ ? cornerFromDirection(releaseX, releaseY, dx, dy)
546
+ : closestCorner(releaseX, releaseY);
547
+
548
+ // Animate flight to target corner (faster for low velocity, longer for throws)
549
+ const dest = cornerCenter(target);
550
+ const startPos = clampCenter(releaseX, releaseY);
551
+ const dist = Math.hypot(dest.x - startPos.x, dest.y - startPos.y);
552
+ const duration = isThrow ? FLIGHT_DURATION : Math.max(120, Math.min(250, dist * 0.6));
553
+ const startTime = performance.now();
554
+
555
+ function animateFlight() {
556
+ const now = performance.now();
557
+ const elapsed = now - startTime;
558
+ const t = Math.min(elapsed / duration, 1);
559
+ const ease = 1 - Math.pow(1 - t, 3); // ease-out cubic
560
+
561
+ const rawX = startPos.x + (dest.x - startPos.x) * ease;
562
+ const rawY = startPos.y + (dest.y - startPos.y) * ease;
563
+ const clamped = clampCenter(rawX, rawY);
564
+
565
+ setFlyPos(clamped);
566
+
567
+ if (t < 1) {
568
+ flyRafRef.current = requestAnimationFrame(animateFlight);
569
+ } else {
570
+ setFlyPos(null);
571
+ onCornerChange(target);
572
+ }
573
+ }
574
+
575
+ flyRafRef.current = requestAnimationFrame(animateFlight);
576
+ }
577
+
578
+ window.addEventListener("pointermove", onMove);
579
+ window.addEventListener("pointerup", onUp);
580
+ }, [expanded, corner, onCornerChange]);
581
+
582
+ // Cleanup flight animation on unmount
583
+ useEffect(() => {
584
+ return () => cancelAnimationFrame(flyRafRef.current);
585
+ }, []);
586
+
587
+ // Position: flying > dragging > corner
588
+ const isRight = corner === "rb" || corner === "rt";
589
+ const isTop = corner === "lt" || corner === "rt";
590
+ let posStyle: Record<string, string> = {};
591
+
592
+ if (isFlying) {
593
+ posStyle = { left: `${flyPos.x - BAR_HEIGHT / 2}px`, top: `${flyPos.y - BAR_HEIGHT / 2}px` };
594
+ } else if (isDragging) {
595
+ posStyle = { left: `${dragPos.x - BAR_HEIGHT / 2}px`, top: `${dragPos.y - BAR_HEIGHT / 2}px` };
596
+ } else {
597
+ // Always anchor from the corner edge — both collapsed and expanded
598
+ // This ensures the bar grows/shrinks toward the corner
599
+ posStyle = {
600
+ top: isTop ? `${OFFSET}px` : `calc(100% - ${OFFSET}px - ${BAR_HEIGHT}px)`,
601
+ ...(isRight
602
+ ? { right: `${OFFSET}px`, left: "auto" }
603
+ : { left: `${OFFSET}px`, right: "auto" }),
604
+ };
605
+ }
606
+
607
+ const rightExpand = expandsRight(corner);
608
+ const isAnimating = isDragging || isFlying;
609
+
610
+ // Projectile preview line (clamped to viewport)
611
+ const previewTarget = targetPreview ? clampCenter(cornerCenter(targetPreview).x, cornerCenter(targetPreview).y) : null;
612
+
613
+ return (
614
+ <TooltipBelowCtx.Provider value={isTop}>
615
+ {/* Projectile preview line */}
616
+ {isDragging && previewTarget && dragPos && (
617
+ <svg
618
+ data-penseat="projectile"
619
+ className="fixed inset-0 z-[9998] pointer-events-none"
620
+ style={{ width: "100%", height: "100%" }}
621
+ >
622
+ <line
623
+ x1={dragPos.x}
624
+ y1={dragPos.y}
625
+ x2={previewTarget.x}
626
+ y2={previewTarget.y}
627
+ stroke="rgba(255,255,255,0.15)"
628
+ strokeWidth="2"
629
+ strokeDasharray="6 4"
630
+ />
631
+ {/* Target indicator */}
632
+ <circle
633
+ cx={previewTarget.x}
634
+ cy={previewTarget.y}
635
+ r="8"
636
+ fill="none"
637
+ stroke="rgba(255,255,255,0.2)"
638
+ strokeWidth="1.5"
639
+ />
640
+ </svg>
641
+ )}
642
+
643
+ <div
644
+ data-penseat="bar"
645
+ onKeyDown={(e) => { if (e.key === " " || e.key === "Enter") e.preventDefault(); }}
646
+ className={`fixed z-[9999] flex items-center overflow-hidden rounded-full bg-zinc-800 border-[1.5px] border-white/25 shadow-[0_0_0_0.5px_rgba(255,255,255,0.06),0_1px_3px_rgba(0,0,0,0.15),inset_0_0.5px_0_rgba(255,255,255,0.06)] ${isDragging ? "opacity-80 scale-105" : ""} ${isFlying ? "scale-95" : ""}`}
647
+ style={{
648
+ ...posStyle,
649
+ height: BAR_HEIGHT,
650
+ width: expanded ? contentWidth + (BAR_HEIGHT - 4) + 4 : BAR_HEIGHT,
651
+ flexDirection: rightExpand ? "row" : "row-reverse",
652
+ ...(isAnimating ? {} : {
653
+ transitionProperty: "all",
654
+ transitionTimingFunction: expanded ? "cubic-bezier(0.22, 1.12, 0.58, 1)" : "ease-out",
655
+ transitionDelay: expanded ? "120ms" : "0ms",
656
+ transitionDuration: expanded ? "260ms" : "80ms",
657
+ }),
658
+ }}
659
+ >
660
+ {/* Anchor icon (pen/close morph) — always at the corner edge */}
661
+ <AnchorIcon expanded={expanded} onToggle={onToggle} onPointerDown={handlePointerDown} wasDragRef={wasDragRef} pressed={pressedId === "close"} rightExpand={rightExpand} />
662
+
663
+ {/* Toolbar content — always in DOM, revealed/hidden by width animation */}
664
+ <div
665
+ ref={contentRef}
666
+ className="flex flex-row items-stretch self-stretch shrink-0 pr-2"
667
+ style={{ pointerEvents: expanded ? "auto" : "none" }}
668
+ >
669
+ <ColorDots colors={COLORS} active={color} onChange={onColorChange} pressedId={pressedId} />
670
+ <Actions onUndo={onUndo} onClear={onClear} pressedId={pressedId} />
671
+ <DoneBtn onClick={onDone} disabled={capturing || !!copied} capturing={capturing} copied={!!copied} pressed={pressedId === "copy"} shake={shake} />
672
+ </div>
673
+ </div>
674
+ </TooltipBelowCtx.Provider>
675
+ );
676
+ }