Skip to content

Instantly share code, notes, and snippets.

@monperrus
Created August 25, 2026 18:34
Show Gist options
  • Select an option

  • Save monperrus/401107b033641c7209cd6a1a4a643054 to your computer and use it in GitHub Desktop.

Select an option

Save monperrus/401107b033641c7209cd6a1a4a643054 to your computer and use it in GitHub Desktop.
Martin's Super Webapp tab: Paint
{
"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