Created
May 17, 2026 17:24
-
-
Save Alexhha/4ac16518e39bc34ace7882defabfea26 to your computer and use it in GitHub Desktop.
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
| <style> | |
| * { box-sizing: border-box; margin: 0; padding: 0; touch-action: none; user-select: none; } | |
| #app { display: flex; flex-direction: column; height: 100vh; min-height: 500px; background: #fff; } | |
| #toolbar { | |
| display: flex; align-items: center; gap: 6px; | |
| padding: 8px 10px; | |
| background: #f9f7f2; | |
| border-bottom: 2px solid #e8e3d8; | |
| flex-wrap: nowrap; overflow-x: auto; | |
| } | |
| .tool-btn { | |
| display: flex; flex-direction: column; align-items: center; justify-content: center; | |
| min-width: 52px; height: 52px; padding: 4px; | |
| border-radius: 12px; border: 2px solid transparent; | |
| background: #fff; cursor: pointer; | |
| transition: transform 0.1s, border-color 0.1s; gap: 2px; flex-shrink: 0; | |
| } | |
| .tool-btn:active { transform: scale(0.93); } | |
| .tool-btn.active { border-color: #378add; background: #e6f1fb; } | |
| .tool-icon { font-size: 24px; line-height: 1; } | |
| .tool-label { font-size: 10px; color: #888; font-family: sans-serif; white-space: nowrap; } | |
| .tool-btn.active .tool-label { color: #185fa5; } | |
| .sep { width: 1px; height: 40px; background: #e0dbd0; margin: 0 2px; flex-shrink: 0; } | |
| #size-wrap { display: flex; flex-direction: column; align-items: center; gap: 2px; flex-shrink: 0; } | |
| #size-slider { width: 80px; accent-color: #378add; } | |
| #size-label { font-size: 11px; color: #888; font-family: sans-serif; } | |
| #palette { display: flex; gap: 5px; align-items: center; flex-wrap: wrap; max-width: 170px; } | |
| .color-dot { | |
| width: 30px; height: 30px; border-radius: 50%; | |
| border: 3px solid transparent; cursor: pointer; | |
| transition: transform 0.1s; flex-shrink: 0; | |
| } | |
| .color-dot.active { border-color: #333; transform: scale(1.25); } | |
| #color-picker { width: 36px; height: 36px; border-radius: 50%; border: 2px solid #ddd; cursor: pointer; padding: 1px; background: none; flex-shrink: 0; } | |
| #clear-btn { | |
| min-width: 44px; height: 44px; border-radius: 12px; | |
| border: 2px solid #fca5a5; background: #fff; cursor: pointer; | |
| font-size: 20px; display: flex; align-items: center; justify-content: center; | |
| transition: transform 0.1s; flex-shrink: 0; | |
| } | |
| #clear-btn:active { transform: scale(0.93); background: #fee2e2; } | |
| #upload-btn { | |
| min-width: 52px; height: 52px; border-radius: 12px; | |
| border: 2px solid #9FE1CB; background: #fff; cursor: pointer; | |
| display: flex; flex-direction: column; align-items: center; justify-content: center; | |
| gap: 2px; flex-shrink: 0; transition: transform 0.1s; | |
| } | |
| #upload-btn:active { transform: scale(0.93); background: #E1F5EE; } | |
| #upload-btn .tool-label { color: #0F6E56; } | |
| #file-input { display: none; } | |
| #canvas-wrap { flex: 1; overflow: hidden; position: relative; background: #fff; cursor: crosshair; } | |
| canvas { display: block; width: 100%; height: 100%; } | |
| #drop-hint { | |
| position: absolute; inset: 0; display: flex; flex-direction: column; | |
| align-items: center; justify-content: center; gap: 12px; | |
| pointer-events: none; opacity: 0.35; | |
| } | |
| #drop-hint .big { font-size: 64px; } | |
| #drop-hint .txt { font-size: 16px; font-family: sans-serif; color: #888; } | |
| </style> | |
| <h2 class="sr-only">Рисовалка для детей с загрузкой фона для раскрашивания</h2> | |
| <div id="app"> | |
| <div id="toolbar"> | |
| <button class="tool-btn active" id="btn-pencil" data-tool="pencil"> | |
| <span class="tool-icon">✏️</span><span class="tool-label">Карандаш</span> | |
| </button> | |
| <button class="tool-btn" id="btn-brush" data-tool="brush"> | |
| <span class="tool-icon">🖌️</span><span class="tool-label">Кисточка</span> | |
| </button> | |
| <button class="tool-btn" id="btn-marker" data-tool="marker"> | |
| <span class="tool-icon">🖊️</span><span class="tool-label">Маркер</span> | |
| </button> | |
| <button class="tool-btn" id="btn-chalk" data-tool="chalk"> | |
| <span class="tool-icon">🖍️</span><span class="tool-label">Мелок</span> | |
| </button> | |
| <button class="tool-btn" id="btn-eraser" data-tool="eraser"> | |
| <span class="tool-icon">🧹</span><span class="tool-label">Ластик</span> | |
| </button> | |
| <div class="sep"></div> | |
| <div id="size-wrap"> | |
| <input type="range" id="size-slider" min="2" max="50" value="8"> | |
| <span id="size-label">Размер: 8</span> | |
| </div> | |
| <div class="sep"></div> | |
| <div id="palette"></div> | |
| <input type="color" id="color-picker" value="#1a1a1a" title="Свой цвет"> | |
| <div class="sep"></div> | |
| <button id="upload-btn" title="Загрузить картинку для раскраски"> | |
| <span class="tool-icon">🖼️</span> | |
| <span class="tool-label">Раскраска</span> | |
| </button> | |
| <input type="file" id="file-input" accept="image/*"> | |
| <div class="sep"></div> | |
| <button id="clear-btn" title="Очистить рисунок">🗑️</button> | |
| </div> | |
| <div id="canvas-wrap"> | |
| <canvas id="bg-canvas"></canvas> | |
| <canvas id="canvas" style="position:absolute;inset:0;"></canvas> | |
| <div id="drop-hint"> | |
| <div class="big">🎨</div> | |
| <div class="txt">Нажми 🖼️ чтобы загрузить раскраску</div> | |
| </div> | |
| </div> | |
| </div> | |
| <script> | |
| const COLORS = ['#1a1a1a','#e24b4a','#ef9f27','#3b6d11','#378add','#d4537e','#7f77dd','#1d9e75','#ffffff','#a0522d']; | |
| const palette = document.getElementById('palette'); | |
| let currentColor = '#1a1a1a'; | |
| let currentTool = 'pencil'; | |
| COLORS.forEach(c => { | |
| const dot = document.createElement('div'); | |
| dot.className = 'color-dot' + (c === currentColor ? ' active' : ''); | |
| dot.style.background = c; | |
| if (c === '#ffffff') dot.style.boxShadow = 'inset 0 0 0 1px #ccc'; | |
| dot.addEventListener('pointerdown', e => { | |
| e.stopPropagation(); | |
| document.querySelectorAll('.color-dot').forEach(d => d.classList.remove('active')); | |
| dot.classList.add('active'); | |
| currentColor = c; | |
| document.getElementById('color-picker').value = c; | |
| if (currentTool === 'eraser') setTool('pencil'); | |
| }); | |
| palette.appendChild(dot); | |
| }); | |
| document.getElementById('color-picker').addEventListener('input', e => { | |
| currentColor = e.target.value; | |
| document.querySelectorAll('.color-dot').forEach(d => d.classList.remove('active')); | |
| if (currentTool === 'eraser') setTool('pencil'); | |
| }); | |
| function setTool(t) { | |
| currentTool = t; | |
| document.querySelectorAll('.tool-btn').forEach(b => b.classList.remove('active')); | |
| const btn = document.getElementById('btn-' + t); | |
| if (btn) btn.classList.add('active'); | |
| } | |
| document.querySelectorAll('.tool-btn').forEach(btn => { | |
| btn.addEventListener('pointerdown', e => { e.preventDefault(); setTool(btn.dataset.tool); }); | |
| }); | |
| document.getElementById('clear-btn').addEventListener('pointerdown', e => { | |
| e.preventDefault(); | |
| ctx.clearRect(0, 0, canvas.offsetWidth, canvas.offsetHeight); | |
| }); | |
| const sizeSlider = document.getElementById('size-slider'); | |
| const sizeLabel = document.getElementById('size-label'); | |
| sizeSlider.addEventListener('input', () => { sizeLabel.textContent = 'Размер: ' + sizeSlider.value; }); | |
| const wrap = document.getElementById('canvas-wrap'); | |
| const bgCanvas = document.getElementById('bg-canvas'); | |
| const bgCtx = bgCanvas.getContext('2d'); | |
| const canvas = document.getElementById('canvas'); | |
| const ctx = canvas.getContext('2d'); | |
| const hint = document.getElementById('drop-hint'); | |
| let bgImage = null; | |
| function resize() { | |
| const dpr = window.devicePixelRatio || 1; | |
| const w = wrap.offsetWidth; const h = wrap.offsetHeight; | |
| const tmp = ctx.getImageData(0, 0, canvas.width, canvas.height); | |
| [bgCanvas, canvas].forEach(c => { | |
| c.width = w * dpr; c.height = h * dpr; | |
| c.style.width = w + 'px'; c.style.height = h + 'px'; | |
| c.getContext('2d').scale(dpr, dpr); | |
| }); | |
| if (bgImage) drawBg(); | |
| ctx.putImageData(tmp, 0, 0); | |
| } | |
| function drawBg() { | |
| const w = bgCanvas.offsetWidth, h = bgCanvas.offsetHeight; | |
| bgCtx.clearRect(0, 0, w, h); | |
| bgCtx.fillStyle = '#ffffff'; | |
| bgCtx.fillRect(0, 0, w, h); | |
| const iw = bgImage.naturalWidth, ih = bgImage.naturalHeight; | |
| const scale = Math.min(w / iw, h / ih); | |
| const dw = iw * scale, dh = ih * scale; | |
| const dx = (w - dw) / 2, dy = (h - dh) / 2; | |
| bgCtx.drawImage(bgImage, dx, dy, dw, dh); | |
| } | |
| resize(); | |
| window.addEventListener('resize', resize); | |
| document.getElementById('upload-btn').addEventListener('pointerdown', e => { | |
| e.preventDefault(); | |
| document.getElementById('file-input').click(); | |
| }); | |
| document.getElementById('file-input').addEventListener('change', e => { | |
| const file = e.target.files[0]; | |
| if (!file) return; | |
| const reader = new FileReader(); | |
| reader.onload = ev => { | |
| const img = new Image(); | |
| img.onload = () => { | |
| bgImage = img; | |
| drawBg(); | |
| ctx.clearRect(0, 0, canvas.offsetWidth, canvas.offsetHeight); | |
| hint.style.display = 'none'; | |
| }; | |
| img.src = ev.target.result; | |
| }; | |
| reader.readAsDataURL(file); | |
| e.target.value = ''; | |
| }); | |
| let drawing = false, lastX = 0, lastY = 0; | |
| function getPos(e) { | |
| const rect = canvas.getBoundingClientRect(); | |
| return { | |
| x: (e.clientX - rect.left) * (canvas.offsetWidth / rect.width), | |
| y: (e.clientY - rect.top) * (canvas.offsetHeight / rect.height) | |
| }; | |
| } | |
| function getPressure(e) { | |
| if (e.pressure > 0 && e.pointerType !== 'mouse') return e.pressure; | |
| return 0.5; | |
| } | |
| function drawStroke(x1, y1, x2, y2, e) { | |
| const base = parseInt(sizeSlider.value); | |
| const pressure = getPressure(e); | |
| if (currentTool === 'eraser') { | |
| ctx.globalCompositeOperation = 'destination-out'; | |
| ctx.globalAlpha = 1; | |
| ctx.strokeStyle = 'rgba(0,0,0,1)'; | |
| ctx.lineWidth = base * 2.5; | |
| ctx.lineCap = 'round'; ctx.lineJoin = 'round'; | |
| ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); | |
| ctx.globalCompositeOperation = 'source-over'; | |
| return; | |
| } | |
| ctx.globalCompositeOperation = 'source-over'; | |
| if (currentTool === 'pencil') { | |
| ctx.globalAlpha = 0.85; | |
| ctx.strokeStyle = currentColor; | |
| ctx.lineWidth = Math.max(1, base * 0.5 * pressure * 2); | |
| ctx.lineCap = 'round'; ctx.lineJoin = 'round'; | |
| ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); | |
| } else if (currentTool === 'brush') { | |
| ctx.globalAlpha = 0.12; | |
| const size = base * 1.8 * pressure; | |
| const steps = Math.ceil(Math.hypot(x2-x1, y2-y1) / (size * 0.3)) + 1; | |
| for (let i = 0; i <= steps; i++) { | |
| const t = steps === 0 ? 0 : i / steps; | |
| ctx.fillStyle = currentColor; | |
| ctx.beginPath(); | |
| ctx.arc(x1+(x2-x1)*t, y1+(y2-y1)*t, size, 0, Math.PI*2); | |
| ctx.fill(); | |
| } | |
| } else if (currentTool === 'marker') { | |
| ctx.globalAlpha = 0.55; | |
| ctx.strokeStyle = currentColor; | |
| ctx.lineWidth = base * 1.6 * pressure; | |
| ctx.lineCap = 'square'; ctx.lineJoin = 'miter'; | |
| ctx.beginPath(); ctx.moveTo(x1, y1); ctx.lineTo(x2, y2); ctx.stroke(); | |
| } else if (currentTool === 'chalk') { | |
| const size = base * 0.9 * pressure; | |
| const dist = Math.hypot(x2-x1, y2-y1); | |
| const steps = Math.max(1, Math.ceil(dist / 2)); | |
| for (let i = 0; i <= steps; i++) { | |
| const t = i / steps; | |
| const bx = x1+(x2-x1)*t + (Math.random()-0.5)*size*0.8; | |
| const by = y1+(y2-y1)*t + (Math.random()-0.5)*size*0.8; | |
| ctx.globalAlpha = Math.random()*0.4+0.1; | |
| ctx.fillStyle = currentColor; | |
| ctx.beginPath(); | |
| ctx.ellipse(bx, by, size*0.4+Math.random()*size*0.3, size*0.2, Math.random()*Math.PI, 0, Math.PI*2); | |
| ctx.fill(); | |
| } | |
| } | |
| ctx.globalAlpha = 1; | |
| } | |
| canvas.addEventListener('pointerdown', e => { | |
| e.preventDefault(); drawing = true; | |
| canvas.setPointerCapture(e.pointerId); | |
| const pos = getPos(e); lastX = pos.x; lastY = pos.y; | |
| drawStroke(pos.x, pos.y, pos.x+0.1, pos.y+0.1, e); | |
| }); | |
| canvas.addEventListener('pointermove', e => { | |
| if (!drawing) return; e.preventDefault(); | |
| const pos = getPos(e); | |
| drawStroke(lastX, lastY, pos.x, pos.y, e); | |
| lastX = pos.x; lastY = pos.y; | |
| }); | |
| canvas.addEventListener('pointerup', () => { drawing = false; }); | |
| canvas.addEventListener('pointercancel', () => { drawing = false; }); | |
| </script> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment