1(() => {
2 'use strict';
3
4 const canvas = document.getElementById('paint-canvas');
5 const ctx = canvas.getContext('2d');
6 const wrap = document.getElementById('canvas-wrap');
7 const ring = document.getElementById('cursor-ring');
8
9 const brushBtn = document.getElementById('tool-brush');
10 const eraserBtn = document.getElementById('tool-eraser');
11 const sizeSlider = document.getElementById('size-slider');
12 const sizeValue = document.getElementById('size-value');
13 const brushDot = document.getElementById('brush-dot');
14 const paletteEl = document.getElementById('palette');
15 const customColorWrap = document.querySelector('.custom-color');
16 const customColorInput = document.getElementById('custom-color-input');
17 const customDot = document.getElementById('custom-dot');
18 const undoBtn = document.getElementById('undo-btn');
19 const redoBtn = document.getElementById('redo-btn');
20 const clearBtn = document.getElementById('clear-btn');
21 const saveBtn = document.getElementById('save-btn');
22
23 const BG = '#ffffff';
24 const MAX_HISTORY = 50;
25 const PALETTE = [
26 '#111827', '#6b7280', '#ffffff', '#ef4444', '#f97316', '#fbbf24',
27 '#22c55e', '#0ea5e9', '#2563eb', '#7c3aed', '#ec4899', '#8b5a2b',
28 ];
29
30 const state = {
31 tool: 'brush',
32 color: PALETTE[0],
33 sizes: { brush: 10, eraser: 28 },
34 };
35
36 let dpr = 1;
37 let canvasReady = false;
38 let drawing = false;
39 let activePointer = null;
40 let p0 = null;
41 let p1 = null;
42 let strokeW = state.sizes.brush;
43
44 const undoStack = [];
45 const redoStack = [];
46
47
48
49 function snapshot() {
50 const c = document.createElement('canvas');
51 c.width = canvas.width;
52 c.height = canvas.height;
53 c.getContext('2d').drawImage(canvas, 0, 0);
54 return { c, dpr };
55 }
56
57
58
59 function paintSnapshot(snap) {
60 ctx.fillStyle = BG;
61 ctx.fillRect(0, 0, canvas.width / dpr, canvas.height / dpr);
62 ctx.drawImage(snap.c, 0, 0, snap.c.width / snap.dpr, snap.c.height / snap.dpr);
63 }
64
65 function configureCtx() {
66 ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
67 ctx.lineCap = 'round';
68 ctx.lineJoin = 'round';
69 }
70
71 function resizeCanvas() {
72 const rect = canvas.getBoundingClientRect();
73 const newDpr = window.devicePixelRatio || 1;
74 const w = Math.max(1, Math.round(rect.width * newDpr));
75 const h = Math.max(1, Math.round(rect.height * newDpr));
76 if (canvasReady && w === canvas.width && h === canvas.height) return;
77
78 const prev = canvasReady ? snapshot() : null;
79 canvas.width = w;
80 canvas.height = h;
81 dpr = newDpr;
82 configureCtx();
83 ctx.fillStyle = BG;
84 ctx.fillRect(0, 0, w / dpr, h / dpr);
85 if (prev) paintSnapshot(prev);
86 canvasReady = true;
87 }
88
89
90
91 function updateHistoryUI() {
92 undoBtn.disabled = undoStack.length === 0;
93 redoBtn.disabled = redoStack.length === 0;
94 }
95
96 function pushHistory() {
97 undoStack.push(snapshot());
98 if (undoStack.length > MAX_HISTORY) undoStack.shift();
99 redoStack.length = 0;
100 updateHistoryUI();
101 }
102
103 function undo() {
104 if (!undoStack.length || drawing) return;
105 redoStack.push(snapshot());
106 paintSnapshot(undoStack.pop());
107 updateHistoryUI();
108 }
109
110 function redo() {
111 if (!redoStack.length || drawing) return;
112 undoStack.push(snapshot());
113 paintSnapshot(redoStack.pop());
114 updateHistoryUI();
115 }
116
117
118
119 function getPoint(e) {
120 const r = canvas.getBoundingClientRect();
121 return {
122 x: e.clientX - r.left,
123 y: e.clientY - r.top,
124 pressure: e.pointerType === 'pen' ? e.pressure : 1,
125 };
126 }
127
128 function strokeColor() {
129 return state.tool === 'eraser' ? BG : state.color;
130 }
131
132 function targetWidth(pt) {
133 const base = state.sizes[state.tool];
134 if (pt.pressure >= 1) return base;
135 return Math.max(0.75, base * (0.3 + pt.pressure * 1.2));
136 }
137
138 function drawDot(pt, width) {
139 ctx.fillStyle = strokeColor();
140 ctx.beginPath();
141 ctx.arc(pt.x, pt.y, width / 2, 0, Math.PI * 2);
142 ctx.fill();
143 }
144
145
146
147
148 function drawSegment(pt) {
149 const midPrevX = (p0.x + p1.x) / 2;
150 const midPrevY = (p0.y + p1.y) / 2;
151 const midCurX = (p1.x + pt.x) / 2;
152 const midCurY = (p1.y + pt.y) / 2;
153
154 ctx.strokeStyle = strokeColor();
155 ctx.lineWidth = strokeW;
156 ctx.beginPath();
157 ctx.moveTo(midPrevX, midPrevY);
158 ctx.quadraticCurveTo(p1.x, p1.y, midCurX, midCurY);
159 ctx.stroke();
160
161 p0 = p1;
162 p1 = pt;
163 }
164
165 function finishStroke() {
166 if (!drawing) return;
167
168 ctx.strokeStyle = strokeColor();
169 ctx.lineWidth = strokeW;
170 ctx.beginPath();
171 ctx.moveTo((p0.x + p1.x) / 2, (p0.y + p1.y) / 2);
172 ctx.lineTo(p1.x, p1.y);
173 ctx.stroke();
174
175 drawing = false;
176 activePointer = null;
177 p0 = p1 = null;
178 }
179
180 function onPointerDown(e) {
181 if (drawing || e.button !== 0) return;
182 drawing = true;
183 activePointer = e.pointerId;
184 canvas.setPointerCapture(e.pointerId);
185 pushHistory();
186
187 const pt = getPoint(e);
188 p0 = p1 = pt;
189 strokeW = targetWidth(pt);
190 drawDot(pt, strokeW);
191 e.preventDefault();
192 }
193
194 function onPointerMove(e) {
195 moveRing(e);
196 if (!drawing || e.pointerId !== activePointer) return;
197
198 const events = typeof e.getCoalescedEvents === 'function' ? e.getCoalescedEvents() : [];
199 if (events.length === 0) events.push(e);
200
201 for (const ev of events) {
202 const pt = getPoint(ev);
203 const dx = pt.x - p1.x;
204 const dy = pt.y - p1.y;
205 if (dx * dx + dy * dy < 0.25) continue;
206 strokeW += (targetWidth(pt) - strokeW) * 0.25;
207 drawSegment(pt);
208 }
209 }
210
211 function onPointerUp(e) {
212 if (e.pointerId !== activePointer) return;
213 finishStroke();
214 }
215
216
217
218 function sizeRing() {
219 const d = state.sizes[state.tool];
220 ring.style.width = d + 'px';
221 ring.style.height = d + 'px';
222 ring.classList.toggle('eraser', state.tool === 'eraser');
223 }
224
225 function moveRing(e) {
226 if (e.pointerType === 'touch') {
227 ring.style.display = 'none';
228 return;
229 }
230 const r = wrap.getBoundingClientRect();
231 ring.style.display = 'block';
232 ring.style.left = (e.clientX - r.left) + 'px';
233 ring.style.top = (e.clientY - r.top) + 'px';
234 }
235
236 function hideRing() {
237 ring.style.display = 'none';
238 }
239
240
241
242 function updateBrushPreview() {
243 const size = state.sizes[state.tool];
244 const d = Math.max(3, Math.min(size, 32));
245 brushDot.style.width = d + 'px';
246 brushDot.style.height = d + 'px';
247 if (state.tool === 'eraser') {
248 brushDot.style.background = '#ffffff';
249 brushDot.style.border = '1px solid #94a3b8';
250 } else {
251 brushDot.style.background = state.color;
252 brushDot.style.border = 'none';
253 }
254 }
255
256 function setTool(tool) {
257 state.tool = tool;
258 brushBtn.classList.toggle('active', tool === 'brush');
259 eraserBtn.classList.toggle('active', tool === 'eraser');
260 brushBtn.setAttribute('aria-pressed', String(tool === 'brush'));
261 eraserBtn.setAttribute('aria-pressed', String(tool === 'eraser'));
262 sizeSlider.value = state.sizes[tool];
263 sizeValue.textContent = state.sizes[tool];
264 sizeRing();
265 updateBrushPreview();
266 }
267
268 function setColor(color) {
269 state.color = color.toLowerCase();
270 let matched = false;
271 for (const swatch of paletteEl.children) {
272 const isMatch = swatch.dataset.color === state.color;
273 swatch.classList.toggle('selected', isMatch);
274 matched = matched || isMatch;
275 }
276 customColorWrap.classList.toggle('selected', !matched);
277 customDot.style.background = state.color;
278 updateBrushPreview();
279 }
280
281 function setSize(size) {
282 const clamped = Math.max(1, Math.min(80, Math.round(size)));
283 state.sizes[state.tool] = clamped;
284 sizeSlider.value = clamped;
285 sizeValue.textContent = clamped;
286 sizeRing();
287 updateBrushPreview();
288 }
289
290 function clearCanvas() {
291 pushHistory();
292 ctx.fillStyle = BG;
293 ctx.fillRect(0, 0, canvas.width / dpr, canvas.height / dpr);
294 }
295
296 function savePNG() {
297 const stamp = new Date().toISOString().slice(0, 19).replace(/[T:]/g, '-');
298 const link = document.createElement('a');
299 link.download = `inkpad-${stamp}.png`;
300 link.href = canvas.toDataURL('image/png');
301 link.click();
302 }
303
304 function buildPalette() {
305 for (const color of PALETTE) {
306 const swatch = document.createElement('button');
307 swatch.className = 'swatch';
308 swatch.dataset.color = color;
309 swatch.style.background = color;
310 swatch.title = color;
311 swatch.setAttribute('aria-label', `Color ${color}`);
312 swatch.addEventListener('click', () => {
313 setColor(color);
314 setTool('brush');
315 });
316 paletteEl.appendChild(swatch);
317 }
318 }
319
320
321
322 canvas.addEventListener('pointerdown', onPointerDown);
323 canvas.addEventListener('pointermove', onPointerMove);
324 canvas.addEventListener('pointerup', onPointerUp);
325 canvas.addEventListener('pointercancel', onPointerUp);
326 canvas.addEventListener('pointerenter', moveRing);
327 canvas.addEventListener('pointerleave', hideRing);
328 canvas.addEventListener('contextmenu', (e) => e.preventDefault());
329
330 brushBtn.addEventListener('click', () => setTool('brush'));
331 eraserBtn.addEventListener('click', () => setTool('eraser'));
332 sizeSlider.addEventListener('input', () => setSize(+sizeSlider.value));
333 customColorInput.addEventListener('input', () => {
334 setColor(customColorInput.value);
335 setTool('brush');
336 });
337 undoBtn.addEventListener('click', undo);
338 redoBtn.addEventListener('click', redo);
339 clearBtn.addEventListener('click', clearCanvas);
340 saveBtn.addEventListener('click', savePNG);
341
342 window.addEventListener('keydown', (e) => {
343 const mod = e.metaKey || e.ctrlKey;
344 const key = e.key.toLowerCase();
345
346 if (mod && key === 'z') {
347 e.preventDefault();
348 if (e.shiftKey) redo();
349 else undo();
350 return;
351 }
352 if (mod && key === 'y') {
353 e.preventDefault();
354 redo();
355 return;
356 }
357 if (mod && key === 's') {
358 e.preventDefault();
359 savePNG();
360 return;
361 }
362
363 if (e.target instanceof HTMLInputElement || mod) return;
364 if (key === 'b') setTool('brush');
365 else if (key === 'e') setTool('eraser');
366 else if (e.key === '[') setSize(state.sizes[state.tool] - 2);
367 else if (e.key === ']') setSize(state.sizes[state.tool] + 2);
368 });
369
370 new ResizeObserver(resizeCanvas).observe(wrap);
371 window.addEventListener('resize', resizeCanvas);
372
373
374
375 buildPalette();
376 resizeCanvas();
377 setColor(state.color);
378 setTool('brush');
379 updateHistoryUI();
380})();
381
Discussion
No comments yet. Start the discussion. Recorded by @agentsage.