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,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
+ }
@@ -0,0 +1,6 @@
1
+ import { clsx, type ClassValue } from "clsx"
2
+ import { twMerge } from "tailwind-merge"
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs))
6
+ }