⌘+S to save painting to file; ⌘+O to open existing

Hunter
Monday, April 20th, 2026 at 7:56 AM

index.html+94 

index.html
447  		}
448  	}
449  
450 +	const fileInput = document.createElement('input');
451 +	fileInput.type = 'file';
452 +	fileInput.accept = 'image/*';
453 +	fileInput.style.display = 'none';
454 +	document.body.appendChild(fileInput);
455 +	fileInput.addEventListener('change', (e) => {
456 +		const f = e.target.files && e.target.files[0];
457 +		if (f) importImage(f);
458 +		fileInput.value = '';
459 +	});
460 +
461 +	function formatTimestamp(d) {
462 +		const p = (n) => String(n).padStart(2, '0');
463 +		return p(d.getFullYear() % 100) + '\u00b7' + p(d.getMonth() + 1) + '\u00b7' + p(d.getDate()) + '\u00b7' + p(d.getHours()) + '\u00b7' + p(d.getMinutes()) + '\u00b7' + p(d.getSeconds());
464 +	}
465 +
466 +	function exportPNG() {
467 +		if (chunks.size === 0) return;
468 +		let minCx = Infinity, minCy = Infinity, maxCx = -Infinity, maxCy = -Infinity;
469 +		for (const k of chunks.keys()) {
470 +			const [cx, cy] = k.split(',').map(Number);
471 +			if (cx < minCx) minCx = cx;
472 +			if (cy < minCy) minCy = cy;
473 +			if (cx > maxCx) maxCx = cx;
474 +			if (cy > maxCy) maxCy = cy;
475 +		}
476 +		const w = (maxCx - minCx + 1) * CHUNK;
477 +		const h = (maxCy - minCy + 1) * CHUNK;
478 +		const out = document.createElement('canvas');
479 +		out.width = w;
480 +		out.height = h;
481 +		const octx = out.getContext('2d');
482 +		octx.fillStyle = '#000';
483 +		octx.fillRect(0, 0, w, h);
484 +		for (const [k, c] of chunks) {
485 +			const [cx, cy] = k.split(',').map(Number);
486 +			octx.drawImage(c.canvas, (cx - minCx) * CHUNK, (cy - minCy) * CHUNK);
487 +		}
488 +		out.toBlob((blob) => {
489 +			const url = URL.createObjectURL(blob);
490 +			const a = document.createElement('a');
491 +			a.href = url;
492 +			a.download = 'paint \u00b7 ' + formatTimestamp(new Date()) + '.png';
493 +			document.body.appendChild(a);
494 +			a.click();
495 +			document.body.removeChild(a);
496 +			URL.revokeObjectURL(url);
497 +		}, 'image/png');
498 +		dirty = false;
499 +	}
500 +
501 +	function importImage(file) {
502 +		if (dirty && !confirm('Importing will discard the current painting. Continue?')) return;
503 +		const img = new Image();
504 +		img.onload = () => {
505 +			chunks.clear();
506 +			const w = img.width, h = img.height;
507 +			const tmp = document.createElement('canvas');
508 +			tmp.width = w;
509 +			tmp.height = h;
510 +			const tctx = tmp.getContext('2d');
511 +			tctx.drawImage(img, 0, 0);
512 +			const ox = -Math.floor(w / 2);
513 +			const oy = -Math.floor(h / 2);
514 +			const cx0 = Math.floor(ox / CHUNK);
515 +			const cy0 = Math.floor(oy / CHUNK);
516 +			const cx1 = Math.floor((ox + w - 1) / CHUNK);
517 +			const cy1 = Math.floor((oy + h - 1) / CHUNK);
518 +			for (let cy = cy0; cy <= cy1; cy++) {
519 +				for (let cx = cx0; cx <= cx1; cx++) {
520 +					const c = getOrCreateChunk(cx, cy);
521 +					c.ctx.drawImage(tmp, ox - cx * CHUNK, oy - cy * CHUNK);
522 +				}
523 +			}
524 +			camX = ox - (cssW / zoom - w) / 2;
525 +			camY = oy - (cssH / zoom - h) / 2;
526 +			dirty = false;
527 +			URL.revokeObjectURL(img.src);
528 +			requestDraw();
529 +		};
530 +		img.onerror = () => URL.revokeObjectURL(img.src);
531 +		img.src = URL.createObjectURL(file);
532 +	}
533 +
534  	window.addEventListener('resize', resize);
535  
536  	window.addEventListener('mouseover', (e) => {

660  	}
661  
662  	window.addEventListener('keydown', (e) => {
663 +		if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 's') {
664 +			e.preventDefault();
665 +			exportPNG();
666 +			return;
667 +		}
668 +		if ((e.metaKey || e.ctrlKey) && !e.shiftKey && !e.altKey && e.key.toLowerCase() === 'o') {
669 +			e.preventDefault();
670 +			fileInput.click();
671 +			return;
672 +		}
673  		const k = e.key.toLowerCase();
674  		const wasDown = keys[k];
675  		keys[k] = true;