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.
- package/index.mjs +142 -0
- package/package.json +27 -0
- package/templates/components/drawing-canvas.tsx +632 -0
- package/templates/components/penseat-bar.tsx +676 -0
- package/templates/components/penseat.tsx +121 -0
- package/templates/components/ui/button.tsx +60 -0
- package/templates/lib/capture.ts +71 -0
- package/templates/lib/utils.ts +6 -0
|
@@ -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
|
+
}
|