// ==UserScript== // @name penisland.art – Image Insert // @namespace penisland // @match https://www.penisland.art/draw* // @version 2.0 // @author you // @license MIT // @grant none // @description Insert an image onto the canvas using the site palette, mobile-friendly // ==/UserScript== (function () { 'use strict' // ── Palette (5 skin tones, same order as toolbar) ───────────────────────── const PALETTE = [ '#ffecde', '#f7e0b7', '#d1ab71', '#875d1c', '#382200', ] const PALETTE_RGB = PALETTE.map(hex => { const h = hex.slice(1) return [parseInt(h,16)>>16, (parseInt(h,16)>>8)&255, parseInt(h,16)&255] }) // ── Nearest palette color ───────────────────────────────────────────────── function nearestPalette(r, g, b) { let best = 0, bestD = Infinity for (let i = 0; i < PALETTE_RGB.length; i++) { const [pr,pg,pb] = PALETTE_RGB[i] const d = (r-pr)**2 + (g-pg)**2 + (b-pb)**2 if (d < bestD) { bestD = d; best = i } } return best } // ── Color swatch clicking ───────────────────────────────────────────────── // Toolbar: 3 brush btns, separator, 5 color btns — all w-8 h-8 rounded-full function getColorButtons() { // Color buttons have scale-500 SVGs inside; brush buttons have scale-200/300/default return [...document.querySelectorAll('button.w-8.h-8.rounded-full')].filter(btn => { const svg = btn.querySelector('svg') return svg && svg.className.baseVal && svg.className.baseVal.includes('scale-500') }) } function clickColor(paletteIdx) { const btns = getColorButtons() if (btns[paletteIdx]) btns[paletteIdx].click() } // Click largest brush size (3rd brush button = scale-300) function clickLargeBrush() { const all = [...document.querySelectorAll('button.w-8.h-8.rounded-full')] const brushBtns = all.filter(btn => { const svg = btn.querySelector('svg') if (!svg) return false const cls = svg.className.baseVal || '' return !cls.includes('scale-500') && !cls.includes('pointer-events-none') }) // 3rd brush = index 2 = largest if (brushBtns[2]) brushBtns[2].click() } // ── Quantize image to palette pixels ───────────────────────────────────── function quantize(img, res, whiteThreshold) { const oc = document.createElement('canvas') oc.width = oc.height = res const ctx = oc.getContext('2d') ctx.drawImage(img, 0, 0, res, res) const { data } = ctx.getImageData(0, 0, res, res) // Returns array[y][x] = paletteIdx or -1 (transparent/white) const grid = [] for (let y = 0; y < res; y++) { grid[y] = [] for (let x = 0; x < res; x++) { const i = (y * res + x) * 4 const r = data[i], g = data[i+1], b = data[i+2], a = data[i+3] if (a < 20 || (r > whiteThreshold && g > whiteThreshold && b > whiteThreshold)) { grid[y][x] = -1 } else { grid[y][x] = nearestPalette(r, g, b) } } } return grid } // ── Preview canvas ──────────────────────────────────────────────────────── function renderPreview(img, previewCanvas, res, whiteThreshold) { const grid = quantize(img, res, whiteThreshold) previewCanvas.width = res previewCanvas.height = res const ctx = previewCanvas.getContext('2d') for (let y = 0; y < res; y++) { for (let x = 0; x < res; x++) { const p = grid[y][x] ctx.fillStyle = p === -1 ? '#ffffff' : PALETTE[p] ctx.fillRect(x, y, 1, 1) } } return grid } // ── Draw onto canvas via pointer events ────────────────────────────────── // KEY FIX: use getBoundingClientRect on the SVG, fire pointer events // using clientX/Y relative to that rect. Draw horizontal lines per row. async function drawGrid(svgEl, grid, res, onProgress) { const rect = svgEl.getBoundingClientRect() const W = rect.width, H = rect.height const cellW = W / res, cellH = H / res function px(x) { return rect.left + (x + 0.5) * cellW } function py(y) { return rect.top + (y + 0.5) * cellH } function fire(type, cx, cy) { svgEl.dispatchEvent(new PointerEvent(type, { bubbles: true, cancelable: true, isPrimary: true, pointerId: 1, pointerType: 'touch', clientX: cx, clientY: cy, pressure: (type !== 'pointerup') ? 0.5 : 0, })) } // Build strokes grouped by color: for each color, collect horizontal runs // A run is a consecutive sequence of same-color pixels in a row const strokes = {} // paletteIdx -> [{x1,x2,y}] for (let y = 0; y < res; y++) { let runStart = -1, runColor = -1 for (let x = 0; x <= res; x++) { const c = x < res ? grid[y][x] : -1 if (c !== runColor) { if (runColor !== -1 && runStart !== -1) { if (!strokes[runColor]) strokes[runColor] = [] strokes[runColor].push({ x1: runStart, x2: x - 1, y }) } runColor = c runStart = x } } } const totalStrokes = Object.values(strokes).reduce((s, a) => s + a.length, 0) let done = 0 for (const [colorIdx, runs] of Object.entries(strokes)) { clickColor(parseInt(colorIdx)) await sleep(120) // wait for React state to update for (const { x1, x2, y } of runs) { const cx1 = px(x1), cx2 = px(x2), cy = py(y) fire('pointerdown', cx1, cy) if (x2 > x1) { // Add intermediate move points for smoother lines const steps = Math.max(1, Math.round((x2 - x1) / 2)) for (let s = 1; s <= steps; s++) { const mx = cx1 + (cx2 - cx1) * (s / steps) fire('pointermove', mx, cy) } } fire('pointerup', cx2, cy) done++ if (done % 30 === 0) { onProgress(Math.round(done / totalStrokes * 100)) await sleep(0) // yield to browser paint } } } onProgress(100) } function sleep(ms) { return new Promise(r => setTimeout(r, ms)) } // ── Wait for element ────────────────────────────────────────────────────── function waitFor(sel, timeout = 10000) { return new Promise((resolve, reject) => { const el = document.querySelector(sel) if (el) return resolve(el) const obs = new MutationObserver(() => { const el = document.querySelector(sel) if (el) { obs.disconnect(); resolve(el) } }) obs.observe(document.body, { childList: true, subtree: true }) setTimeout(() => { obs.disconnect(); reject(new Error('Timeout')) }, timeout) }) } // ── Build UI ────────────────────────────────────────────────────────────── async function init() { const svgEl = await waitFor('#react-sketch-canvas') // Toggle button const toggleBtn = document.createElement('button') toggleBtn.textContent = '🖼️' toggleBtn.style.cssText = [ 'position:fixed', 'bottom:80px', 'right:12px', 'z-index:9999', 'width:48px', 'height:48px', 'border-radius:50%', 'border:2px solid #382200', 'background:white', 'font-size:22px', 'cursor:pointer', 'box-shadow:0 2px 10px rgba(0,0,0,0.25)', 'touch-action:manipulation', 'display:flex', 'align-items:center', 'justify-content:center', ].join(';') document.body.appendChild(toggleBtn) // Panel const panel = document.createElement('div') panel.style.cssText = [ 'position:fixed', 'bottom:140px', 'right:12px', 'z-index:9998', 'background:white', 'border:1px solid #d1d5db', 'border-radius:12px', 'padding:14px', 'width:min(310px,92vw)', 'display:none', 'box-shadow:0 4px 20px rgba(0,0,0,0.18)', 'font-family:sans-serif', 'font-size:13px', ].join(';') document.body.appendChild(panel) toggleBtn.addEventListener('click', () => { panel.style.display = panel.style.display === 'none' ? 'block' : 'none' }) // Palette dots const palRow = Object.assign(document.createElement('div'), { innerHTML: 'Palette:' }) palRow.style.cssText = 'display:flex;align-items:center;margin-bottom:10px;flex-wrap:wrap;gap:4px' PALETTE.forEach(hex => { const d = document.createElement('div') d.style.cssText = `width:20px;height:20px;border-radius:50%;background:${hex};border:1px solid #9ca3af;flex-shrink:0` d.title = hex palRow.appendChild(d) }) panel.appendChild(palRow) // File input const fileWrap = document.createElement('label') fileWrap.style.cssText = 'display:block;margin-bottom:10px;cursor:pointer' fileWrap.innerHTML = '