@ogulcancelik/pi-sketch 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/LICENSE +21 -0
- package/README.md +57 -0
- package/assets/demo.gif +0 -0
- package/extensions/index.ts +208 -0
- package/extensions/sketch.html +411 -0
- package/package.json +36 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 Can Celik
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# pi-sketch
|
|
2
|
+
|
|
3
|
+
Quick sketch pad for [pi](https://github.com/badlogic/pi-mono) - draw in browser, send to Claude.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Install
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pi install npm:@ogulcancelik/pi-sketch
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Or try without installing:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pi -e npm:@ogulcancelik/pi-sketch
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Usage
|
|
20
|
+
|
|
21
|
+
```
|
|
22
|
+
/sketch
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Opens a canvas in your browser. Draw your sketch, press Enter to send.
|
|
26
|
+
|
|
27
|
+
## Features
|
|
28
|
+
|
|
29
|
+
- **Quick sketches** - draw arrows, boxes, diagrams
|
|
30
|
+
- **Clipboard paste** - `Ctrl+V` to paste screenshots, annotate on top
|
|
31
|
+
- **Colors** - black, red, green, blue, white (eraser)
|
|
32
|
+
- **Brush sizes** - 1, 2, 3 keys
|
|
33
|
+
- **Undo** - Z key
|
|
34
|
+
- **Cancel from pi** - Escape key in terminal
|
|
35
|
+
|
|
36
|
+
## Workflow
|
|
37
|
+
|
|
38
|
+
1. `/paint` - opens browser canvas (URL shown as fallback)
|
|
39
|
+
2. Draw your sketch
|
|
40
|
+
3. `Ctrl+V` to paste and annotate screenshots
|
|
41
|
+
4. Press Enter in browser to save
|
|
42
|
+
5. `Sketch: /path` appears in your editor - add context and send
|
|
43
|
+
|
|
44
|
+
## Keyboard Shortcuts (in browser)
|
|
45
|
+
|
|
46
|
+
| Key | Action |
|
|
47
|
+
|-----|--------|
|
|
48
|
+
| `Enter` | Send sketch |
|
|
49
|
+
| `Escape` | Cancel |
|
|
50
|
+
| `C` | Clear canvas |
|
|
51
|
+
| `Z` | Undo |
|
|
52
|
+
| `1-3` | Brush size |
|
|
53
|
+
| `Ctrl+V` | Paste image |
|
|
54
|
+
|
|
55
|
+
## License
|
|
56
|
+
|
|
57
|
+
MIT
|
package/assets/demo.gif
ADDED
|
Binary file
|
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sketch extension - quick sketch pad that opens in browser
|
|
3
|
+
* /sketch → opens browser canvas → draw → Enter sends to Claude
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
7
|
+
import { createServer, type Server } from "node:http";
|
|
8
|
+
import { exec } from "node:child_process";
|
|
9
|
+
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
10
|
+
import { join, dirname } from "node:path";
|
|
11
|
+
import { tmpdir } from "node:os";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
// Load HTML from file
|
|
15
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const SKETCH_HTML = readFileSync(join(__dirname, "sketch.html"), "utf-8");
|
|
17
|
+
|
|
18
|
+
function openBrowser(url: string): void {
|
|
19
|
+
const platform = process.platform;
|
|
20
|
+
let cmd: string;
|
|
21
|
+
|
|
22
|
+
if (platform === "darwin") {
|
|
23
|
+
cmd = `open "${url}"`;
|
|
24
|
+
} else if (platform === "win32") {
|
|
25
|
+
cmd = `start "" "${url}"`;
|
|
26
|
+
} else {
|
|
27
|
+
cmd = `xdg-open "${url}" 2>/dev/null || sensible-browser "${url}" 2>/dev/null || x-www-browser "${url}" 2>/dev/null`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
exec(cmd);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface PaintServer {
|
|
34
|
+
url: string;
|
|
35
|
+
waitForResult: () => Promise<string | null>;
|
|
36
|
+
close: () => void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function launchPaintServer(): PaintServer {
|
|
40
|
+
let resolved = false;
|
|
41
|
+
let resolvePromise: (value: string | null) => void;
|
|
42
|
+
|
|
43
|
+
const resultPromise = new Promise<string | null>((resolve) => {
|
|
44
|
+
resolvePromise = resolve;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
const server: Server = createServer((req, res) => {
|
|
48
|
+
// CORS headers for local dev
|
|
49
|
+
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
50
|
+
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
51
|
+
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
52
|
+
|
|
53
|
+
if (req.method === "OPTIONS") {
|
|
54
|
+
res.writeHead(204);
|
|
55
|
+
res.end();
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (req.method === "GET" && (req.url === "/" || req.url === "/sketch")) {
|
|
60
|
+
res.writeHead(200, { "Content-Type": "text/html" });
|
|
61
|
+
res.end(SKETCH_HTML);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (req.method === "POST" && req.url === "/submit") {
|
|
66
|
+
let body = "";
|
|
67
|
+
req.on("data", (chunk) => (body += chunk));
|
|
68
|
+
req.on("end", () => {
|
|
69
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
70
|
+
res.end("OK");
|
|
71
|
+
|
|
72
|
+
if (!resolved) {
|
|
73
|
+
resolved = true;
|
|
74
|
+
server.close();
|
|
75
|
+
resolvePromise(body); // base64 PNG data
|
|
76
|
+
}
|
|
77
|
+
});
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (req.method === "POST" && req.url === "/cancel") {
|
|
82
|
+
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
83
|
+
res.end("OK");
|
|
84
|
+
|
|
85
|
+
if (!resolved) {
|
|
86
|
+
resolved = true;
|
|
87
|
+
server.close();
|
|
88
|
+
resolvePromise(null);
|
|
89
|
+
}
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 404 for anything else
|
|
94
|
+
res.writeHead(404);
|
|
95
|
+
res.end("Not found");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
// Handle server errors
|
|
99
|
+
server.on("error", (err) => {
|
|
100
|
+
console.error("Paint server error:", err);
|
|
101
|
+
if (!resolved) {
|
|
102
|
+
resolved = true;
|
|
103
|
+
resolvePromise(null);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Get URL synchronously after listen
|
|
108
|
+
let url = "";
|
|
109
|
+
server.listen(0, "127.0.0.1", () => {
|
|
110
|
+
const addr = server.address();
|
|
111
|
+
if (addr && typeof addr === "object") {
|
|
112
|
+
url = `http://127.0.0.1:${addr.port}/sketch`;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// Timeout after 10 minutes
|
|
117
|
+
const timeout = setTimeout(() => {
|
|
118
|
+
if (!resolved) {
|
|
119
|
+
resolved = true;
|
|
120
|
+
server.close();
|
|
121
|
+
resolvePromise(null);
|
|
122
|
+
}
|
|
123
|
+
}, 10 * 60 * 1000);
|
|
124
|
+
|
|
125
|
+
return {
|
|
126
|
+
get url() {
|
|
127
|
+
return url;
|
|
128
|
+
},
|
|
129
|
+
waitForResult: () => resultPromise,
|
|
130
|
+
close: () => {
|
|
131
|
+
clearTimeout(timeout);
|
|
132
|
+
if (!resolved) {
|
|
133
|
+
resolved = true;
|
|
134
|
+
server.close();
|
|
135
|
+
resolvePromise(null);
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export default function (pi: ExtensionAPI) {
|
|
142
|
+
pi.registerCommand("sketch", {
|
|
143
|
+
description: "Open a sketch pad in browser to draw something for Claude",
|
|
144
|
+
|
|
145
|
+
handler: async (_args, ctx) => {
|
|
146
|
+
if (!ctx.hasUI) {
|
|
147
|
+
ctx.ui.notify("Sketch requires interactive mode", "error");
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const paintServer = launchPaintServer();
|
|
152
|
+
|
|
153
|
+
// Wait a tick for the server to get its port
|
|
154
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
155
|
+
|
|
156
|
+
// Auto-open browser
|
|
157
|
+
openBrowser(paintServer.url);
|
|
158
|
+
|
|
159
|
+
// Use custom UI to show status and handle Escape to cancel
|
|
160
|
+
const imageBase64 = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
161
|
+
// Race between browser result and user pressing Escape
|
|
162
|
+
paintServer.waitForResult().then(done);
|
|
163
|
+
|
|
164
|
+
// Simple component that shows status and handles Escape
|
|
165
|
+
return {
|
|
166
|
+
render(width: number): string[] {
|
|
167
|
+
const line1 = theme.fg("success", "Sketch opened in browser");
|
|
168
|
+
const line2 = theme.fg("muted", paintServer.url);
|
|
169
|
+
const line3 = theme.fg("dim", "Press Escape to cancel");
|
|
170
|
+
return [line1, line2, "", line3];
|
|
171
|
+
},
|
|
172
|
+
handleInput(data: string) {
|
|
173
|
+
// Check for Escape
|
|
174
|
+
if (data === "\x1b" || data === "\x1b\x1b") {
|
|
175
|
+
paintServer.close();
|
|
176
|
+
done(null);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
if (imageBase64) {
|
|
184
|
+
// Save to temp file (same flow as @ image attachments)
|
|
185
|
+
const sketchDir = join(tmpdir(), "pi-sketches");
|
|
186
|
+
mkdirSync(sketchDir, { recursive: true });
|
|
187
|
+
|
|
188
|
+
const timestamp = Date.now();
|
|
189
|
+
const sketchPath = join(sketchDir, `sketch-${timestamp}.png`);
|
|
190
|
+
|
|
191
|
+
// Decode base64 and write to file
|
|
192
|
+
const buffer = Buffer.from(imageBase64, "base64");
|
|
193
|
+
writeFileSync(sketchPath, buffer);
|
|
194
|
+
|
|
195
|
+
// Append to editor instead of auto-sending - user can add more text
|
|
196
|
+
const currentText = ctx.ui.getEditorText?.() || "";
|
|
197
|
+
const prefix = currentText ? currentText + "\n" : "";
|
|
198
|
+
ctx.ui.setEditorText(`${prefix}Sketch: ${sketchPath}`);
|
|
199
|
+
} else {
|
|
200
|
+
ctx.ui.notify("Sketch cancelled", "info");
|
|
201
|
+
}
|
|
202
|
+
} catch (error) {
|
|
203
|
+
paintServer.close();
|
|
204
|
+
ctx.ui.notify(`Sketch error: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="UTF-8">
|
|
5
|
+
<title>pi sketch</title>
|
|
6
|
+
<style>
|
|
7
|
+
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
8
|
+
body {
|
|
9
|
+
font-family: system-ui, -apple-system, sans-serif;
|
|
10
|
+
background: #1a1a1a;
|
|
11
|
+
height: 100vh;
|
|
12
|
+
display: flex;
|
|
13
|
+
flex-direction: column;
|
|
14
|
+
align-items: center;
|
|
15
|
+
justify-content: center;
|
|
16
|
+
gap: 16px;
|
|
17
|
+
padding: 20px;
|
|
18
|
+
}
|
|
19
|
+
.toolbar {
|
|
20
|
+
display: flex;
|
|
21
|
+
gap: 12px;
|
|
22
|
+
align-items: center;
|
|
23
|
+
color: #888;
|
|
24
|
+
font-size: 14px;
|
|
25
|
+
}
|
|
26
|
+
.toolbar button {
|
|
27
|
+
background: #333;
|
|
28
|
+
color: #fff;
|
|
29
|
+
border: none;
|
|
30
|
+
padding: 8px 16px;
|
|
31
|
+
border-radius: 6px;
|
|
32
|
+
cursor: pointer;
|
|
33
|
+
font-size: 14px;
|
|
34
|
+
}
|
|
35
|
+
.toolbar button:hover { background: #444; }
|
|
36
|
+
.toolbar button.primary { background: #2563eb; }
|
|
37
|
+
.toolbar button.primary:hover { background: #1d4ed8; }
|
|
38
|
+
.brush-sizes {
|
|
39
|
+
display: flex;
|
|
40
|
+
gap: 6px;
|
|
41
|
+
}
|
|
42
|
+
.brush-size {
|
|
43
|
+
width: 28px;
|
|
44
|
+
height: 28px;
|
|
45
|
+
border-radius: 50%;
|
|
46
|
+
background: #333;
|
|
47
|
+
display: flex;
|
|
48
|
+
align-items: center;
|
|
49
|
+
justify-content: center;
|
|
50
|
+
cursor: pointer;
|
|
51
|
+
border: 2px solid transparent;
|
|
52
|
+
}
|
|
53
|
+
.brush-size:hover { background: #444; }
|
|
54
|
+
.brush-size.active { border-color: #2563eb; }
|
|
55
|
+
.brush-size .dot {
|
|
56
|
+
background: #fff;
|
|
57
|
+
border-radius: 50%;
|
|
58
|
+
}
|
|
59
|
+
.colors {
|
|
60
|
+
display: flex;
|
|
61
|
+
gap: 4px;
|
|
62
|
+
}
|
|
63
|
+
.color-btn {
|
|
64
|
+
width: 24px;
|
|
65
|
+
height: 24px;
|
|
66
|
+
border-radius: 50%;
|
|
67
|
+
cursor: pointer;
|
|
68
|
+
border: 2px solid transparent;
|
|
69
|
+
}
|
|
70
|
+
.color-btn:hover { opacity: 0.8; }
|
|
71
|
+
.color-btn.active { border-color: #fff; }
|
|
72
|
+
.canvas-container {
|
|
73
|
+
position: relative;
|
|
74
|
+
display: inline-block;
|
|
75
|
+
}
|
|
76
|
+
canvas {
|
|
77
|
+
background: #fff;
|
|
78
|
+
border-radius: 8px;
|
|
79
|
+
cursor: crosshair;
|
|
80
|
+
box-shadow: 0 4px 24px rgba(0,0,0,0.3);
|
|
81
|
+
}
|
|
82
|
+
.resize-handle {
|
|
83
|
+
position: absolute;
|
|
84
|
+
bottom: 4px;
|
|
85
|
+
right: 4px;
|
|
86
|
+
width: 16px;
|
|
87
|
+
height: 16px;
|
|
88
|
+
cursor: nwse-resize;
|
|
89
|
+
background: linear-gradient(135deg, transparent 50%, #666 50%);
|
|
90
|
+
border-radius: 0 0 6px 0;
|
|
91
|
+
opacity: 0.7;
|
|
92
|
+
}
|
|
93
|
+
.resize-handle:hover {
|
|
94
|
+
opacity: 1;
|
|
95
|
+
}
|
|
96
|
+
.hint {
|
|
97
|
+
color: #666;
|
|
98
|
+
font-size: 13px;
|
|
99
|
+
}
|
|
100
|
+
kbd {
|
|
101
|
+
background: #333;
|
|
102
|
+
padding: 2px 6px;
|
|
103
|
+
border-radius: 4px;
|
|
104
|
+
font-family: inherit;
|
|
105
|
+
}
|
|
106
|
+
</style>
|
|
107
|
+
</head>
|
|
108
|
+
<body>
|
|
109
|
+
<div class="toolbar">
|
|
110
|
+
<div class="colors">
|
|
111
|
+
<div class="color-btn active" data-color="#000000" style="background: #000;" title="Black"></div>
|
|
112
|
+
<div class="color-btn" data-color="#ef4444" style="background: #ef4444;" title="Red"></div>
|
|
113
|
+
<div class="color-btn" data-color="#22c55e" style="background: #22c55e;" title="Green"></div>
|
|
114
|
+
<div class="color-btn" data-color="#3b82f6" style="background: #3b82f6;" title="Blue"></div>
|
|
115
|
+
<div class="color-btn" data-color="#ffffff" style="background: #fff;" title="White (eraser)"></div>
|
|
116
|
+
</div>
|
|
117
|
+
<div class="brush-sizes">
|
|
118
|
+
<div class="brush-size" data-size="2" title="Small">
|
|
119
|
+
<div class="dot" style="width: 4px; height: 4px;"></div>
|
|
120
|
+
</div>
|
|
121
|
+
<div class="brush-size active" data-size="4" title="Medium">
|
|
122
|
+
<div class="dot" style="width: 8px; height: 8px;"></div>
|
|
123
|
+
</div>
|
|
124
|
+
<div class="brush-size" data-size="8" title="Large">
|
|
125
|
+
<div class="dot" style="width: 14px; height: 14px;"></div>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
<button onclick="clearCanvas()">Clear</button>
|
|
129
|
+
<button onclick="undo()">Undo</button>
|
|
130
|
+
<button class="primary" onclick="send()">Send</button>
|
|
131
|
+
<button onclick="cancel()">Cancel</button>
|
|
132
|
+
</div>
|
|
133
|
+
|
|
134
|
+
<div class="canvas-container">
|
|
135
|
+
<canvas id="canvas" width="1000" height="600"></canvas>
|
|
136
|
+
<div class="resize-handle" id="resizeHandle"></div>
|
|
137
|
+
</div>
|
|
138
|
+
|
|
139
|
+
<div class="hint">
|
|
140
|
+
<kbd>Ctrl+V</kbd> paste image · <kbd>Enter</kbd> send · <kbd>Esc</kbd> cancel · <kbd>C</kbd> clear · <kbd>Z</kbd> undo · <kbd>1-3</kbd> brush size
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
<script>
|
|
144
|
+
const canvas = document.getElementById('canvas');
|
|
145
|
+
const ctx = canvas.getContext('2d');
|
|
146
|
+
|
|
147
|
+
let drawing = false;
|
|
148
|
+
let brushSize = 4;
|
|
149
|
+
let brushColor = '#000000';
|
|
150
|
+
let history = [];
|
|
151
|
+
let lastX, lastY;
|
|
152
|
+
|
|
153
|
+
// Save initial blank state
|
|
154
|
+
saveState();
|
|
155
|
+
|
|
156
|
+
function saveState() {
|
|
157
|
+
history.push(canvas.toDataURL());
|
|
158
|
+
if (history.length > 50) history.shift(); // Limit history
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function undo() {
|
|
162
|
+
if (history.length > 1) {
|
|
163
|
+
history.pop(); // Remove current state
|
|
164
|
+
const img = new Image();
|
|
165
|
+
img.onload = () => {
|
|
166
|
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
|
167
|
+
ctx.drawImage(img, 0, 0);
|
|
168
|
+
};
|
|
169
|
+
img.src = history[history.length - 1];
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const DEFAULT_WIDTH = 1000;
|
|
174
|
+
const DEFAULT_HEIGHT = 600;
|
|
175
|
+
|
|
176
|
+
function clearCanvas() {
|
|
177
|
+
canvas.width = DEFAULT_WIDTH;
|
|
178
|
+
canvas.height = DEFAULT_HEIGHT;
|
|
179
|
+
ctx.fillStyle = '#fff';
|
|
180
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
181
|
+
saveState();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function getPos(e) {
|
|
185
|
+
const rect = canvas.getBoundingClientRect();
|
|
186
|
+
const scaleX = canvas.width / rect.width;
|
|
187
|
+
const scaleY = canvas.height / rect.height;
|
|
188
|
+
return {
|
|
189
|
+
x: (e.clientX - rect.left) * scaleX,
|
|
190
|
+
y: (e.clientY - rect.top) * scaleY
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
canvas.addEventListener('mousedown', (e) => {
|
|
195
|
+
drawing = true;
|
|
196
|
+
const pos = getPos(e);
|
|
197
|
+
lastX = pos.x;
|
|
198
|
+
lastY = pos.y;
|
|
199
|
+
|
|
200
|
+
// Draw a dot for single clicks
|
|
201
|
+
ctx.beginPath();
|
|
202
|
+
ctx.arc(pos.x, pos.y, brushSize / 2, 0, Math.PI * 2);
|
|
203
|
+
ctx.fillStyle = brushColor;
|
|
204
|
+
ctx.fill();
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
canvas.addEventListener('mousemove', (e) => {
|
|
208
|
+
if (!drawing) return;
|
|
209
|
+
const pos = getPos(e);
|
|
210
|
+
|
|
211
|
+
ctx.beginPath();
|
|
212
|
+
ctx.moveTo(lastX, lastY);
|
|
213
|
+
ctx.lineTo(pos.x, pos.y);
|
|
214
|
+
ctx.strokeStyle = brushColor;
|
|
215
|
+
ctx.lineWidth = brushSize;
|
|
216
|
+
ctx.lineCap = 'round';
|
|
217
|
+
ctx.lineJoin = 'round';
|
|
218
|
+
ctx.stroke();
|
|
219
|
+
|
|
220
|
+
lastX = pos.x;
|
|
221
|
+
lastY = pos.y;
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
canvas.addEventListener('mouseup', () => {
|
|
225
|
+
if (drawing) {
|
|
226
|
+
drawing = false;
|
|
227
|
+
saveState();
|
|
228
|
+
}
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
canvas.addEventListener('mouseleave', () => {
|
|
232
|
+
if (drawing) {
|
|
233
|
+
drawing = false;
|
|
234
|
+
saveState();
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
// Brush size selection
|
|
239
|
+
document.querySelectorAll('.brush-size').forEach(el => {
|
|
240
|
+
el.addEventListener('click', () => {
|
|
241
|
+
document.querySelectorAll('.brush-size').forEach(b => b.classList.remove('active'));
|
|
242
|
+
el.classList.add('active');
|
|
243
|
+
brushSize = parseInt(el.dataset.size);
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// Color selection
|
|
248
|
+
document.querySelectorAll('.color-btn').forEach(el => {
|
|
249
|
+
el.addEventListener('click', () => {
|
|
250
|
+
document.querySelectorAll('.color-btn').forEach(b => b.classList.remove('active'));
|
|
251
|
+
el.classList.add('active');
|
|
252
|
+
brushColor = el.dataset.color;
|
|
253
|
+
});
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
// Keyboard shortcuts
|
|
257
|
+
document.addEventListener('keydown', (e) => {
|
|
258
|
+
if (e.key === 'Enter') {
|
|
259
|
+
e.preventDefault();
|
|
260
|
+
send();
|
|
261
|
+
} else if (e.key === 'Escape') {
|
|
262
|
+
cancel();
|
|
263
|
+
} else if (e.key.toLowerCase() === 'c' && !e.ctrlKey && !e.metaKey) {
|
|
264
|
+
clearCanvas();
|
|
265
|
+
} else if (e.key.toLowerCase() === 'z' && !e.ctrlKey && !e.metaKey) {
|
|
266
|
+
undo();
|
|
267
|
+
} else if (e.key === '1') {
|
|
268
|
+
document.querySelector('[data-size="2"]').click();
|
|
269
|
+
} else if (e.key === '2') {
|
|
270
|
+
document.querySelector('[data-size="4"]').click();
|
|
271
|
+
} else if (e.key === '3') {
|
|
272
|
+
document.querySelector('[data-size="8"]').click();
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
// Clipboard paste - paste image and draw on top
|
|
277
|
+
document.addEventListener('paste', async (e) => {
|
|
278
|
+
const items = e.clipboardData?.items;
|
|
279
|
+
if (!items) return;
|
|
280
|
+
|
|
281
|
+
for (const item of items) {
|
|
282
|
+
if (item.type.startsWith('image/')) {
|
|
283
|
+
e.preventDefault();
|
|
284
|
+
const blob = item.getAsFile();
|
|
285
|
+
if (!blob) continue;
|
|
286
|
+
|
|
287
|
+
const img = new Image();
|
|
288
|
+
img.onload = () => {
|
|
289
|
+
// Max canvas bounds - keep it reasonable so toolbar stays visible
|
|
290
|
+
const maxWidth = 1000;
|
|
291
|
+
const maxHeight = 600;
|
|
292
|
+
|
|
293
|
+
let newWidth = img.width;
|
|
294
|
+
let newHeight = img.height;
|
|
295
|
+
|
|
296
|
+
// Scale down preserving aspect ratio
|
|
297
|
+
const widthRatio = maxWidth / newWidth;
|
|
298
|
+
const heightRatio = maxHeight / newHeight;
|
|
299
|
+
const scale = Math.min(widthRatio, heightRatio, 1); // Don't upscale
|
|
300
|
+
|
|
301
|
+
newWidth = Math.round(newWidth * scale);
|
|
302
|
+
newHeight = Math.round(newHeight * scale);
|
|
303
|
+
|
|
304
|
+
// Resize canvas
|
|
305
|
+
canvas.width = newWidth;
|
|
306
|
+
canvas.height = newHeight;
|
|
307
|
+
|
|
308
|
+
// Draw pasted image
|
|
309
|
+
ctx.fillStyle = '#fff';
|
|
310
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
311
|
+
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
|
312
|
+
|
|
313
|
+
saveState();
|
|
314
|
+
URL.revokeObjectURL(img.src);
|
|
315
|
+
};
|
|
316
|
+
img.src = URL.createObjectURL(blob);
|
|
317
|
+
break;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// Track if we've already sent a result
|
|
323
|
+
let submitted = false;
|
|
324
|
+
|
|
325
|
+
async function send() {
|
|
326
|
+
if (submitted) return;
|
|
327
|
+
submitted = true;
|
|
328
|
+
|
|
329
|
+
// Get PNG data (strip the data:image/png;base64, prefix)
|
|
330
|
+
const dataUrl = canvas.toDataURL('image/png');
|
|
331
|
+
const base64 = dataUrl.split(',')[1];
|
|
332
|
+
|
|
333
|
+
await fetch('/submit', {
|
|
334
|
+
method: 'POST',
|
|
335
|
+
headers: { 'Content-Type': 'text/plain' },
|
|
336
|
+
body: base64
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
window.close();
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
async function cancel() {
|
|
343
|
+
if (submitted) return;
|
|
344
|
+
submitted = true;
|
|
345
|
+
|
|
346
|
+
await fetch('/cancel', { method: 'POST' });
|
|
347
|
+
window.close();
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Resize handle functionality
|
|
351
|
+
const resizeHandle = document.getElementById('resizeHandle');
|
|
352
|
+
let resizing = false;
|
|
353
|
+
let resizeStartX, resizeStartY, startWidth, startHeight;
|
|
354
|
+
|
|
355
|
+
resizeHandle.addEventListener('mousedown', (e) => {
|
|
356
|
+
e.preventDefault();
|
|
357
|
+
resizing = true;
|
|
358
|
+
resizeStartX = e.clientX;
|
|
359
|
+
resizeStartY = e.clientY;
|
|
360
|
+
startWidth = canvas.width;
|
|
361
|
+
startHeight = canvas.height;
|
|
362
|
+
|
|
363
|
+
// Save current canvas content
|
|
364
|
+
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
|
365
|
+
|
|
366
|
+
const onMouseMove = (e) => {
|
|
367
|
+
if (!resizing) return;
|
|
368
|
+
|
|
369
|
+
const dx = e.clientX - resizeStartX;
|
|
370
|
+
const dy = e.clientY - resizeStartY;
|
|
371
|
+
|
|
372
|
+
const newWidth = Math.max(200, startWidth + dx);
|
|
373
|
+
const newHeight = Math.max(150, startHeight + dy);
|
|
374
|
+
|
|
375
|
+
// Resize canvas
|
|
376
|
+
canvas.width = newWidth;
|
|
377
|
+
canvas.height = newHeight;
|
|
378
|
+
|
|
379
|
+
// Fill with white first
|
|
380
|
+
ctx.fillStyle = '#fff';
|
|
381
|
+
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
|
382
|
+
|
|
383
|
+
// Restore previous content
|
|
384
|
+
ctx.putImageData(imageData, 0, 0);
|
|
385
|
+
};
|
|
386
|
+
|
|
387
|
+
const onMouseUp = () => {
|
|
388
|
+
if (resizing) {
|
|
389
|
+
resizing = false;
|
|
390
|
+
saveState();
|
|
391
|
+
}
|
|
392
|
+
document.removeEventListener('mousemove', onMouseMove);
|
|
393
|
+
document.removeEventListener('mouseup', onMouseUp);
|
|
394
|
+
};
|
|
395
|
+
|
|
396
|
+
document.addEventListener('mousemove', onMouseMove);
|
|
397
|
+
document.addEventListener('mouseup', onMouseUp);
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
// Initialize with white background
|
|
401
|
+
clearCanvas();
|
|
402
|
+
history = []; // Reset history after initial clear
|
|
403
|
+
saveState();
|
|
404
|
+
|
|
405
|
+
// Send cancel if tab is closed without explicit action
|
|
406
|
+
window.addEventListener('beforeunload', () => {
|
|
407
|
+
navigator.sendBeacon('/cancel');
|
|
408
|
+
});
|
|
409
|
+
</script>
|
|
410
|
+
</body>
|
|
411
|
+
</html>
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@ogulcancelik/pi-sketch",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Quick sketch pad for pi - draw in browser, send to models",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"pi-package",
|
|
7
|
+
"paint",
|
|
8
|
+
"sketch",
|
|
9
|
+
"drawing",
|
|
10
|
+
"visual"
|
|
11
|
+
],
|
|
12
|
+
"author": "Can Celik",
|
|
13
|
+
"license": "MIT",
|
|
14
|
+
"repository": {
|
|
15
|
+
"type": "git",
|
|
16
|
+
"url": "https://github.com/ogulcancelik/pi-sketch.git"
|
|
17
|
+
},
|
|
18
|
+
"homepage": "https://github.com/ogulcancelik/pi-sketch",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/ogulcancelik/pi-sketch/issues"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"extensions",
|
|
24
|
+
"assets",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"peerDependencies": {
|
|
29
|
+
"@mariozechner/pi-coding-agent": "*"
|
|
30
|
+
},
|
|
31
|
+
"pi": {
|
|
32
|
+
"extensions": [
|
|
33
|
+
"./extensions"
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
}
|