Created
August 25, 2026 18:34
-
-
Save monperrus/401107b033641c7209cd6a1a4a643054 to your computer and use it in GitHub Desktop.
Martin's Super Webapp tab: Paint
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| { | |
| "1": { | |
| "tab": { | |
| "id": "paint", | |
| "label": "Paint", | |
| "html": "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"UTF-8\">\n<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no\">\n<title>Paint</title>\n<style>\n html, body {\n margin: 0; height: 100%;\n background: #0d1117; color: #c9d1d9;\n font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;\n overflow: hidden;\n -webkit-user-select: none; user-select: none;\n }\n #app { display: flex; flex-direction: column; height: 100%; }\n\n /* Top bar */\n header {\n display: flex; align-items: center; gap: 6px;\n padding: 8px 10px;\n background: #161b22; border-bottom: 1px solid #30363d;\n flex-shrink: 0;\n }\n header h1 {\n font-size: 16px; margin: 0 8px 0 0; font-weight: 600; color: #58a6ff;\n flex-shrink: 0;\n }\n .hbtn {\n min-width: 44px; height: 44px;\n display: flex; align-items: center; justify-content: center;\n background: #21262d; border: 1px solid #30363d; border-radius: 10px;\n color: #c9d1d9; font-size: 18px; cursor: pointer;\n flex: 1; max-width: 64px;\n }\n .hbtn:active { background: #30363d; }\n .hbtn:disabled { opacity: .35; }\n\n /* Canvas area */\n #canvasWrap {\n flex: 1; position: relative; min-height: 0;\n background:\n linear-gradient(45deg, #10151c 25%, transparent 25%, transparent 75%, #10151c 75%),\n linear-gradient(45deg, #10151c 25%, transparent 25%, transparent 75%, #10151c 75%);\n background-size: 24px 24px; background-position: 0 0, 12px 12px;\n background-color: #0d1117;\n }\n #paintCanvas {\n position: absolute; inset: 0; width: 100%; height: 100%;\n touch-action: none; cursor: crosshair;\n }\n\n /* Bottom toolbar */\n footer {\n background: #161b22; border-top: 1px solid #30363d;\n padding: 8px 10px 12px; flex-shrink: 0;\n display: flex; flex-direction: column; gap: 8px;\n }\n .row { display: flex; align-items: center; gap: 8px; }\n\n .tool {\n flex: 1; height: 44px;\n display: flex; align-items: center; justify-content: center; gap: 6px;\n background: #21262d; border: 1px solid #30363d; border-radius: 10px;\n color: #8b949e; font-size: 14px; cursor: pointer;\n }\n .tool.active { background: #1f3a5f; border-color: #58a6ff; color: #58a6ff; }\n\n #sizeRange { flex: 1; height: 44px; accent-color: #58a6ff; }\n #sizeLabel {\n width: 52px; height: 44px; flex-shrink: 0;\n display: flex; align-items: center; justify-content: center;\n background: #21262d; border: 1px solid #30363d; border-radius: 10px;\n font-size: 14px; color: #c9d1d9;\n }\n\n #swatches {\n display: flex; gap: 8px; overflow-x: auto; padding: 2px;\n scrollbar-width: none;\n }\n #swatches::-webkit-scrollbar { display: none; }\n .swatch {\n width: 44px; height: 44px; flex-shrink: 0;\n border-radius: 50%; border: 2px solid #30363d; cursor: pointer;\n position: relative;\n }\n .swatch.active { border-color: #58a6ff; box-shadow: 0 0 0 2px #0d1117, 0 0 0 4px #58a6ff; }\n #customColor {\n width: 44px; height: 44px; flex-shrink: 0; padding: 0;\n border: 2px dashed #8b949e; border-radius: 50%;\n background: transparent; cursor: pointer;\n }\n #status {\n font-size: 12px; color: #8b949e; text-align: center; margin: 0;\n }\n #toast {\n position: fixed; left: 50%; bottom: 140px; transform: translateX(-50%);\n background: #21262d; border: 1px solid #58a6ff; color: #c9d1d9;\n padding: 8px 16px; border-radius: 20px; font-size: 14px;\n opacity: 0; pointer-events: none; transition: opacity .25s;\n }\n #toast.show { opacity: 1; }\n</style>\n</head>\n<body>\n<div id=\"app\">\n <header>\n <h1>🎨 Paint</h1>\n <button class=\"hbtn\" id=\"undoBtn\" title=\"Undo\">↩️</button>\n <button class=\"hbtn\" id=\"redoBtn\" title=\"Redo\">↪️</button>\n <button class=\"hbtn\" id=\"saveBtn\" title=\"Download PNG\">💾</button>\n <button class=\"hbtn\" id=\"clearBtn\" title=\"Clear\">🗑️</button>\n </header>\n\n <div id=\"canvasWrap\">\n <canvas id=\"paintCanvas\"></canvas>\n </div>\n\n <footer>\n <div class=\"row\">\n <button class=\"tool active\" id=\"toolBrush\">✏️ Brush</button>\n <button class=\"tool\" id=\"toolEraser\">🧽 Eraser</button>\n <button class=\"tool\" id=\"toolFill\">🪣 Fill</button>\n </div>\n <div class=\"row\">\n <input type=\"range\" id=\"sizeRange\" min=\"1\" max=\"60\" value=\"6\">\n <div id=\"sizeLabel\">6px</div>\n </div>\n <div id=\"swatches\"></div>\n <p id=\"status\">Brush · 6px</p>\n </footer>\n</div>\n<div id=\"toast\"></div>\n\n<script>\n(function () {\n const LS_KEY = 'msw_tab_paint_drawing';\n const canvas = document.getElementById('paintCanvas');\n const ctx = canvas.getContext('2d', { willReadFrequently: true });\n const wrap = document.getElementById('canvasWrap');\n const sizeRange = document.getElementById('sizeRange');\n const sizeLabel = document.getElementById('sizeLabel');\n const statusEl = document.getElementById('status');\n const undoBtn = document.getElementById('undoBtn');\n const redoBtn = document.getElementById('redoBtn');\n const toastEl = document.getElementById('toast');\n\n // 🩸 Blood red (#8b0000) + light red (#ff3b3b) added!\n const COLORS = ['#ffffff','#000000','#8b0000','#ff3b3b','#ff7b72','#ffa657','#f2cc60',\n '#7ee787','#56d4dd','#58a6ff','#bc8cff','#ff7bfa','#d2a8ff','#8b949e'];\n let currentColor = '#58a6ff';\n let tool = 'brush'; // brush | eraser | fill\n let brushSize = 6;\n let drawing = false;\n let lastX = 0, lastY = 0, midX = 0, midY = 0;\n let undoStack = [], redoStack = [];\n const MAX_UNDO = 20;\n\n /* ---------- swatches ---------- */\n const swatchWrap = document.getElementById('swatches');\n COLORS.forEach(c => {\n const b = document.createElement('button');\n b.className = 'swatch';\n b.style.background = c;\n b.setAttribute('aria-label', c);\n b.addEventListener('click', () => {\n currentColor = c;\n if (tool === 'eraser') setTool('brush');\n document.querySelectorAll('.swatch').forEach(s => s.classList.remove('active'));\n b.classList.add('active');\n updateStatus();\n });\n swatchWrap.appendChild(b);\n });\n const custom = document.createElement('input');\n custom.type = 'color'; custom.id = 'customColor'; custom.value = '#58a6ff';\n custom.title = 'Custom color';\n custom.addEventListener('input', () => {\n currentColor = custom.value;\n if (tool === 'eraser') setTool('brush');\n document.querySelectorAll('.swatch').forEach(s => s.classList.remove('active'));\n updateStatus();\n });\n swatchWrap.appendChild(custom);\n // default selection\n swatchWrap.children[COLORS.indexOf('#58a6ff')].classList.add('active');\n\n /* ---------- canvas sizing ---------- */\n function resizeCanvas(preserve) {\n const dpr = window.devicePixelRatio || 1;\n const w = wrap.clientWidth, h = wrap.clientHeight;\n if (w === 0 || h === 0) return;\n let snapshot = null;\n if (preserve && canvas.width > 0) {\n snapshot = document.createElement('canvas');\n snapshot.width = canvas.width; snapshot.height = canvas.height;\n snapshot.getContext('2d').drawImage(canvas, 0, 0);\n }\n canvas.width = Math.round(w * dpr);\n canvas.height = Math.round(h * dpr);\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n ctx.lineCap = 'round';\n ctx.lineJoin = 'round';\n if (snapshot) ctx.drawImage(snapshot, 0, 0, w, h);\n }\n resizeCanvas(false);\n let resizeTimer;\n window.addEventListener('resize', () => {\n clearTimeout(resizeTimer);\n resizeTimer = setTimeout(() => resizeCanvas(true), 150);\n });\n\n /* ---------- persistence ---------- */\n function saveDrawing() {\n try {\n const tmp = document.createElement('canvas');\n tmp.width = canvas.width; tmp.height = canvas.height;\n const t = tmp.getContext('2d');\n t.fillStyle = '#0d1117';\n t.fillRect(0, 0, tmp.width, tmp.height);\n t.drawImage(canvas, 0, 0);\n localStorage.setItem(LS_KEY, tmp.toDataURL('image/jpeg', 0.8));\n } catch (e) { /* quota — ignore */ }\n }\n function loadDrawing() {\n const data = localStorage.getItem(LS_KEY);\n if (!data) return;\n const img = new Image();\n img.onload = () => ctx.drawImage(img, 0, 0, wrap.clientWidth, wrap.clientHeight);\n img.src = data;\n }\n let saveTimer;\n function queueSave() {\n clearTimeout(saveTimer);\n saveTimer = setTimeout(saveDrawing, 600);\n }\n\n /* ---------- undo / redo ---------- */\n function snapshot() {\n undoStack.push(canvas.toDataURL());\n if (undoStack.length > MAX_UNDO) undoStack.shift();\n redoStack = [];\n updateHistoryButtons();\n }\n function restore(dataUrl, nextStack) {\n if (nextStack) nextStack.push(canvas.toDataURL());\n const img = new Image();\n img.onload = () => {\n ctx.save();\n ctx.setTransform(1, 0, 0, 1, 0, 0);\n ctx.clearRect(0, 0, canvas.width, canvas.height);\n ctx.drawImage(img, 0, 0, canvas.width, canvas.height);\n ctx.restore();\n queueSave();\n };\n img.src = dataUrl;\n }\n function updateHistoryButtons() {\n undoBtn.disabled = undoStack.length === 0;\n redoBtn.disabled = redoStack.length === 0;\n }\n undoBtn.addEventListener('click', () => {\n if (!undoStack.length) return;\n restore(undoStack.pop(), redoStack);\n updateHistoryButtons();\n });\n redoBtn.addEventListener('click', () => {\n if (!redoStack.length) return;\n restore(redoStack.pop(), undoStack);\n updateHistoryButtons();\n });\n updateHistoryButtons();\n\n /* ---------- tools & size ---------- */\n const toolButtons = {\n brush: document.getElementById('toolBrush'),\n eraser: document.getElementById('toolEraser'),\n fill: document.getElementById('toolFill')\n };\n function setTool(t) {\n tool = t;\n Object.entries(toolButtons).forEach(([k, b]) =>\n b.classList.toggle('active', k === t));\n updateStatus();\n }\n Object.entries(toolButtons).forEach(([k, b]) =>\n b.addEventListener('click', () => setTool(k)));\n\n sizeRange.addEventListener('input', () => {\n brushSize = parseInt(sizeRange.value, 10);\n sizeLabel.textContent = brushSize + 'px';\n updateStatus();\n });\n\n function updateStatus() {\n const names = { brush: 'Brush', eraser: 'Eraser', fill: 'Fill' };\n statusEl.textContent = names[tool] + ' · ' + brushSize + 'px' +\n (tool !== 'eraser' ? ' · ' + currentColor : '');\n }\n\n /* ---------- flood fill ---------- */\n function floodFill(cssX, cssY) {\n const dpr = window.devicePixelRatio || 1;\n const x = Math.floor(cssX * dpr), y = Math.floor(cssY * dpr);\n const w = canvas.width, h = canvas.height;\n if (x < 0 || y < 0 || x >= w || y >= h) return;\n const img = ctx.getImageData(0, 0, w, h);\n const d = img.data;\n const si = (y * w + x) * 4;\n const tr = d[si], tg = d[si+1], tb = d[si+2], ta = d[si+3];\n const fr = parseInt(currentColor.slice(1,3),16);\n const fg = parseInt(currentColor.slice(3,5),16);\n const fb = parseInt(currentColor.slice(5,7),16);\n if (Math.abs(tr-fr) < 4 && Math.abs(tg-fg) < 4 &&\n Math.abs(tb-fb) < 4 && ta === 255) return;\n const tol = 48, tol2 = tol * tol * 4;\n const visited = new Uint8Array(w * h);\n const stack = new Int32Array(w * h);\n let sp = 0;\n stack[sp++] = y * w + x;\n while (sp > 0) {\n const i = stack[--sp];\n if (visited[i]) continue;\n const p = i * 4;\n const dr = d[p]-tr, dg = d[p+1]-tg, db = d[p+2]-tb, da = d[p+3]-ta;\n if (dr*dr + dg*dg + db*db + da*da > tol2) continue;\n visited[i] = 1;\n d[p] = fr; d[p+1] = fg; d[p+2] = fb; d[p+3] = 255;\n const cx = i % w;\n if (cx > 0 && sp < stack.length) stack[sp++] = i - 1;\n if (cx < w-1 && sp < stack.length) stack[sp++] = i + 1;\n if (i >= w && sp < stack.length) stack[sp++] = i - w;\n if (i < w*(h-1) && sp < stack.length) stack[sp++] = i + w;\n }\n ctx.putImageData(img, 0, 0);\n }\n\n /* ---------- pointer drawing ---------- */\n function getPos(e) {\n const r = canvas.getBoundingClientRect();\n return { x: e.clientX - r.left, y: e.clientY - r.top };\n }\n\n canvas.addEventListener('pointerdown', e => {\n e.preventDefault();\n canvas.setPointerCapture(e.pointerId);\n const p = getPos(e);\n if (tool === 'fill') {\n snapshot();\n floodFill(p.x, p.y);\n queueSave();\n return;\n }\n drawing = true;\n snapshot();\n lastX = p.x; lastY = p.y;\n midX = p.x; midY = p.y;\n\n if (tool === 'eraser') {\n ctx.globalCompositeOperation = 'destination-out';\n ctx.strokeStyle = '#000000';\n ctx.fillStyle = '#000000';\n } else {\n ctx.globalCompositeOperation = 'source-over';\n ctx.strokeStyle = currentColor;\n ctx.fillStyle = currentColor;\n }\n\n const pressure = (e.pointerType === 'pen' && e.pressure > 0)\n ? 0.4 + e.pressure : 1;\n ctx.lineWidth = brushSize * pressure;\n ctx.beginPath();\n ctx.arc(p.x, p.y, ctx.lineWidth / 2, 0, Math.PI * 2);\n ctx.fill();\n });\n\n canvas.addEventListener('pointermove', e => {\n if (!drawing) return;\n e.preventDefault();\n const p = getPos(e);\n\n if (tool === 'eraser') {\n ctx.globalCompositeOperation = 'destination-out';\n ctx.strokeStyle = '#000000';\n } else {\n ctx.globalCompositeOperation = 'source-over';\n ctx.strokeStyle = currentColor;\n }\n\n const pressure = (e.pointerType === 'pen' && e.pressure > 0)\n ? 0.4 + e.pressure : 1;\n ctx.lineWidth = brushSize * pressure;\n const nmidX = (lastX + p.x) / 2, nmidY = (lastY + p.y) / 2;\n ctx.beginPath();\n ctx.moveTo(midX, midY);\n ctx.quadraticCurveTo(lastX, lastY, nmidX, nmidY);\n ctx.stroke();\n lastX = p.x; lastY = p.y;\n midX = nmidX; midY = nmidY;\n });\n\n function endStroke() {\n if (!drawing) return;\n drawing = false;\n ctx.globalCompositeOperation = 'source-over';\n queueSave();\n }\n canvas.addEventListener('pointerup', endStroke);\n canvas.addEventListener('pointercancel', endStroke);\n\n /* ---------- buttons ---------- */\n document.getElementById('clearBtn').addEventListener('click', () => {\n snapshot();\n ctx.save();\n ctx.setTransform(1, 0, 0, 1, 0, 0);\n ctx.clearRect(0, 0, canvas.width, canvas.height);\n ctx.restore();\n queueSave();\n showToast('Canvas cleared');\n });\n\n document.getElementById('saveBtn').addEventListener('click', () => {\n const a = document.createElement('a');\n a.download = 'paint-' + Date.now() + '.png';\n a.href = canvas.toDataURL('image/png');\n a.click();\n showToast('PNG downloaded');\n });\n\n function showToast(msg) {\n toastEl.textContent = msg;\n toastEl.classList.add('show');\n setTimeout(() => toastEl.classList.remove('show'), 1600);\n }\n\n updateStatus();\n loadDrawing();\n})();\n</script>\n</body>\n</html>" | |
| }, | |
| "currentHash": "d1bed7714607e91af7f1ffc2b81c5e6be7659df910735476192cc5ba52e290af", | |
| "previousHash": null, | |
| "prompt": "Published shared tab", | |
| "publishedAt": "2026-08-25T18:34:39.775Z", | |
| "appId": "dfda7e57-b0ee-4ab3-aa45-5005bd0d4af3" | |
| }, | |
| "appId": "dfda7e57-b0ee-4ab3-aa45-5005bd0d4af3" | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment