⌘+S to save painting to file; ⌘+O to open existing
Hunter
Monday, April 20th, 2026 at 7:56 AM
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;