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,121 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useRef, useCallback, useEffect } from "react";
|
|
4
|
+
import DrawingCanvas, { type DrawingCanvasHandle } from "./drawing-canvas";
|
|
5
|
+
import PenseatBar, { type Corner } from "./penseat-bar";
|
|
6
|
+
import { captureAndCopy } from "@/lib/capture";
|
|
7
|
+
|
|
8
|
+
type Mode = "idle" | "drawing" | "capturing";
|
|
9
|
+
|
|
10
|
+
export default function Penseat() {
|
|
11
|
+
const [mode, setMode] = useState<Mode>("idle");
|
|
12
|
+
const [color, setColor] = useState("#ef4444");
|
|
13
|
+
const [corner, setCorner] = useState<Corner>("rb");
|
|
14
|
+
const [shake, setShake] = useState(false);
|
|
15
|
+
const [copied, setCopied] = useState(false);
|
|
16
|
+
const shakeTimer = useRef<ReturnType<typeof setTimeout>>(null);
|
|
17
|
+
const canvasRef = useRef<DrawingCanvasHandle>(null);
|
|
18
|
+
|
|
19
|
+
const triggerShake = useCallback(() => {
|
|
20
|
+
if (shakeTimer.current) return; // previous shake still running
|
|
21
|
+
setShake(true);
|
|
22
|
+
shakeTimer.current = setTimeout(() => {
|
|
23
|
+
setShake(false);
|
|
24
|
+
shakeTimer.current = null;
|
|
25
|
+
}, 400);
|
|
26
|
+
}, []);
|
|
27
|
+
|
|
28
|
+
const toggle = useCallback(() => {
|
|
29
|
+
if (mode === "idle") {
|
|
30
|
+
setMode("drawing");
|
|
31
|
+
} else {
|
|
32
|
+
canvasRef.current?.clear();
|
|
33
|
+
setMode("idle");
|
|
34
|
+
}
|
|
35
|
+
}, [mode]);
|
|
36
|
+
|
|
37
|
+
const handleDone = useCallback(() => {
|
|
38
|
+
const canvas = canvasRef.current?.getCanvas();
|
|
39
|
+
if (!canvas) return;
|
|
40
|
+
|
|
41
|
+
if (!canvasRef.current?.hasStrokes()) {
|
|
42
|
+
triggerShake();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Fire clipboard.write IMMEDIATELY — must be synchronous within user activation
|
|
47
|
+
const copyPromise = captureAndCopy(canvas);
|
|
48
|
+
|
|
49
|
+
setMode("capturing");
|
|
50
|
+
|
|
51
|
+
copyPromise.then(() => {
|
|
52
|
+
setMode("drawing");
|
|
53
|
+
setCopied(true);
|
|
54
|
+
setTimeout(() => {
|
|
55
|
+
setCopied(false);
|
|
56
|
+
}, 1200);
|
|
57
|
+
}).catch((err) => {
|
|
58
|
+
console.error("Capture failed:", err);
|
|
59
|
+
triggerShake();
|
|
60
|
+
setMode("drawing");
|
|
61
|
+
});
|
|
62
|
+
}, []);
|
|
63
|
+
|
|
64
|
+
useEffect(() => {
|
|
65
|
+
const COLORS = ["#ef4444", "#eab308", "#3b82f6", "#22c55e"];
|
|
66
|
+
|
|
67
|
+
function handleKeyDown(e: KeyboardEvent) {
|
|
68
|
+
if ((e.metaKey || e.ctrlKey) && e.shiftKey && e.key === "d") {
|
|
69
|
+
e.preventDefault();
|
|
70
|
+
toggle();
|
|
71
|
+
}
|
|
72
|
+
if (e.key === "Escape" && mode === "drawing") {
|
|
73
|
+
toggle();
|
|
74
|
+
}
|
|
75
|
+
if (mode !== "drawing") return;
|
|
76
|
+
|
|
77
|
+
const isMeta = e.metaKey || e.ctrlKey;
|
|
78
|
+
if (isMeta && e.key === "c") {
|
|
79
|
+
handleDone();
|
|
80
|
+
}
|
|
81
|
+
if (isMeta && e.key === "z") {
|
|
82
|
+
e.preventDefault();
|
|
83
|
+
canvasRef.current?.undo();
|
|
84
|
+
}
|
|
85
|
+
if (!isMeta && !e.shiftKey) {
|
|
86
|
+
if (e.key === "x") canvasRef.current?.clear();
|
|
87
|
+
if (e.key === "e" || e.key === "5") setColor("#ffffff");
|
|
88
|
+
const idx = ["1", "2", "3", "4"].indexOf(e.key);
|
|
89
|
+
if (idx !== -1) setColor(COLORS[idx]);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
window.addEventListener("keydown", handleKeyDown);
|
|
94
|
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
95
|
+
}, [mode, toggle, handleDone]);
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<>
|
|
99
|
+
<DrawingCanvas
|
|
100
|
+
ref={canvasRef}
|
|
101
|
+
active={mode === "drawing" || mode === "capturing"}
|
|
102
|
+
color={color}
|
|
103
|
+
/>
|
|
104
|
+
|
|
105
|
+
<PenseatBar
|
|
106
|
+
expanded={mode !== "idle"}
|
|
107
|
+
corner={corner}
|
|
108
|
+
onCornerChange={setCorner}
|
|
109
|
+
color={color}
|
|
110
|
+
onColorChange={setColor}
|
|
111
|
+
onToggle={toggle}
|
|
112
|
+
onUndo={() => canvasRef.current?.undo()}
|
|
113
|
+
onClear={() => canvasRef.current?.clear()}
|
|
114
|
+
onDone={handleDone}
|
|
115
|
+
capturing={mode === "capturing"}
|
|
116
|
+
copied={copied}
|
|
117
|
+
shake={shake}
|
|
118
|
+
/>
|
|
119
|
+
</>
|
|
120
|
+
);
|
|
121
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
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 }
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import html2canvas from "html2canvas-pro";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Captures the page + drawing annotations and copies to clipboard.
|
|
5
|
+
*
|
|
6
|
+
* Key trick: ClipboardItem accepts Promise<Blob>, so we call
|
|
7
|
+
* navigator.clipboard.write() IMMEDIATELY during the user gesture,
|
|
8
|
+
* passing a promise that resolves later after html2canvas finishes.
|
|
9
|
+
* This keeps the clipboard write within the user activation window.
|
|
10
|
+
*/
|
|
11
|
+
export function captureAndCopy(
|
|
12
|
+
drawingCanvas: HTMLCanvasElement
|
|
13
|
+
): Promise<void> {
|
|
14
|
+
// Create the async blob promise — resolved after capture completes
|
|
15
|
+
const blobPromise = captureComposite(drawingCanvas).then(
|
|
16
|
+
(composite) =>
|
|
17
|
+
new Promise<Blob>((resolve, reject) => {
|
|
18
|
+
composite.toBlob(
|
|
19
|
+
(b) =>
|
|
20
|
+
b ? resolve(b) : reject(new Error("Failed to create blob")),
|
|
21
|
+
"image/png"
|
|
22
|
+
);
|
|
23
|
+
})
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
// Write to clipboard IMMEDIATELY (during user gesture) with promised data
|
|
27
|
+
return navigator.clipboard.write([
|
|
28
|
+
new ClipboardItem({ "image/png": blobPromise }),
|
|
29
|
+
]);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function captureComposite(
|
|
33
|
+
drawingCanvas: HTMLCanvasElement
|
|
34
|
+
): Promise<HTMLCanvasElement> {
|
|
35
|
+
// Screenshot the page DOM (visible viewport)
|
|
36
|
+
const pageCanvas = await html2canvas(document.body, {
|
|
37
|
+
useCORS: true,
|
|
38
|
+
allowTaint: true,
|
|
39
|
+
x: window.scrollX,
|
|
40
|
+
y: window.scrollY,
|
|
41
|
+
width: window.innerWidth,
|
|
42
|
+
height: window.innerHeight,
|
|
43
|
+
ignoreElements: (el) => {
|
|
44
|
+
return el.closest("[data-penseat]") !== null;
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Composite page + drawing annotations
|
|
49
|
+
const composite = document.createElement("canvas");
|
|
50
|
+
composite.width = pageCanvas.width;
|
|
51
|
+
composite.height = pageCanvas.height;
|
|
52
|
+
const ctx = composite.getContext("2d")!;
|
|
53
|
+
|
|
54
|
+
ctx.drawImage(pageCanvas, 0, 0);
|
|
55
|
+
|
|
56
|
+
// The drawing canvas is document-sized — crop visible viewport portion
|
|
57
|
+
const dpr = window.devicePixelRatio || 1;
|
|
58
|
+
ctx.drawImage(
|
|
59
|
+
drawingCanvas,
|
|
60
|
+
window.scrollX * dpr,
|
|
61
|
+
window.scrollY * dpr,
|
|
62
|
+
window.innerWidth * dpr,
|
|
63
|
+
window.innerHeight * dpr,
|
|
64
|
+
0,
|
|
65
|
+
0,
|
|
66
|
+
composite.width,
|
|
67
|
+
composite.height
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
return composite;
|
|
71
|
+
}
|