penseat 0.1.0 → 0.3.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
CHANGED
|
@@ -42,7 +42,6 @@ const hasSrc = existsSync(join(cwd, "src"));
|
|
|
42
42
|
const base = hasSrc ? join(cwd, "src") : cwd;
|
|
43
43
|
|
|
44
44
|
const componentsDir = join(base, "components", "penseat");
|
|
45
|
-
const uiDir = join(componentsDir, "ui");
|
|
46
45
|
const libDir = join(base, "lib");
|
|
47
46
|
|
|
48
47
|
// ── Check if already installed ──
|
|
@@ -56,23 +55,16 @@ if (existsSync(join(componentsDir, "penseat.tsx"))) {
|
|
|
56
55
|
console.log(` ${dim("Installing to")} ${cyan(componentsDir.replace(cwd + "/", ""))}`);
|
|
57
56
|
console.log();
|
|
58
57
|
|
|
59
|
-
mkdirSync(
|
|
58
|
+
mkdirSync(componentsDir, { recursive: true });
|
|
60
59
|
mkdirSync(libDir, { recursive: true });
|
|
61
60
|
|
|
62
61
|
const files = [
|
|
63
62
|
{ from: "components/penseat.tsx", to: join(componentsDir, "penseat.tsx") },
|
|
64
63
|
{ from: "components/penseat-bar.tsx", to: join(componentsDir, "penseat-bar.tsx") },
|
|
65
64
|
{ from: "components/drawing-canvas.tsx", to: join(componentsDir, "drawing-canvas.tsx") },
|
|
66
|
-
{ from: "components/ui/button.tsx", to: join(componentsDir, "ui", "button.tsx") },
|
|
67
65
|
{ from: "lib/capture.ts", to: join(libDir, "capture.ts") },
|
|
68
66
|
];
|
|
69
67
|
|
|
70
|
-
// Only copy utils.ts if it doesn't exist (shadcn projects already have it)
|
|
71
|
-
const utilsTarget = join(libDir, "utils.ts");
|
|
72
|
-
if (!existsSync(utilsTarget)) {
|
|
73
|
-
files.push({ from: "lib/utils.ts", to: utilsTarget });
|
|
74
|
-
}
|
|
75
|
-
|
|
76
68
|
for (const f of files) {
|
|
77
69
|
copyFileSync(join(TEMPLATES, f.from), f.to);
|
|
78
70
|
}
|
|
@@ -82,11 +74,6 @@ console.log(` ${green("+")} Copied ${files.length} files`);
|
|
|
82
74
|
// ── Install dependencies ──
|
|
83
75
|
const needed = [
|
|
84
76
|
"html2canvas-pro",
|
|
85
|
-
"lucide-react",
|
|
86
|
-
"@base-ui/react",
|
|
87
|
-
"class-variance-authority",
|
|
88
|
-
"clsx",
|
|
89
|
-
"tailwind-merge",
|
|
90
77
|
];
|
|
91
78
|
|
|
92
79
|
const missing = needed.filter((d) => !deps[d]);
|
package/package.json
CHANGED
|
@@ -601,8 +601,10 @@ const DrawingCanvas = forwardRef<DrawingCanvasHandle, DrawingCanvasProps>(
|
|
|
601
601
|
<canvas
|
|
602
602
|
ref={canvasRef}
|
|
603
603
|
data-penseat="canvas"
|
|
604
|
-
className="absolute top-0 left-0"
|
|
605
604
|
style={{
|
|
605
|
+
position: "absolute",
|
|
606
|
+
top: 0,
|
|
607
|
+
left: 0,
|
|
606
608
|
pointerEvents: active ? "auto" : "none",
|
|
607
609
|
cursor: active
|
|
608
610
|
? color === ERASER_COLOR
|
|
@@ -616,8 +618,13 @@ const DrawingCanvas = forwardRef<DrawingCanvasHandle, DrawingCanvasProps>(
|
|
|
616
618
|
{active && isEraser && (
|
|
617
619
|
<div
|
|
618
620
|
ref={eraserCursorRef}
|
|
619
|
-
className="fixed z-[99999] pointer-events-none -translate-x-1/2 -translate-y-1/2 rounded-full border-2 border-white"
|
|
620
621
|
style={{
|
|
622
|
+
position: "fixed",
|
|
623
|
+
zIndex: 99999,
|
|
624
|
+
pointerEvents: "none",
|
|
625
|
+
transform: "translate(-50%, -50%)",
|
|
626
|
+
borderRadius: "9999px",
|
|
627
|
+
border: "2px solid #ffffff",
|
|
621
628
|
width: ERASER_RADIUS * 2,
|
|
622
629
|
height: ERASER_RADIUS * 2,
|
|
623
630
|
mixBlendMode: "difference",
|
|
@@ -2,8 +2,13 @@
|
|
|
2
2
|
|
|
3
3
|
import { useState, useCallback, useRef, useEffect, createContext, useContext, type ReactNode } from "react";
|
|
4
4
|
import { createPortal } from "react-dom";
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
// Inline SVG icons (from Lucide) — zero dependencies
|
|
6
|
+
const iconProps = { width: 16, height: 16, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round" as const, strokeLinejoin: "round" as const };
|
|
7
|
+
const Undo2Icon = <svg {...iconProps}><path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 5.5 5.5a5.5 5.5 0 0 1-5.5 5.5H11"/></svg>;
|
|
8
|
+
const Trash2Icon = <svg {...iconProps}><path d="M3 6h18"/><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/></svg>;
|
|
9
|
+
const XIcon = <svg {...iconProps} width={20} height={20}><path d="M18 6 6 18"/><path d="m6 6 12 12"/></svg>;
|
|
10
|
+
const CopyIcon = <svg {...iconProps}><rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/></svg>;
|
|
11
|
+
const CheckIcon = <svg {...iconProps} strokeWidth={3}><path d="M20 6 9 17l-5-5"/></svg>;
|
|
7
12
|
|
|
8
13
|
const TOOLTIP_DELAY = 360;
|
|
9
14
|
const TOOLTIP_GAP = 10;
|
|
@@ -25,13 +30,26 @@ const CMD_ICON = (
|
|
|
25
30
|
function Keycap({ children }: { children: ReactNode }) {
|
|
26
31
|
const content = children === "command" ? CMD_ICON : children === "shift" ? SHIFT_ICON : children;
|
|
27
32
|
return (
|
|
28
|
-
<kbd
|
|
33
|
+
<kbd style={{
|
|
34
|
+
display: 'inline-flex',
|
|
35
|
+
alignItems: 'center',
|
|
36
|
+
justifyContent: 'center',
|
|
37
|
+
width: '20px',
|
|
38
|
+
height: '20px',
|
|
39
|
+
borderRadius: '4px',
|
|
40
|
+
backgroundColor: '#3f3f46',
|
|
41
|
+
border: '1px solid #52525b',
|
|
42
|
+
fontSize: '13px',
|
|
43
|
+
fontFamily: 'monospace',
|
|
44
|
+
lineHeight: 1,
|
|
45
|
+
color: '#d4d4d8',
|
|
46
|
+
}}>
|
|
29
47
|
{content}
|
|
30
48
|
</kbd>
|
|
31
49
|
);
|
|
32
50
|
}
|
|
33
51
|
|
|
34
|
-
function WithTooltip({ label, shortcut, children, pressed }: { label: string; shortcut?: string[]; children: ReactNode; pressed?: boolean }) {
|
|
52
|
+
function WithTooltip({ label, shortcut, children, pressed, forceShow }: { label: string; shortcut?: string[]; children: ReactNode; pressed?: boolean; forceShow?: boolean }) {
|
|
35
53
|
const below = useContext(TooltipBelowCtx);
|
|
36
54
|
const [hoverShow, setHoverShow] = useState(false);
|
|
37
55
|
const [pos, setPos] = useState<{ x: number; top: number; bottom: number } | null>(null);
|
|
@@ -45,6 +63,11 @@ function WithTooltip({ label, shortcut, children, pressed }: { label: string; sh
|
|
|
45
63
|
}
|
|
46
64
|
}, []);
|
|
47
65
|
|
|
66
|
+
// Force-show: compute position on mount when forceShow is true
|
|
67
|
+
useEffect(() => {
|
|
68
|
+
if (forceShow) computePos();
|
|
69
|
+
}, [forceShow, computePos]);
|
|
70
|
+
|
|
48
71
|
const onEnter = () => {
|
|
49
72
|
timerRef.current = setTimeout(() => {
|
|
50
73
|
computePos();
|
|
@@ -61,17 +84,24 @@ function WithTooltip({ label, shortcut, children, pressed }: { label: string; sh
|
|
|
61
84
|
return (
|
|
62
85
|
<div
|
|
63
86
|
ref={wrapRef}
|
|
64
|
-
|
|
65
|
-
|
|
87
|
+
style={{
|
|
88
|
+
display: 'flex',
|
|
89
|
+
alignItems: 'center',
|
|
90
|
+
alignSelf: 'stretch',
|
|
91
|
+
transition: 'transform 100ms',
|
|
92
|
+
transform: pressed ? 'scale(0.95)' : undefined,
|
|
93
|
+
}}
|
|
66
94
|
onPointerEnter={onEnter}
|
|
67
95
|
onPointerLeave={onLeave}
|
|
68
96
|
>
|
|
69
97
|
{children}
|
|
70
|
-
{hoverShow && pos && createPortal(
|
|
98
|
+
{(hoverShow || forceShow) && pos && createPortal(
|
|
71
99
|
<div
|
|
72
100
|
data-penseat="tooltip"
|
|
73
|
-
className="fixed z-[99999] pointer-events-none"
|
|
74
101
|
style={{
|
|
102
|
+
position: 'fixed',
|
|
103
|
+
zIndex: 99999,
|
|
104
|
+
pointerEvents: 'none',
|
|
75
105
|
left: pos.x,
|
|
76
106
|
top: below ? pos.bottom + TOOLTIP_GAP : pos.top - TOOLTIP_GAP,
|
|
77
107
|
translate: below ? "-50% 0" : "-50% -100%",
|
|
@@ -79,10 +109,20 @@ function WithTooltip({ label, shortcut, children, pressed }: { label: string; sh
|
|
|
79
109
|
animation: below ? "tooltip-in-below 150ms ease-out" : "tooltip-in-above 150ms ease-out",
|
|
80
110
|
}}
|
|
81
111
|
>
|
|
82
|
-
<div
|
|
83
|
-
|
|
112
|
+
<div style={{
|
|
113
|
+
display: 'flex',
|
|
114
|
+
alignItems: 'center',
|
|
115
|
+
gap: '8px',
|
|
116
|
+
borderRadius: '8px',
|
|
117
|
+
backgroundColor: '#27272A',
|
|
118
|
+
paddingLeft: '12px',
|
|
119
|
+
paddingTop: '6px',
|
|
120
|
+
paddingBottom: '6px',
|
|
121
|
+
paddingRight: shortcut && shortcut.length > 0 ? '6px' : '12px',
|
|
122
|
+
}}>
|
|
123
|
+
<span style={{ fontSize: '12px', fontFamily: 'monospace', color: '#e4e4e7' }}>{label}</span>
|
|
84
124
|
{shortcut && shortcut.length > 0 && (
|
|
85
|
-
<div
|
|
125
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '2px' }}>
|
|
86
126
|
{shortcut.map((key, i) => (
|
|
87
127
|
<Keycap key={i}>{key}</Keycap>
|
|
88
128
|
))}
|
|
@@ -131,15 +171,27 @@ interface PenseatBarProps {
|
|
|
131
171
|
}
|
|
132
172
|
|
|
133
173
|
function EraserDot({ active, onClick }: { active: boolean; onClick: () => void }) {
|
|
174
|
+
const [hovered, setHovered] = useState(false);
|
|
134
175
|
return (
|
|
135
176
|
<div
|
|
136
177
|
onClick={onClick}
|
|
137
|
-
|
|
178
|
+
onPointerEnter={() => setHovered(true)}
|
|
179
|
+
onPointerLeave={() => setHovered(false)}
|
|
138
180
|
style={{
|
|
181
|
+
width: '20px',
|
|
182
|
+
height: '20px',
|
|
183
|
+
borderRadius: '9999px',
|
|
184
|
+
cursor: 'pointer',
|
|
185
|
+
transition: 'transform 150ms',
|
|
186
|
+
transform: hovered ? 'scale(1.1)' : 'scale(1)',
|
|
187
|
+
flexShrink: 0,
|
|
188
|
+
display: 'flex',
|
|
189
|
+
alignItems: 'center',
|
|
190
|
+
justifyContent: 'center',
|
|
139
191
|
boxShadow: active ? "0 0 0 2px #18181b, 0 0 0 3px #a1a1aa" : "none",
|
|
140
192
|
}}
|
|
141
193
|
>
|
|
142
|
-
<svg viewBox="0 0 20 20"
|
|
194
|
+
<svg viewBox="0 0 20 20" style={{ width: '20px', height: '20px' }}>
|
|
143
195
|
<defs>
|
|
144
196
|
<clipPath id="eraser-clip">
|
|
145
197
|
<circle cx="10" cy="10" r="8.5" />
|
|
@@ -158,7 +210,7 @@ function EraserDot({ active, onClick }: { active: boolean; onClick: () => void }
|
|
|
158
210
|
|
|
159
211
|
function ColorDots({ colors, active, onChange, pressedId }: { colors: typeof COLORS; active: string; onChange: (c: string) => void; pressedId: string | null }) {
|
|
160
212
|
return (
|
|
161
|
-
<div
|
|
213
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px', padding: '0 12px', flexShrink: 0 }}>
|
|
162
214
|
{colors.map((c, i) => {
|
|
163
215
|
if (c.name === "Eraser") {
|
|
164
216
|
return (
|
|
@@ -168,41 +220,105 @@ function ColorDots({ colors, active, onChange, pressedId }: { colors: typeof COL
|
|
|
168
220
|
);
|
|
169
221
|
}
|
|
170
222
|
return (
|
|
171
|
-
<
|
|
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>
|
|
223
|
+
<ColorDot key={c.value} color={c} index={i} active={active === c.value} onChange={onChange} pressedId={pressedId} />
|
|
181
224
|
);
|
|
182
225
|
})}
|
|
183
226
|
</div>
|
|
184
227
|
);
|
|
185
228
|
}
|
|
186
229
|
|
|
230
|
+
function ColorDot({ color: c, index: i, active, onChange, pressedId }: { color: typeof COLORS[number]; index: number; active: boolean; onChange: (c: string) => void; pressedId: string | null }) {
|
|
231
|
+
const [hovered, setHovered] = useState(false);
|
|
232
|
+
return (
|
|
233
|
+
<WithTooltip label={c.name} shortcut={[String(i + 1)]} pressed={pressedId === `color-${i + 1}`}>
|
|
234
|
+
<div
|
|
235
|
+
onClick={() => onChange(c.value)}
|
|
236
|
+
onPointerEnter={() => setHovered(true)}
|
|
237
|
+
onPointerLeave={() => setHovered(false)}
|
|
238
|
+
style={{
|
|
239
|
+
width: '20px',
|
|
240
|
+
height: '20px',
|
|
241
|
+
borderRadius: '9999px',
|
|
242
|
+
cursor: 'pointer',
|
|
243
|
+
transition: 'transform 150ms',
|
|
244
|
+
transform: hovered ? 'scale(1.1)' : 'scale(1)',
|
|
245
|
+
flexShrink: 0,
|
|
246
|
+
backgroundColor: c.value,
|
|
247
|
+
boxShadow: active ? `0 0 0 2px #18181b, 0 0 0 3px ${c.value}` : "none",
|
|
248
|
+
}}
|
|
249
|
+
/>
|
|
250
|
+
</WithTooltip>
|
|
251
|
+
);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function ActionButton({ icon, ariaLabel, onClick, pressed }: { icon: ReactNode; ariaLabel: string; onClick: () => void; pressed: boolean }) {
|
|
255
|
+
const [hovered, setHovered] = useState(false);
|
|
256
|
+
const [active, setActive] = useState(false);
|
|
257
|
+
const isDown = pressed || active;
|
|
258
|
+
return (
|
|
259
|
+
<button
|
|
260
|
+
tabIndex={-1}
|
|
261
|
+
data-penseat-btn
|
|
262
|
+
aria-label={ariaLabel}
|
|
263
|
+
onClick={onClick}
|
|
264
|
+
onPointerEnter={() => setHovered(true)}
|
|
265
|
+
onPointerLeave={() => { setHovered(false); setActive(false); }}
|
|
266
|
+
onPointerDown={() => setActive(true)}
|
|
267
|
+
onPointerUp={() => setActive(false)}
|
|
268
|
+
style={{
|
|
269
|
+
display: 'flex',
|
|
270
|
+
alignItems: 'center',
|
|
271
|
+
justifyContent: 'center',
|
|
272
|
+
width: '32px',
|
|
273
|
+
height: '32px',
|
|
274
|
+
flexShrink: 0,
|
|
275
|
+
cursor: 'pointer',
|
|
276
|
+
transition: 'all 150ms',
|
|
277
|
+
transform: isDown ? 'scale(0.9)' : undefined,
|
|
278
|
+
color: (isDown || hovered) ? '#f4f4f5' : '#a1a1aa',
|
|
279
|
+
backgroundColor: (isDown || hovered) ? '#3f3f46' : 'transparent',
|
|
280
|
+
border: 'none',
|
|
281
|
+
outline: 'none',
|
|
282
|
+
borderRadius: '6px',
|
|
283
|
+
padding: 0,
|
|
284
|
+
}}
|
|
285
|
+
>
|
|
286
|
+
{icon}
|
|
287
|
+
</button>
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
|
|
187
291
|
function Actions({ onUndo, onClear, pressedId }: { onUndo: () => void; onClear: () => void; pressedId: string | null }) {
|
|
188
292
|
return (
|
|
189
|
-
<div
|
|
293
|
+
<div style={{
|
|
294
|
+
display: 'flex',
|
|
295
|
+
alignItems: 'center',
|
|
296
|
+
gap: '4px',
|
|
297
|
+
padding: '0 4px',
|
|
298
|
+
opacity: 1,
|
|
299
|
+
}}>
|
|
190
300
|
<WithTooltip label="Undo" shortcut={["command", "Z"]} pressed={pressedId === "undo"}>
|
|
191
|
-
<
|
|
192
|
-
|
|
193
|
-
|
|
301
|
+
<ActionButton
|
|
302
|
+
icon={Undo2Icon}
|
|
303
|
+
ariaLabel="Undo"
|
|
304
|
+
onClick={onUndo}
|
|
305
|
+
pressed={pressedId === "undo"}
|
|
306
|
+
/>
|
|
194
307
|
</WithTooltip>
|
|
195
308
|
<WithTooltip label="Clear all" shortcut={["X"]} pressed={pressedId === "clear"}>
|
|
196
|
-
<
|
|
197
|
-
|
|
198
|
-
|
|
309
|
+
<ActionButton
|
|
310
|
+
icon={Trash2Icon}
|
|
311
|
+
ariaLabel="Clear all"
|
|
312
|
+
onClick={onClear}
|
|
313
|
+
pressed={pressedId === "clear"}
|
|
314
|
+
/>
|
|
199
315
|
</WithTooltip>
|
|
200
316
|
</div>
|
|
201
317
|
);
|
|
202
318
|
}
|
|
203
319
|
|
|
204
320
|
const PEN_ICON = (
|
|
205
|
-
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
|
|
321
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" style={{ width: '20px', height: '20px' }} fill="none">
|
|
206
322
|
<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
323
|
</svg>
|
|
208
324
|
);
|
|
@@ -216,6 +332,7 @@ function AnchorIcon({ expanded, onToggle, onPointerDown, wasDragRef, pressed, ri
|
|
|
216
332
|
pressed?: boolean;
|
|
217
333
|
rightExpand: boolean;
|
|
218
334
|
}) {
|
|
335
|
+
const [hovered, setHovered] = useState(false);
|
|
219
336
|
// Spin toward the toolbar: clockwise when expanding right, counter-clockwise when left
|
|
220
337
|
const spinDeg = rightExpand ? 90 : -90;
|
|
221
338
|
return (
|
|
@@ -224,18 +341,39 @@ function AnchorIcon({ expanded, onToggle, onPointerDown, wasDragRef, pressed, ri
|
|
|
224
341
|
data-penseat="trigger"
|
|
225
342
|
onClick={(e) => { (e.currentTarget as HTMLElement).blur(); if (!wasDragRef.current) onToggle(); }}
|
|
226
343
|
onPointerDown={expanded ? undefined : onPointerDown}
|
|
227
|
-
|
|
344
|
+
onPointerEnter={() => setHovered(true)}
|
|
345
|
+
onPointerLeave={() => setHovered(false)}
|
|
228
346
|
style={{
|
|
347
|
+
position: 'relative',
|
|
348
|
+
display: 'flex',
|
|
349
|
+
alignItems: 'center',
|
|
350
|
+
justifyContent: 'center',
|
|
351
|
+
flexShrink: 0,
|
|
352
|
+
cursor: 'pointer',
|
|
353
|
+
backgroundColor: hovered ? '#3f3f46' : 'transparent',
|
|
354
|
+
borderRadius: '9999px',
|
|
355
|
+
border: 'none',
|
|
356
|
+
outline: 'none',
|
|
357
|
+
padding: 0,
|
|
229
358
|
width: BAR_HEIGHT - 4,
|
|
230
359
|
height: BAR_HEIGHT - 4,
|
|
231
360
|
transform: pressed ? "scale(0.9)" : undefined,
|
|
232
|
-
transition: "transform 100ms",
|
|
361
|
+
transition: "transform 100ms, background-color 150ms",
|
|
233
362
|
}}
|
|
234
363
|
>
|
|
235
364
|
{/* Pen icon — visible when collapsed */}
|
|
236
365
|
<span
|
|
237
|
-
className="absolute inset-0 flex items-center justify-center text-white transition-all duration-200 ease-in-out"
|
|
238
366
|
style={{
|
|
367
|
+
position: 'absolute',
|
|
368
|
+
top: 0,
|
|
369
|
+
left: 0,
|
|
370
|
+
right: 0,
|
|
371
|
+
bottom: 0,
|
|
372
|
+
display: 'flex',
|
|
373
|
+
alignItems: 'center',
|
|
374
|
+
justifyContent: 'center',
|
|
375
|
+
color: '#ffffff',
|
|
376
|
+
transition: 'all 200ms ease-in-out',
|
|
239
377
|
opacity: expanded ? 0 : 1,
|
|
240
378
|
transform: expanded ? "scale(0.5)" : "scale(1)",
|
|
241
379
|
filter: expanded ? "blur(4px)" : "blur(0px)",
|
|
@@ -247,8 +385,17 @@ function AnchorIcon({ expanded, onToggle, onPointerDown, wasDragRef, pressed, ri
|
|
|
247
385
|
</span>
|
|
248
386
|
{/* Close icon — spins in from the toolbar direction */}
|
|
249
387
|
<span
|
|
250
|
-
className="absolute inset-0 flex items-center justify-center text-zinc-400 transition-all duration-300 ease-in-out"
|
|
251
388
|
style={{
|
|
389
|
+
position: 'absolute',
|
|
390
|
+
top: 0,
|
|
391
|
+
left: 0,
|
|
392
|
+
right: 0,
|
|
393
|
+
bottom: 0,
|
|
394
|
+
display: 'flex',
|
|
395
|
+
alignItems: 'center',
|
|
396
|
+
justifyContent: 'center',
|
|
397
|
+
color: '#a1a1aa',
|
|
398
|
+
transition: 'all 300ms ease-in-out',
|
|
252
399
|
opacity: expanded ? 1 : 0,
|
|
253
400
|
transform: expanded ? "scale(1) rotate(0deg)" : `scale(0.5) rotate(${-spinDeg}deg)`,
|
|
254
401
|
filter: expanded ? "blur(0px)" : "blur(4px)",
|
|
@@ -256,44 +403,96 @@ function AnchorIcon({ expanded, onToggle, onPointerDown, wasDragRef, pressed, ri
|
|
|
256
403
|
transitionDelay: expanded ? "0ms" : "60ms",
|
|
257
404
|
}}
|
|
258
405
|
>
|
|
259
|
-
|
|
406
|
+
{XIcon}
|
|
260
407
|
</span>
|
|
261
408
|
</button>
|
|
262
409
|
);
|
|
263
410
|
}
|
|
264
411
|
|
|
265
412
|
function DoneBtn({ onClick, disabled, capturing, copied, pressed, shake }: { onClick: () => void; disabled: boolean; capturing: boolean; copied: boolean; pressed: boolean; shake?: boolean }) {
|
|
413
|
+
const [hovered, setHovered] = useState(false);
|
|
414
|
+
const [active, setActive] = useState(false);
|
|
415
|
+
const isDown = (pressed || active) && !shake;
|
|
266
416
|
const anim = copied ? "copy-bounce 600ms ease-out" : shake ? "nope-shake 320ms ease-out" : undefined;
|
|
267
417
|
return (
|
|
268
|
-
<div
|
|
269
|
-
|
|
270
|
-
|
|
418
|
+
<div style={{
|
|
419
|
+
display: 'flex',
|
|
420
|
+
alignItems: 'center',
|
|
421
|
+
alignSelf: 'stretch',
|
|
422
|
+
animation: anim,
|
|
423
|
+
}}>
|
|
424
|
+
<WithTooltip label={copied ? "Copied!" : "Copy"} shortcut={copied ? undefined : ["command", "C"]} pressed={pressed} forceShow={copied}>
|
|
425
|
+
<button
|
|
271
426
|
tabIndex={-1}
|
|
272
|
-
|
|
273
|
-
size="icon"
|
|
427
|
+
data-penseat-btn
|
|
274
428
|
onClick={onClick}
|
|
429
|
+
onPointerEnter={() => setHovered(true)}
|
|
430
|
+
onPointerLeave={() => { setHovered(false); setActive(false); }}
|
|
431
|
+
onPointerDown={() => setActive(true)}
|
|
432
|
+
onPointerUp={() => setActive(false)}
|
|
275
433
|
disabled={disabled && !copied}
|
|
276
434
|
aria-label="Done"
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
435
|
+
style={{
|
|
436
|
+
display: 'flex',
|
|
437
|
+
alignItems: 'center',
|
|
438
|
+
justifyContent: 'center',
|
|
439
|
+
width: '32px',
|
|
440
|
+
height: '32px',
|
|
441
|
+
flexShrink: 0,
|
|
442
|
+
cursor: 'pointer',
|
|
443
|
+
transition: 'all 200ms',
|
|
444
|
+
transform: isDown ? 'scale(0.9)' : undefined,
|
|
445
|
+
border: 'none',
|
|
446
|
+
outline: 'none',
|
|
447
|
+
padding: 0,
|
|
448
|
+
borderRadius: '6px',
|
|
449
|
+
backgroundColor: (isDown || hovered) ? '#3f3f46' : 'transparent',
|
|
450
|
+
color: (isDown || hovered) ? '#f4f4f5' : '#a1a1aa',
|
|
451
|
+
opacity: (disabled && !copied) ? 0.5 : 1,
|
|
452
|
+
}}
|
|
285
453
|
>
|
|
286
454
|
{capturing ? (
|
|
287
|
-
<span
|
|
455
|
+
<span style={{
|
|
456
|
+
width: '12px',
|
|
457
|
+
height: '12px',
|
|
458
|
+
borderRadius: '9999px',
|
|
459
|
+
border: '2px solid rgba(161,161,170,0.3)',
|
|
460
|
+
borderTopColor: '#a1a1aa',
|
|
461
|
+
animation: 'penseat-spin 1s linear infinite',
|
|
462
|
+
}} />
|
|
288
463
|
) : (
|
|
289
|
-
<span
|
|
290
|
-
<
|
|
291
|
-
|
|
464
|
+
<span style={{ position: 'relative', width: '16px', height: '16px' }}>
|
|
465
|
+
<span style={{
|
|
466
|
+
position: 'absolute',
|
|
467
|
+
top: 0,
|
|
468
|
+
left: 0,
|
|
469
|
+
right: 0,
|
|
470
|
+
bottom: 0,
|
|
471
|
+
display: 'flex',
|
|
472
|
+
alignItems: 'center',
|
|
473
|
+
justifyContent: 'center',
|
|
474
|
+
transition: 'all 180ms ease-out',
|
|
475
|
+
opacity: copied ? 0 : 1,
|
|
476
|
+
transform: copied ? "scale(0.5)" : "scale(1)",
|
|
477
|
+
}}>{CopyIcon}</span>
|
|
478
|
+
<span style={{
|
|
479
|
+
position: 'absolute',
|
|
480
|
+
top: 0,
|
|
481
|
+
left: 0,
|
|
482
|
+
right: 0,
|
|
483
|
+
bottom: 0,
|
|
484
|
+
display: 'flex',
|
|
485
|
+
alignItems: 'center',
|
|
486
|
+
justifyContent: 'center',
|
|
487
|
+
transition: 'all 180ms ease-out',
|
|
488
|
+
opacity: copied ? 1 : 0,
|
|
489
|
+
transform: copied ? "scale(1)" : "scale(0.5)",
|
|
490
|
+
}}>{CheckIcon}</span>
|
|
292
491
|
</span>
|
|
293
492
|
)}
|
|
294
|
-
</
|
|
493
|
+
</button>
|
|
295
494
|
</WithTooltip>
|
|
296
|
-
{(shake || copied) && (
|
|
495
|
+
{(shake || copied || capturing) && (
|
|
297
496
|
<style>{`
|
|
298
497
|
@keyframes nope-shake {
|
|
299
498
|
0% { transform: translateX(0); }
|
|
@@ -309,6 +508,13 @@ function DoneBtn({ onClick, disabled, capturing, copied, pressed, shake }: { onC
|
|
|
309
508
|
70% { transform: translateY(-2px); }
|
|
310
509
|
100% { transform: translateY(0); }
|
|
311
510
|
}
|
|
511
|
+
@keyframes penseat-spin {
|
|
512
|
+
from { transform: rotate(0deg); }
|
|
513
|
+
to { transform: rotate(360deg); }
|
|
514
|
+
}
|
|
515
|
+
[data-penseat-btn]:active {
|
|
516
|
+
transform: scale(0.9) !important;
|
|
517
|
+
}
|
|
312
518
|
`}</style>
|
|
313
519
|
)}
|
|
314
520
|
</div>
|
|
@@ -616,8 +822,17 @@ export default function PenseatBar({
|
|
|
616
822
|
{isDragging && previewTarget && dragPos && (
|
|
617
823
|
<svg
|
|
618
824
|
data-penseat="projectile"
|
|
619
|
-
|
|
620
|
-
|
|
825
|
+
style={{
|
|
826
|
+
position: 'fixed',
|
|
827
|
+
top: 0,
|
|
828
|
+
left: 0,
|
|
829
|
+
right: 0,
|
|
830
|
+
bottom: 0,
|
|
831
|
+
zIndex: 9998,
|
|
832
|
+
pointerEvents: 'none',
|
|
833
|
+
width: '100%',
|
|
834
|
+
height: '100%',
|
|
835
|
+
}}
|
|
621
836
|
>
|
|
622
837
|
<line
|
|
623
838
|
x1={dragPos.x}
|
|
@@ -643,8 +858,18 @@ export default function PenseatBar({
|
|
|
643
858
|
<div
|
|
644
859
|
data-penseat="bar"
|
|
645
860
|
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
861
|
style={{
|
|
862
|
+
position: 'fixed',
|
|
863
|
+
zIndex: 9999,
|
|
864
|
+
display: 'flex',
|
|
865
|
+
alignItems: 'center',
|
|
866
|
+
overflow: 'hidden',
|
|
867
|
+
borderRadius: '9999px',
|
|
868
|
+
backgroundColor: '#27272a',
|
|
869
|
+
border: '1.5px solid rgba(255,255,255,0.25)',
|
|
870
|
+
boxShadow: '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)',
|
|
871
|
+
opacity: isDragging ? 0.8 : 1,
|
|
872
|
+
transform: isDragging ? 'scale(1.05)' : isFlying ? 'scale(0.95)' : undefined,
|
|
648
873
|
...posStyle,
|
|
649
874
|
height: BAR_HEIGHT,
|
|
650
875
|
width: expanded ? contentWidth + (BAR_HEIGHT - 4) + 4 : BAR_HEIGHT,
|
|
@@ -663,11 +888,18 @@ export default function PenseatBar({
|
|
|
663
888
|
{/* Toolbar content — always in DOM, revealed/hidden by width animation */}
|
|
664
889
|
<div
|
|
665
890
|
ref={contentRef}
|
|
666
|
-
|
|
667
|
-
|
|
891
|
+
style={{
|
|
892
|
+
display: 'flex',
|
|
893
|
+
flexDirection: 'row',
|
|
894
|
+
alignItems: 'stretch',
|
|
895
|
+
alignSelf: 'stretch',
|
|
896
|
+
flexShrink: 0,
|
|
897
|
+
paddingRight: '8px',
|
|
898
|
+
pointerEvents: expanded ? 'auto' : 'none',
|
|
899
|
+
}}
|
|
668
900
|
>
|
|
669
901
|
<ColorDots colors={COLORS} active={color} onChange={onColorChange} pressedId={pressedId} />
|
|
670
|
-
<Actions onUndo={onUndo} onClear={onClear} pressedId={pressedId} />
|
|
902
|
+
<Actions onUndo={onUndo} onClear={onClear} pressedId={pressedId} />
|
|
671
903
|
<DoneBtn onClick={onDone} disabled={capturing || !!copied} capturing={capturing} copied={!!copied} pressed={pressedId === "copy"} shake={shake} />
|
|
672
904
|
</div>
|
|
673
905
|
</div>
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
"use client"
|
|
2
|
-
|
|
3
|
-
import { Button as ButtonPrimitive } from "@base-ui/react/button"
|
|
4
|
-
import { cva, type VariantProps } from "class-variance-authority"
|
|
5
|
-
|
|
6
|
-
import { cn } from "@/lib/utils"
|
|
7
|
-
|
|
8
|
-
const buttonVariants = cva(
|
|
9
|
-
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
10
|
-
{
|
|
11
|
-
variants: {
|
|
12
|
-
variant: {
|
|
13
|
-
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
|
|
14
|
-
outline:
|
|
15
|
-
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
|
|
16
|
-
secondary:
|
|
17
|
-
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
|
|
18
|
-
ghost:
|
|
19
|
-
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
|
|
20
|
-
destructive:
|
|
21
|
-
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
|
|
22
|
-
link: "text-primary underline-offset-4 hover:underline",
|
|
23
|
-
},
|
|
24
|
-
size: {
|
|
25
|
-
default:
|
|
26
|
-
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
|
|
27
|
-
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
|
28
|
-
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
|
29
|
-
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-3 has-data-[icon=inline-start]:pl-3",
|
|
30
|
-
icon: "size-8",
|
|
31
|
-
"icon-xs":
|
|
32
|
-
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
|
33
|
-
"icon-sm":
|
|
34
|
-
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
|
|
35
|
-
"icon-lg": "size-9",
|
|
36
|
-
},
|
|
37
|
-
},
|
|
38
|
-
defaultVariants: {
|
|
39
|
-
variant: "default",
|
|
40
|
-
size: "default",
|
|
41
|
-
},
|
|
42
|
-
}
|
|
43
|
-
)
|
|
44
|
-
|
|
45
|
-
function Button({
|
|
46
|
-
className,
|
|
47
|
-
variant = "default",
|
|
48
|
-
size = "default",
|
|
49
|
-
...props
|
|
50
|
-
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
|
51
|
-
return (
|
|
52
|
-
<ButtonPrimitive
|
|
53
|
-
data-slot="button"
|
|
54
|
-
className={cn(buttonVariants({ variant, size, className }))}
|
|
55
|
-
{...props}
|
|
56
|
-
/>
|
|
57
|
-
)
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
export { Button, buttonVariants }
|